Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
18 changes: 14 additions & 4 deletions plugins/claude-code/scripts/jieli_node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 || "");
Expand Down Expand Up @@ -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)}`);
}
Expand Down
37 changes: 37 additions & 0 deletions plugins/claude-code/tests/runtime-node.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading