diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 2dfbd22..aca93c2 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jieli", "displayName": "Jieli", - "version": "0.1.24", + "version": "0.1.25", "description": "Sync Claude Code sessions to Jieli threads and attach Jieli thread trailers to Claude-created commits.", "author": { "name": "Jieli" diff --git a/plugins/claude-code/scripts/jieli_node.mjs b/plugins/claude-code/scripts/jieli_node.mjs index 9412f5d..73f82f7 100644 --- a/plugins/claude-code/scripts/jieli_node.mjs +++ b/plugins/claude-code/scripts/jieli_node.mjs @@ -50,6 +50,7 @@ const CODEX_AUTO_COMPACTION_PREAMBLE_RE = const AUTO_COMPACTION_CURRENT_PROGRESS_RE = /^Current progress:\s*(?:\n|$)/i; const AUTO_COMPACTION_SECTION_RE = /^(?:Important context(?: and constraints)?|What remains to do|Relevant files|Critical examples):\s*$/im; const BASH_NO_OUTPUT_MARKERS = new Set(["", "(Bash completed with no output)"]); +const QUOTA_NOTICE_FILE = "quota-notices.json"; const SUPPORTED_IMAGE_MEDIA_TYPES = new Map([ [".png", "image/png"], [".jpg", "image/jpeg"], @@ -256,10 +257,18 @@ function buildMissingConfigHookResponse(trigger, missing) { }; } -function buildQuotaExceededHookResponse(trigger, error) { - if (String(trigger || "").toLowerCase() !== "sessionstart") return {}; +function buildQuotaExceededHookResponse(trigger, error, sessionId = "") { + if (String(trigger || "").toLowerCase() !== "stop") return {}; const message = formatError(error); if (!/\b409\b/.test(message) || !/quota/i.test(message)) return {}; + const cleanSessionId = String(sessionId || "").trim(); + if (!cleanSessionId) return {}; + const path = join(homeDir(), ".jieli", QUOTA_NOTICE_FILE); + const notices = readJson(path, {}); + const key = `claude_code|${cleanSessionId}|message-quota-exceeded`; + if (notices[key]) return {}; + notices[key] = new Date().toISOString(); + writeJsonAtomic(path, notices); return { continue: true, systemMessage: @@ -392,8 +401,9 @@ function loadHookStdin() { async function syncMain(args) { const opts = parseArgs(args, { boolean: new Set(["jieli-hook"]) }); + let hookData = {}; try { - const hookData = loadHookStdin(); + hookData = loadHookStdin(); hookData.trigger = opts.trigger || ""; writeHandoffContext(hookData); captureBaselineFromHook(hookData, opts.trigger || ""); @@ -422,7 +432,7 @@ async function syncMain(args) { releaseSyncLock(lock); } } catch (error) { - const response = buildQuotaExceededHookResponse(opts.trigger || "", error); + const response = buildQuotaExceededHookResponse(opts.trigger || "", error, hookData.session_id || ""); if (Object.keys(response).length) console.log(JSON.stringify(response)); logHookError(`sync ${opts.trigger || ""}: ${formatError(error)}`); } diff --git a/plugins/claude-code/tests/runtime-node.test.mjs b/plugins/claude-code/tests/runtime-node.test.mjs index b192916..f1b185e 100644 --- a/plugins/claude-code/tests/runtime-node.test.mjs +++ b/plugins/claude-code/tests/runtime-node.test.mjs @@ -650,6 +650,43 @@ test("sync CLI reports missing config and skips missing transcripts without fail assert.throws(() => statSync(join(transcriptHome, ".jieli", "hooks.log"))); }); +test("sync CLI reports message quota exceeded only once per session on stop", async () => { + const home = makeTempDir(); + const transcript = join(home, "projects", "quota-session.jsonl"); + mkdirSync(dirname(transcript), { recursive: true }); + writeJsonl(transcript, [ + { type: "user", uuid: "u-quota", sessionId: "cc-quota", cwd: "/Users/alice/work/jieli", gitBranch: "main", message: { role: "user", content: "sync me" } }, + { type: "assistant", uuid: "a-quota", sessionId: "cc-quota", cwd: "/Users/alice/work/jieli", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } }, + ]); + const { server } = createMockJieliServer({ uploadStatus: 409, uploadResponse: { success: false, message: "message quota exceeded", data: null } }); + const baseUrl = await listen(server); + const env = { HOME: home, PATH: process.env.PATH, JIELI_API_KEY: "secret", JIELI_BASE_URL: baseUrl }; + try { + const sessionStart = await runNode([join(pluginRoot, "scripts", "sync.mjs"), "--trigger", "sessionstart", "--jieli-hook"], { + input: JSON.stringify({ transcript_path: transcript, session_id: "cc-quota", cwd: "/Users/alice/work/jieli" }), + env, + }); + assert.equal(sessionStart.status, 0); + assert.equal(sessionStart.stdout, ""); + + const firstStop = await runNode([join(pluginRoot, "scripts", "sync.mjs"), "--trigger", "stop", "--jieli-hook"], { + input: JSON.stringify({ transcript_path: transcript, session_id: "cc-quota", cwd: "/Users/alice/work/jieli" }), + env, + }); + assert.equal(firstStop.status, 0); + assert.match(JSON.parse(firstStop.stdout).systemMessage, /message quota exceeded/); + + const secondStop = await runNode([join(pluginRoot, "scripts", "sync.mjs"), "--trigger", "stop", "--jieli-hook"], { + input: JSON.stringify({ transcript_path: transcript, session_id: "cc-quota", cwd: "/Users/alice/work/jieli" }), + env, + }); + assert.equal(secondStop.status, 0); + assert.equal(secondStop.stdout, ""); + } finally { + await close(server); + } +}); + test("pretool hook uploads a Claude session that sessionstart saw before transcript flush", async () => { const home = makeTempDir(); const transcript = join(home, "projects", "late-session.jsonl"); diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index a36ed0d..e170f1a 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jieli", - "version": "0.1.24", + "version": "0.1.25", "description": "Sync Codex sessions to Jieli threads and attach Jieli thread trailers to Codex-created commits.", "author": { "name": "Jieli"