sideby 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +8 -1
- package/dist/src/cli/commands.js +330 -9
- package/dist/src/cli/format.d.ts +3 -1
- package/dist/src/cli/format.js +25 -0
- package/dist/src/cli/json-schemas.js +113 -0
- package/dist/src/cli/main.d.ts +5 -0
- package/dist/src/cli/main.js +40 -8
- package/dist/src/core/config.d.ts +41 -0
- package/dist/src/core/config.js +86 -11
- package/dist/src/core/file-memo.d.ts +23 -0
- package/dist/src/core/file-memo.js +37 -0
- package/dist/src/core/handoff-brief.d.ts +17 -0
- package/dist/src/core/handoff-brief.js +75 -0
- package/dist/src/core/handoff-chain.d.ts +74 -0
- package/dist/src/core/handoff-chain.js +74 -0
- package/dist/src/core/handoff-doctor.d.ts +13 -0
- package/dist/src/core/handoff-doctor.js +35 -0
- package/dist/src/core/handoff-run.d.ts +31 -0
- package/dist/src/core/handoff-run.js +346 -0
- package/dist/src/core/handoff-select.d.ts +42 -0
- package/dist/src/core/handoff-select.js +88 -0
- package/dist/src/core/handoff-status.d.ts +66 -0
- package/dist/src/core/handoff-status.js +198 -0
- package/dist/src/core/launch.d.ts +22 -1
- package/dist/src/core/launch.js +54 -4
- package/dist/src/core/memo-store.d.ts +20 -0
- package/dist/src/core/memo-store.js +92 -0
- package/dist/src/core/resume.d.ts +7 -2
- package/dist/src/core/resume.js +22 -9
- package/dist/src/core/tuning-apply.d.ts +51 -0
- package/dist/src/core/tuning-apply.js +237 -0
- package/dist/src/core/tuning.d.ts +98 -0
- package/dist/src/core/tuning.js +176 -0
- package/dist/src/families/claude/brief.d.ts +5 -0
- package/dist/src/families/claude/brief.js +63 -0
- package/dist/src/families/claude/handoff-hook.d.ts +2 -0
- package/dist/src/families/claude/handoff-hook.js +31 -0
- package/dist/src/families/claude/handoff.d.ts +5 -0
- package/dist/src/families/claude/handoff.js +115 -0
- package/dist/src/families/claude/index.js +8 -1
- package/dist/src/families/claude/tuning.d.ts +22 -0
- package/dist/src/families/claude/tuning.js +187 -0
- package/dist/src/families/claude/usage.d.ts +1 -0
- package/dist/src/families/claude/usage.js +2 -2
- package/dist/src/families/codex/brief.d.ts +5 -0
- package/dist/src/families/codex/brief.js +49 -0
- package/dist/src/families/codex/handoff-hook.d.ts +2 -0
- package/dist/src/families/codex/handoff-hook.js +16 -0
- package/dist/src/families/codex/handoff.d.ts +5 -0
- package/dist/src/families/codex/handoff.js +121 -0
- package/dist/src/families/codex/index.js +6 -0
- package/dist/src/families/codex/rate-limits.d.ts +16 -0
- package/dist/src/families/codex/rate-limits.js +92 -0
- package/dist/src/families/codex/rollout.d.ts +1 -2
- package/dist/src/families/codex/rollout.js +4 -48
- package/dist/src/families/codex/tuning.d.ts +16 -0
- package/dist/src/families/codex/tuning.js +145 -0
- package/dist/src/families/grok/brief.d.ts +5 -0
- package/dist/src/families/grok/brief.js +74 -0
- package/dist/src/families/grok/handoff-hook.d.ts +4 -0
- package/dist/src/families/grok/handoff-hook.js +26 -0
- package/dist/src/families/grok/handoff.d.ts +47 -0
- package/dist/src/families/grok/handoff.js +219 -0
- package/dist/src/families/grok/index.js +2 -0
- package/dist/src/families/pi/handoff.d.ts +2 -0
- package/dist/src/families/pi/handoff.js +13 -0
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/families/shared/brief.d.ts +24 -0
- package/dist/src/families/shared/brief.js +42 -0
- package/dist/src/families/shared/handoff-args.d.ts +18 -0
- package/dist/src/families/shared/handoff-args.js +57 -0
- package/dist/src/families/shared/sessions.d.ts +4 -0
- package/dist/src/families/shared/sessions.js +19 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +30 -0
- package/dist/src/handoff/hook.d.ts +16 -0
- package/dist/src/handoff/hook.js +166 -0
- package/dist/src/handoff/messages.d.ts +7 -0
- package/dist/src/handoff/messages.js +27 -0
- package/dist/src/handoff/reader.d.ts +11 -0
- package/dist/src/handoff/reader.js +9 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +110 -0
- package/dist/src/panel/i18n.d.ts +96 -0
- package/dist/src/panel/i18n.js +192 -0
- package/dist/src/panel/page-logic.d.ts +49 -0
- package/dist/src/panel/page-logic.js +67 -0
- package/dist/src/panel/page-script.js +454 -4
- package/dist/src/panel/page-styles.js +28 -2
- package/dist/src/panel/page.js +4 -1
- package/dist/src/quota/claude-statusline.js +4 -7
- package/dist/src/runtime.d.ts +40 -3
- package/dist/src/runtime.js +226 -43
- package/dist/src/types.d.ts +101 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +1 -0
- package/package.json +16 -4
- package/schemas/config.json +77 -0
- package/schemas/families.json +5 -1
- package/schemas/handoff-disable.json +106 -0
- package/schemas/handoff-enable.json +106 -0
- package/schemas/handoff-ready.json +23 -0
- package/schemas/handoff-setup.json +60 -0
- package/schemas/handoff-status.json +137 -0
- package/schemas/handoff-teardown.json +30 -0
- package/schemas/list.json +5 -1
- package/schemas/tune-change.json +108 -0
- package/schemas/tune.json +243 -0
- package/skills/sideby/SKILL.md +7 -4
- package/skills/sideby/references/badge.md +31 -0
- package/skills/sideby/references/diagnose.md +4 -0
- package/skills/sideby/references/handoff.md +24 -2
- package/skills/sideby/references/setup.md +3 -2
- package/README.zh-CN.md +0 -112
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// How Claude Code takes part in a Handoff (spec §3.17): hooks through `--settings`, which merges with the
|
|
2
|
+
// Account's own settings hooks (verified 2026-10-09), and the Brief's directory through `--add-dir`.
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { writeFileAtomic } from '../../core/fs-safe.js';
|
|
6
|
+
import { carryArgs, hasOption, promptAfterDashes } from '../shared/handoff-args.js';
|
|
7
|
+
import { claudeBrief } from './brief.js';
|
|
8
|
+
/** From `claude --help`: options that take a value, so it is never mistaken for the old prompt. */
|
|
9
|
+
export const CLAUDE_ARGS = {
|
|
10
|
+
valued: [
|
|
11
|
+
'--agent',
|
|
12
|
+
'--agents',
|
|
13
|
+
'--append-system-prompt',
|
|
14
|
+
'--append-system-prompt-file',
|
|
15
|
+
'--autocompact',
|
|
16
|
+
'--debug-file',
|
|
17
|
+
'--effort',
|
|
18
|
+
'--environment',
|
|
19
|
+
'--fallback-model',
|
|
20
|
+
'--input-format',
|
|
21
|
+
'--json-schema',
|
|
22
|
+
'--max-budget-usd',
|
|
23
|
+
'--model',
|
|
24
|
+
'-n',
|
|
25
|
+
'--name',
|
|
26
|
+
'--output-format',
|
|
27
|
+
'--permission-mode',
|
|
28
|
+
'--permission-prompts',
|
|
29
|
+
'--plugin-dir',
|
|
30
|
+
'--plugin-url',
|
|
31
|
+
'--remote-control-session-name-prefix',
|
|
32
|
+
'--session-id',
|
|
33
|
+
'--setting-sources',
|
|
34
|
+
'--settings',
|
|
35
|
+
'--system-prompt',
|
|
36
|
+
'--system-prompt-file',
|
|
37
|
+
'--system-prompt-snapshot',
|
|
38
|
+
],
|
|
39
|
+
variadic: [
|
|
40
|
+
'--add-dir',
|
|
41
|
+
'--allowedTools',
|
|
42
|
+
'--allowed-tools',
|
|
43
|
+
'--betas',
|
|
44
|
+
'--disallowedTools',
|
|
45
|
+
'--disallowed-tools',
|
|
46
|
+
'--file',
|
|
47
|
+
'--mcp-config',
|
|
48
|
+
'--tools',
|
|
49
|
+
],
|
|
50
|
+
optional: [
|
|
51
|
+
'--cloud',
|
|
52
|
+
'-d',
|
|
53
|
+
'--debug',
|
|
54
|
+
'--from-pr',
|
|
55
|
+
'--prompt-suggestions',
|
|
56
|
+
'--remote-control',
|
|
57
|
+
'-r',
|
|
58
|
+
'--resume',
|
|
59
|
+
'--teleport',
|
|
60
|
+
'-w',
|
|
61
|
+
'--worktree',
|
|
62
|
+
],
|
|
63
|
+
drop: [
|
|
64
|
+
'-r',
|
|
65
|
+
'--resume',
|
|
66
|
+
'-c',
|
|
67
|
+
'--continue',
|
|
68
|
+
'--fork-session',
|
|
69
|
+
'--session-id',
|
|
70
|
+
'--from-pr',
|
|
71
|
+
'--teleport',
|
|
72
|
+
'-n',
|
|
73
|
+
'--name',
|
|
74
|
+
'-p',
|
|
75
|
+
'--print',
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
export const claudeHandoff = {
|
|
79
|
+
starts: ['quota', 'limit'],
|
|
80
|
+
async hookArgs(command, stateDir) {
|
|
81
|
+
const dir = join(stateDir, 'handoff');
|
|
82
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
const hook = (event) => [
|
|
84
|
+
{ hooks: [{ type: 'command', command: command(event) }] },
|
|
85
|
+
];
|
|
86
|
+
const settings = {
|
|
87
|
+
hooks: {
|
|
88
|
+
PostToolUse: hook('PostToolUse'),
|
|
89
|
+
Stop: hook('Stop'),
|
|
90
|
+
StopFailure: [
|
|
91
|
+
{ matcher: 'rate_limit', hooks: [{ type: 'command', command: command('StopFailure') }] },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const file = join(dir, 'claude-settings.json');
|
|
96
|
+
await writeFileAtomic(file, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
|
|
97
|
+
return ['--settings', file];
|
|
98
|
+
},
|
|
99
|
+
dirArgs: (dir) => ['--add-dir', dir],
|
|
100
|
+
promptArgs: promptAfterDashes,
|
|
101
|
+
continueArgs: (args) => carryArgs(args, CLAUDE_ARGS),
|
|
102
|
+
resumeArgs: (id) => ['--resume', id],
|
|
103
|
+
async eligibility(args) {
|
|
104
|
+
if (hasOption(args, ['-p', '--print']))
|
|
105
|
+
return { start: false, receive: false, reason: 'a headless run (`-p`) has nobody to hand over to' };
|
|
106
|
+
if (hasOption(args, ['--settings']))
|
|
107
|
+
return {
|
|
108
|
+
start: false,
|
|
109
|
+
receive: true,
|
|
110
|
+
reason: '`--settings` is already given, so sideby cannot add its hooks',
|
|
111
|
+
};
|
|
112
|
+
return { start: true, receive: true };
|
|
113
|
+
},
|
|
114
|
+
briefFromSession: claudeBrief,
|
|
115
|
+
};
|
|
@@ -5,9 +5,11 @@ import { identityFromJsonFile, identityOf } from '../../core/identity.js';
|
|
|
5
5
|
import { readSecretFile } from '../../core/secret-file.js';
|
|
6
6
|
import { claudeStatusline } from '../../quota/claude-statusline.js';
|
|
7
7
|
import { BUILTIN_LOGOS } from '../logos.js';
|
|
8
|
-
import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
|
|
8
|
+
import { dirWrittenAt, groupedEntryWrittenAt, resumeFlagSession, withoutResumeFlag, } from '../shared/sessions.js';
|
|
9
|
+
import { claudeHandoff } from './handoff.js';
|
|
9
10
|
import { CLAUDE_LAYOUT } from './layout.js';
|
|
10
11
|
import { readClaudeQuota } from './quota.js';
|
|
12
|
+
import { readClaudeTuning } from './tuning.js';
|
|
11
13
|
import { readClaudeUsage } from './usage.js';
|
|
12
14
|
const LINKED = [
|
|
13
15
|
'settings.json',
|
|
@@ -115,9 +117,14 @@ export const claudeFamily = {
|
|
|
115
117
|
identity: claudeIdentity,
|
|
116
118
|
readQuota: readClaudeQuota,
|
|
117
119
|
readUsage: readClaudeUsage,
|
|
120
|
+
readTuning: readClaudeTuning,
|
|
118
121
|
quotaSetup: claudeStatusline,
|
|
119
122
|
resumedSession: resumeFlagSession,
|
|
120
123
|
sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'projects'), `${id}.jsonl`, 'file'),
|
|
124
|
+
// Claude Code makes this directory for SessionStart hooks before the first message is saved (ADR-0009).
|
|
125
|
+
sessionStartedAt: (account, id) => dirWrittenAt(join(account.dir, 'session-env', id)),
|
|
126
|
+
withoutResume: withoutResumeFlag,
|
|
127
|
+
handoff: claudeHandoff,
|
|
121
128
|
};
|
|
122
129
|
export const claudePlugin = {
|
|
123
130
|
name: 'claude',
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { FileMemo } from '../../core/file-memo.ts';
|
|
2
|
+
import type { Account, ReadContext, TuningResult } from '../../types.ts';
|
|
3
|
+
/** Context window of a model, from the table or the records: a context that grew past 200K is a 1M window. */
|
|
4
|
+
export declare const isKnownClaudeModel: (id: string) => boolean;
|
|
5
|
+
/** `[key, ts, session, model, ctx, out, write5m, write1h]` */
|
|
6
|
+
type TurnRow = [string, number, string, string, number, number, number, number];
|
|
7
|
+
/** `[ts, session, model, trigger, pre, post]` */
|
|
8
|
+
type CompactionRow = [number, string, string, string, number, number];
|
|
9
|
+
/** What one session log contributes: only what the replay needs, never message text. */
|
|
10
|
+
interface Summary {
|
|
11
|
+
lines: number;
|
|
12
|
+
parsed: number;
|
|
13
|
+
turns: TurnRow[];
|
|
14
|
+
compactions: CompactionRow[];
|
|
15
|
+
failed: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Per-file summaries per Account directory; a file is parsed again only after it changed. */
|
|
18
|
+
export declare const claudeTuningMemo: FileMemo<Summary>;
|
|
19
|
+
/** The Account's configured Compaction Threshold, or null for the Host default. Reads exactly one key. */
|
|
20
|
+
export declare function readAutoCompactWindow(dir: string): Promise<number | null>;
|
|
21
|
+
export declare function readClaudeTuning(account: Account, ctx: ReadContext): Promise<TuningResult>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Context tuning for a Claude Code Account (spec §3.18): replays its recent session logs against candidate
|
|
2
|
+
// Compaction Thresholds. Read-only and local. The one Host config value read is `autoCompactWindow` from the
|
|
3
|
+
// Account's `settings.json`; no other key of that file is returned, logged or cached.
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { FileMemo } from '../../core/file-memo.js';
|
|
9
|
+
import { USAGE_DAYS } from '../../core/quota-levels.js';
|
|
10
|
+
import { getOrCreate, tuningResult, } from '../../core/tuning.js';
|
|
11
|
+
import { listJsonl } from './usage.js';
|
|
12
|
+
const DAY_MS = 86_400_000;
|
|
13
|
+
const SETTINGS_MAX_BYTES = 1_000_000;
|
|
14
|
+
/** Context window of a model, from the table or the records: a context that grew past 200K is a 1M window. */
|
|
15
|
+
export const isKnownClaudeModel = (id) => id.startsWith('claude-');
|
|
16
|
+
const windowOf = (maxCtx) => (maxCtx > 200_000 ? 1_000_000 : 200_000);
|
|
17
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
18
|
+
async function parseFile(file, since) {
|
|
19
|
+
const out = { lines: 0, parsed: 0, turns: [], compactions: [], failed: false };
|
|
20
|
+
const byKey = new Map();
|
|
21
|
+
const lastModel = new Map();
|
|
22
|
+
// Compactions logged before the first assistant row of their session in this file: they take that row's model.
|
|
23
|
+
const orphans = new Map();
|
|
24
|
+
try {
|
|
25
|
+
const rl = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
|
|
26
|
+
for await (const line of rl) {
|
|
27
|
+
if (!line.trim())
|
|
28
|
+
continue;
|
|
29
|
+
out.lines++;
|
|
30
|
+
let row;
|
|
31
|
+
try {
|
|
32
|
+
row = JSON.parse(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out.parsed++;
|
|
38
|
+
const ts = typeof row?.timestamp === 'string' ? Date.parse(row.timestamp) : Number.NaN;
|
|
39
|
+
const session = typeof row?.sessionId === 'string' ? row.sessionId : file;
|
|
40
|
+
if (row?.type === 'system' && row.subtype === 'compact_boundary') {
|
|
41
|
+
const m = row.compactMetadata;
|
|
42
|
+
const pre = num(m?.preTokens);
|
|
43
|
+
const post = num(m?.postTokens);
|
|
44
|
+
if (!Number.isFinite(ts) || ts < since || !pre || !post)
|
|
45
|
+
continue;
|
|
46
|
+
const t = m?.trigger;
|
|
47
|
+
const entry = [
|
|
48
|
+
ts,
|
|
49
|
+
session,
|
|
50
|
+
lastModel.get(session) ?? '',
|
|
51
|
+
t === 'auto' || t === 'manual' ? t : 'unknown',
|
|
52
|
+
pre,
|
|
53
|
+
post,
|
|
54
|
+
];
|
|
55
|
+
out.compactions.push(entry);
|
|
56
|
+
if (!entry[2])
|
|
57
|
+
orphans.set(session, [...(orphans.get(session) ?? []), entry]);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (row?.type !== 'assistant' || row.isSidechain === true)
|
|
61
|
+
continue;
|
|
62
|
+
const usage = row.message?.usage;
|
|
63
|
+
const model = row.message?.model;
|
|
64
|
+
if (typeof usage !== 'object' ||
|
|
65
|
+
usage === null ||
|
|
66
|
+
typeof model !== 'string' ||
|
|
67
|
+
!model ||
|
|
68
|
+
model === '<synthetic>')
|
|
69
|
+
continue;
|
|
70
|
+
lastModel.set(session, model);
|
|
71
|
+
for (const o of orphans.get(session) ?? [])
|
|
72
|
+
o[2] = model;
|
|
73
|
+
orphans.delete(session);
|
|
74
|
+
if (!Number.isFinite(ts) || ts < since)
|
|
75
|
+
continue;
|
|
76
|
+
const id = row.message?.id;
|
|
77
|
+
const key = typeof id === 'string'
|
|
78
|
+
? id
|
|
79
|
+
: typeof row.requestId === 'string'
|
|
80
|
+
? `request:${row.requestId}`
|
|
81
|
+
: `${file}:${out.lines}`;
|
|
82
|
+
const cc = usage.cache_creation;
|
|
83
|
+
const cacheWrite = num(usage.cache_creation_input_tokens);
|
|
84
|
+
const w1h = Math.min(num(cc?.ephemeral_1h_input_tokens), cacheWrite);
|
|
85
|
+
const ctx = num(usage.input_tokens) + num(usage.cache_read_input_tokens) + cacheWrite;
|
|
86
|
+
// Re-insert so the last line of a message wins, as in the usage reader.
|
|
87
|
+
byKey.delete(key);
|
|
88
|
+
byKey.set(key, [key, ts, session, model, ctx, num(usage.output_tokens), cacheWrite - w1h, w1h]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
out.failed = true;
|
|
93
|
+
}
|
|
94
|
+
out.turns = [...byKey.values()];
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/** Per-file summaries per Account directory; a file is parsed again only after it changed. */
|
|
98
|
+
export const claudeTuningMemo = new FileMemo({ name: 'claude-tuning', version: 2 });
|
|
99
|
+
/** The Account's configured Compaction Threshold, or null for the Host default. Reads exactly one key. */
|
|
100
|
+
export async function readAutoCompactWindow(dir) {
|
|
101
|
+
try {
|
|
102
|
+
const file = join(dir, 'settings.json');
|
|
103
|
+
const st = await stat(file);
|
|
104
|
+
if (!st.isFile() || st.size > SETTINGS_MAX_BYTES)
|
|
105
|
+
return null;
|
|
106
|
+
const json = JSON.parse(await readFile(file, 'utf8'));
|
|
107
|
+
const v = json && typeof json === 'object' ? json.autoCompactWindow : undefined;
|
|
108
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function readClaudeTuning(account, ctx) {
|
|
115
|
+
const files = await listJsonl(join(account.dir, 'projects'));
|
|
116
|
+
if (!files.length)
|
|
117
|
+
return {
|
|
118
|
+
status: 'unavailable',
|
|
119
|
+
reason: 'no-session',
|
|
120
|
+
detail: 'no Claude Code sessions recorded for this account yet',
|
|
121
|
+
};
|
|
122
|
+
const now = ctx.now.getTime();
|
|
123
|
+
const since = now - USAGE_DAYS * DAY_MS;
|
|
124
|
+
const turns = new Map();
|
|
125
|
+
const compactions = [];
|
|
126
|
+
const compactionKeys = new Set();
|
|
127
|
+
let lines = 0;
|
|
128
|
+
let parsed = 0;
|
|
129
|
+
const memo = claudeTuningMemo.pass(account.dir);
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const st = await stat(file).catch(() => null);
|
|
132
|
+
if (!st || st.mtimeMs < since)
|
|
133
|
+
continue;
|
|
134
|
+
const s = await memo.get(file, st, () => parseFile(file, since), (v) => !v.failed);
|
|
135
|
+
lines += s.lines;
|
|
136
|
+
parsed += s.parsed;
|
|
137
|
+
for (const t of s.turns) {
|
|
138
|
+
turns.delete(t[0]);
|
|
139
|
+
turns.set(t[0], t);
|
|
140
|
+
}
|
|
141
|
+
// A resumed session can repeat its history in a new file; one compaction counts once.
|
|
142
|
+
for (const c of s.compactions)
|
|
143
|
+
if (!compactionKeys.has(`${c[1]}:${c[0]}:${c[4]}`)) {
|
|
144
|
+
compactionKeys.add(`${c[1]}:${c[0]}:${c[4]}`);
|
|
145
|
+
compactions.push(c);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
memo.done();
|
|
149
|
+
if (lines > 0 && parsed === 0)
|
|
150
|
+
return {
|
|
151
|
+
status: 'unavailable',
|
|
152
|
+
reason: 'unrecognized',
|
|
153
|
+
detail: `session logs in the last ${USAGE_DAYS} days could not be parsed as JSON lines`,
|
|
154
|
+
};
|
|
155
|
+
// A compaction whose own file never named a model takes the model its session used in another file. The rows
|
|
156
|
+
// belong to the cache, so a resolved copy is made instead of editing them.
|
|
157
|
+
const sessionModel = new Map();
|
|
158
|
+
for (const t of [...turns.values()].sort((x, y) => x[1] - y[1]))
|
|
159
|
+
sessionModel.set(t[2], t[3]);
|
|
160
|
+
const resolved = compactions.map((c) => (c[2] ? c : [c[0], c[1], sessionModel.get(c[1]) ?? '', c[3], c[4], c[5]]));
|
|
161
|
+
// Group per model, then per session; a session that switched models is replayed once per model.
|
|
162
|
+
const byModel = new Map();
|
|
163
|
+
for (const [, ts, session, model, c, out, w5, w1] of turns.values()) {
|
|
164
|
+
if (ts > now || ts < since)
|
|
165
|
+
continue;
|
|
166
|
+
const sessions = getOrCreate(byModel, model, () => new Map());
|
|
167
|
+
const list = getOrCreate(sessions, session, () => []);
|
|
168
|
+
list.push({ ts, ctx: c, out, write5m: w5, write1h: w1 });
|
|
169
|
+
}
|
|
170
|
+
if (!byModel.size)
|
|
171
|
+
return {
|
|
172
|
+
status: 'unavailable',
|
|
173
|
+
reason: 'no-session',
|
|
174
|
+
detail: `no Claude Code turns in the last ${USAGE_DAYS} days`,
|
|
175
|
+
};
|
|
176
|
+
const configured = await readAutoCompactWindow(account.dir);
|
|
177
|
+
const records = [...byModel].map(([model, sessions]) => {
|
|
178
|
+
const list = [...sessions.values()].map((t) => t.sort((a, b) => a.ts - b.ts));
|
|
179
|
+
const maxCtx = Math.max(0, ...list.flat().map((t) => t.ctx));
|
|
180
|
+
const comps = resolved
|
|
181
|
+
.filter((c) => c[2] === model && c[0] >= since && c[0] <= now)
|
|
182
|
+
.map((c) => ({ ts: c[0], trigger: c[3], pre: c[4], post: c[5] }));
|
|
183
|
+
const window = Math.max(windowOf(maxCtx), windowOf(Math.max(0, ...comps.map((c) => c.pre))));
|
|
184
|
+
return { model, sessions: list, compactions: comps, contextWindow: window };
|
|
185
|
+
});
|
|
186
|
+
return tuningResult(USAGE_DAYS, records, { configured, known: isKnownClaudeModel, mayRecommend: true });
|
|
187
|
+
}
|
|
@@ -8,6 +8,7 @@ interface Entry {
|
|
|
8
8
|
cacheRead: number;
|
|
9
9
|
cacheWrite: number;
|
|
10
10
|
}
|
|
11
|
+
export declare function listJsonl(dir: string, out?: string[]): Promise<string[]>;
|
|
11
12
|
/** What one session log holds: its usage rows in file order, keyed for the "last line wins" dedupe. */
|
|
12
13
|
interface Parsed {
|
|
13
14
|
lines: number;
|
|
@@ -9,7 +9,7 @@ import { FileMemo } from '../../core/file-memo.js';
|
|
|
9
9
|
import { USAGE_DAYS } from '../../core/quota-levels.js';
|
|
10
10
|
import { dailyBuckets } from '../../core/usage-days.js';
|
|
11
11
|
const DAY_MS = 86_400_000;
|
|
12
|
-
async function listJsonl(dir, out = []) {
|
|
12
|
+
export async function listJsonl(dir, out = []) {
|
|
13
13
|
let entries;
|
|
14
14
|
try {
|
|
15
15
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -75,7 +75,7 @@ async function parseFile(file) {
|
|
|
75
75
|
return out;
|
|
76
76
|
}
|
|
77
77
|
/** Parsed session logs per Account directory; a file is parsed again only after it changed. */
|
|
78
|
-
export const claudeUsageMemo = new FileMemo();
|
|
78
|
+
export const claudeUsageMemo = new FileMemo({ name: 'claude-usage', version: 1 });
|
|
79
79
|
export async function readClaudeUsage(account, ctx) {
|
|
80
80
|
const files = await listJsonl(join(account.dir, 'projects'));
|
|
81
81
|
if (!files.length)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A Brief from a Codex rollout (`sessions/**/rollout-*.jsonl`), for when the session could not write one, for
|
|
2
|
+
// example under a read-only sandbox. Codex's hook input carries the rollout path.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { DigestBuilder, formatDigest } from '../shared/brief.js';
|
|
5
|
+
/** Context Codex injects as user messages (AGENTS.md, environment), not something the user asked. */
|
|
6
|
+
const INJECTED = /^\s*(<|# AGENTS\.md)/;
|
|
7
|
+
const PATCH_FILE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
|
|
8
|
+
export async function codexBrief(_account, session) {
|
|
9
|
+
const file = session.transcriptPath;
|
|
10
|
+
if (!file)
|
|
11
|
+
return undefined;
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
text = await readFile(file, 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const b = new DigestBuilder();
|
|
20
|
+
for (const line of text.split('\n')) {
|
|
21
|
+
if (!line.includes('"response_item"'))
|
|
22
|
+
continue;
|
|
23
|
+
let o;
|
|
24
|
+
try {
|
|
25
|
+
o = JSON.parse(line);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const p = o.payload;
|
|
31
|
+
if (o.type !== 'response_item' || !p)
|
|
32
|
+
continue;
|
|
33
|
+
if (p.type === 'message' && Array.isArray(p.content)) {
|
|
34
|
+
for (const c of p.content) {
|
|
35
|
+
if (typeof c?.text !== 'string')
|
|
36
|
+
continue;
|
|
37
|
+
if (p.role === 'user' && c.type === 'input_text' && !INJECTED.test(c.text))
|
|
38
|
+
b.user(c.text);
|
|
39
|
+
else if (p.role === 'assistant' && c.type === 'output_text')
|
|
40
|
+
b.assistant(c.text);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (p.type === 'custom_tool_call' && p.name === 'apply_patch' && typeof p.input === 'string')
|
|
44
|
+
for (const m of p.input.matchAll(PATCH_FILE))
|
|
45
|
+
b.file(m[1].trim());
|
|
46
|
+
}
|
|
47
|
+
const d = b.result();
|
|
48
|
+
return d ? formatDigest(d) : undefined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Codex for the handoff hook (spec §3.17): Quota Pressure from the current session's rollout. Codex has no hook
|
|
2
|
+
// for a failed turn, so it starts a Handoff only before its limit. Leaf module: the hook runs on every tool call.
|
|
3
|
+
import { quotaPressure } from '../../core/quota-levels.js';
|
|
4
|
+
import { field } from '../../handoff/reader.js';
|
|
5
|
+
import { lastRateLimits } from './rate-limits.js';
|
|
6
|
+
export const codexHookReader = {
|
|
7
|
+
async pressure(input, _env, now) {
|
|
8
|
+
const file = field(input, 'transcript_path');
|
|
9
|
+
if (!file)
|
|
10
|
+
return null;
|
|
11
|
+
const windows = await lastRateLimits(file);
|
|
12
|
+
return windows
|
|
13
|
+
? quotaPressure({ status: 'ok', windows, observedAt: now.toISOString(), source: file }, now)
|
|
14
|
+
: null;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FamilyHandoff } from '../../types.ts';
|
|
2
|
+
import { type ArgRules } from '../shared/handoff-args.ts';
|
|
3
|
+
/** From `codex --help`. `-c` is a config override in Codex, not a continue flag, so it is kept. */
|
|
4
|
+
export declare const CODEX_ARGS: ArgRules;
|
|
5
|
+
export declare const codexHandoff: FamilyHandoff;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// How Codex takes part in a Handoff (spec §3.17): hooks through `-c hooks.<event>=…` (the session-flags layer is a
|
|
2
|
+
// hook source; verified 2026-10-09), each Account trusting them once in `/hooks`. No hook fires on a failed turn,
|
|
3
|
+
// so Codex starts a Handoff only before its limit.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { carryArgs, hasOption, optionValue, promptAfterDashes, } from '../shared/handoff-args.js';
|
|
7
|
+
import { topLevelTomlString } from '../shared/toml.js';
|
|
8
|
+
import { codexBrief } from './brief.js';
|
|
9
|
+
/** From `codex --help`. `-c` is a config override in Codex, not a continue flag, so it is kept. */
|
|
10
|
+
export const CODEX_ARGS = {
|
|
11
|
+
valued: [
|
|
12
|
+
'-c',
|
|
13
|
+
'--config',
|
|
14
|
+
'--enable',
|
|
15
|
+
'--disable',
|
|
16
|
+
'--remote',
|
|
17
|
+
'--remote-auth-token-env',
|
|
18
|
+
'-m',
|
|
19
|
+
'--model',
|
|
20
|
+
'--local-provider',
|
|
21
|
+
'-p',
|
|
22
|
+
'--profile',
|
|
23
|
+
'-s',
|
|
24
|
+
'--sandbox',
|
|
25
|
+
'-C',
|
|
26
|
+
'--cd',
|
|
27
|
+
'--add-dir',
|
|
28
|
+
'-a',
|
|
29
|
+
'--ask-for-approval',
|
|
30
|
+
],
|
|
31
|
+
variadic: ['-i', '--image'],
|
|
32
|
+
drop: ['--last', '--all', '--include-non-interactive', '-i', '--image'],
|
|
33
|
+
};
|
|
34
|
+
/** Subcommands that are not an interactive session; `resume` and `fork` are. */
|
|
35
|
+
const NOT_INTERACTIVE = new Set([
|
|
36
|
+
'exec',
|
|
37
|
+
'e',
|
|
38
|
+
'review',
|
|
39
|
+
'login',
|
|
40
|
+
'logout',
|
|
41
|
+
'mcp',
|
|
42
|
+
'mcp-server',
|
|
43
|
+
'plugin',
|
|
44
|
+
'app-server',
|
|
45
|
+
'remote-control',
|
|
46
|
+
'app',
|
|
47
|
+
'completion',
|
|
48
|
+
'update',
|
|
49
|
+
'doctor',
|
|
50
|
+
'sandbox',
|
|
51
|
+
'debug',
|
|
52
|
+
'apply',
|
|
53
|
+
'archive',
|
|
54
|
+
'delete',
|
|
55
|
+
'migrate-rollouts',
|
|
56
|
+
'unarchive',
|
|
57
|
+
'cloud',
|
|
58
|
+
'exec-server',
|
|
59
|
+
'features',
|
|
60
|
+
'help',
|
|
61
|
+
'agents',
|
|
62
|
+
]);
|
|
63
|
+
/** The first argument that is not an option or an option's value. */
|
|
64
|
+
function firstPositional(args) {
|
|
65
|
+
for (let i = 0; i < args.length; i++) {
|
|
66
|
+
const a = args[i];
|
|
67
|
+
if (a === '--')
|
|
68
|
+
return args[i + 1];
|
|
69
|
+
if (!a.startsWith('-'))
|
|
70
|
+
return a;
|
|
71
|
+
if (!a.includes('=') && CODEX_ARGS.valued.includes(a))
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/** `-s`/`--sandbox`, else `-c sandbox_mode=…`, else `sandbox_mode` in the Account's config.toml (profiles not read). */
|
|
77
|
+
async function sandboxMode(args, dir) {
|
|
78
|
+
const flag = optionValue(args, ['-s', '--sandbox']);
|
|
79
|
+
if (flag)
|
|
80
|
+
return flag;
|
|
81
|
+
for (let i = 0; i < args.length; i++) {
|
|
82
|
+
if (args[i] === '--')
|
|
83
|
+
break;
|
|
84
|
+
const kv = args[i] === '-c' || args[i] === '--config' ? args[i + 1] : undefined;
|
|
85
|
+
const m = kv && /^sandbox_mode\s*=\s*["']?([\w-]+)["']?$/.exec(kv);
|
|
86
|
+
if (m)
|
|
87
|
+
return m[1];
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return topLevelTomlString(await readFile(join(dir, 'config.toml'), 'utf8'), 'sandbox_mode');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const hookValue = (event, command) => `hooks.${event}=[{hooks=[{type="command",command="${command}"}]}]`;
|
|
97
|
+
export const codexHandoff = {
|
|
98
|
+
starts: ['quota'],
|
|
99
|
+
async hookArgs(command) {
|
|
100
|
+
return ['-c', hookValue('PostToolUse', command('PostToolUse')), '-c', hookValue('Stop', command('Stop'))];
|
|
101
|
+
},
|
|
102
|
+
dirArgs: (dir) => ['--add-dir', dir],
|
|
103
|
+
promptArgs: promptAfterDashes,
|
|
104
|
+
continueArgs: (args) => carryArgs(args, CODEX_ARGS),
|
|
105
|
+
resumeArgs: (id) => ['resume', id],
|
|
106
|
+
async eligibility(args, account) {
|
|
107
|
+
const sub = firstPositional(args);
|
|
108
|
+
if (sub && NOT_INTERACTIVE.has(sub))
|
|
109
|
+
return { start: false, receive: false, reason: `\`codex ${sub}\` is not an interactive session` };
|
|
110
|
+
if (hasOption(args, ['--remote']))
|
|
111
|
+
return {
|
|
112
|
+
start: false,
|
|
113
|
+
receive: false,
|
|
114
|
+
reason: '`--remote` talks to a remote app server: another local account cannot take over its session',
|
|
115
|
+
};
|
|
116
|
+
// Under a read-only sandbox the session cannot write its Brief; sideby puts one together instead.
|
|
117
|
+
const sandbox = await sandboxMode(args, account.dir);
|
|
118
|
+
return { start: true, receive: true, ...(sandbox === 'read-only' ? { agentBrief: false } : {}) };
|
|
119
|
+
},
|
|
120
|
+
briefFromSession: codexBrief,
|
|
121
|
+
};
|
|
@@ -4,7 +4,9 @@ import { identityFromJsonFile, identityOf, jwtEmail } from '../../core/identity.
|
|
|
4
4
|
import { BUILTIN_LOGOS } from '../logos.js';
|
|
5
5
|
import { nestedFileWrittenAt, resumeSubcommandSession } from '../shared/sessions.js';
|
|
6
6
|
import { topLevelTomlString } from '../shared/toml.js';
|
|
7
|
+
import { codexHandoff } from './handoff.js';
|
|
7
8
|
import { readCodexQuota, readCodexUsage } from './rollout.js';
|
|
9
|
+
import { readCodexTuning } from './tuning.js';
|
|
8
10
|
const FILE_STORE_KEY = 'cli_auth_credentials_store';
|
|
9
11
|
async function readModel(account) {
|
|
10
12
|
try {
|
|
@@ -47,6 +49,8 @@ export const codexFamily = {
|
|
|
47
49
|
return [];
|
|
48
50
|
return ['-c', `${FILE_STORE_KEY}="file"`];
|
|
49
51
|
},
|
|
52
|
+
// `tui.terminal_title` takes only built-in items; an empty list leaves the title sideby set alone.
|
|
53
|
+
keepTitle: { key: 'tui.terminal_title', args: ['-c', 'tui.terminal_title=[]'] },
|
|
50
54
|
async loginState(account) {
|
|
51
55
|
try {
|
|
52
56
|
return (await stat(join(account.dir, 'auth.json'))).isFile() ? 'logged-in' : 'unknown';
|
|
@@ -59,8 +63,10 @@ export const codexFamily = {
|
|
|
59
63
|
identity: codexIdentity,
|
|
60
64
|
readQuota: (account) => readCodexQuota(account.dir),
|
|
61
65
|
readUsage: (account, ctx) => readCodexUsage(account.dir, ctx.now),
|
|
66
|
+
readTuning: readCodexTuning,
|
|
62
67
|
resumedSession: resumeSubcommandSession,
|
|
63
68
|
sessionWrittenAt: (account, id) => nestedFileWrittenAt(join(account.dir, 'sessions'), `-${id}.jsonl`),
|
|
69
|
+
handoff: codexHandoff,
|
|
64
70
|
};
|
|
65
71
|
export const codexPlugin = {
|
|
66
72
|
name: 'codex',
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QuotaWindow } from '../../types.ts';
|
|
2
|
+
type Obj = Record<string, unknown>;
|
|
3
|
+
export declare const isObj: (v: unknown) => v is Obj;
|
|
4
|
+
export declare const isNum: (v: unknown) => v is number;
|
|
5
|
+
export declare function windowLabel(minutes: number): string;
|
|
6
|
+
/** The windows of one `token_count` payload, or null when its `rate_limits` is missing or malformed. */
|
|
7
|
+
export declare function rateLimits(payload: Obj): {
|
|
8
|
+
windows: QuotaWindow[];
|
|
9
|
+
plan?: string;
|
|
10
|
+
} | null;
|
|
11
|
+
/**
|
|
12
|
+
* The windows of the last well-formed `rate_limits` in the rollout's last 256 KB, or null. A line cut by the read
|
|
13
|
+
* window or still being written is skipped. Codex writes `rate_limits` after caching them, so they may lag.
|
|
14
|
+
*/
|
|
15
|
+
export declare function lastRateLimits(file: string): Promise<QuotaWindow[] | null>;
|
|
16
|
+
export {};
|