fuli-context 0.9.0 → 0.10.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.
Files changed (34) hide show
  1. package/README.md +17 -43
  2. package/README.zh-CN.md +14 -40
  3. package/dist/web/assets/{ProjectAgentsPage-CQemHFxy.js → ProjectAgentsPage-CPgKVLgg.js} +1 -1
  4. package/dist/web/assets/{ProjectAgentsPage-BgQLIpxg.css → ProjectAgentsPage-DeuRkMp1.css} +1 -1
  5. package/dist/web/assets/RoundtablesPage-C9dXfWCl.js +2 -0
  6. package/dist/web/assets/{index-UygXwZDZ.js → index-ulISYH02.js} +3 -3
  7. package/dist/web/index.html +1 -1
  8. package/docs/roundtable.md +46 -0
  9. package/graph-provider/pyproject.toml +1 -1
  10. package/npm-shrinkwrap.json +2 -2
  11. package/package.json +2 -4
  12. package/skills/capturing-session-knowledge/SKILL.md +1 -1
  13. package/src/agents/claude-code/lifecycle-hook.js +6 -19
  14. package/src/agents/codex/lifecycle-hook.js +51 -27
  15. package/src/agents/codex/token-usage.js +235 -0
  16. package/src/agents/cursor/lifecycle-hook.js +3 -9
  17. package/src/agents/token-usage-resolver.js +147 -0
  18. package/src/graphiti/federated-application.js +5 -1
  19. package/src/graphiti/project-agent-mapping.js +1 -1
  20. package/src/graphiti/project-agent-workflows.js +6 -1
  21. package/src/mcp/instructions.js +1 -1
  22. package/src/mcp/session-id.js +1 -1
  23. package/src/mcp/tool-annotations.js +3 -1
  24. package/src/roundtables/domain.js +3 -3
  25. package/src/roundtables/http-router.js +5 -3
  26. package/src/roundtables/mcp-server.js +15 -8
  27. package/src/roundtables/profiles.js +84 -0
  28. package/src/roundtables/service.js +86 -2
  29. package/src/roundtables/store.js +4 -0
  30. package/src/roundtables/tool-contract.js +22 -5
  31. package/dist/web/assets/RoundtablesPage-ByVeZlgC.js +0 -2
  32. package/docs/roundtable-beta-testing.md +0 -102
  33. package/docs/roundtable-beta-validation.md +0 -133
  34. package/docs/roundtable-research.md +0 -142
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local-first collaboration relationship graph for AI agents.",
5
5
  "type": "module",
6
6
  "files": [
@@ -14,9 +14,7 @@
14
14
  "docs/agent-conversations-and-collaboration.md",
15
15
  "docs/employee-agents.md",
16
16
  "docs/agent-interface-architecture.md",
17
- "docs/roundtable-beta-testing.md",
18
- "docs/roundtable-beta-validation.md",
19
- "docs/roundtable-research.md",
17
+ "docs/roundtable.md",
20
18
  "examples/external-knowledge/markdown-folder.mjs",
21
19
  "graph-provider/Dockerfile",
22
20
  "graph-provider/pyproject.toml",
@@ -288,7 +288,7 @@ worker session (`workerRuntime.sessionId`, or a worker session link only when su
288
288
  Label `sourceApplication` and `sourceSessionId` separately as the reporting host. They do not
289
289
  identify an external worker; do not substitute them for missing worker evidence.
290
290
  Use only the source-labelled cumulative `tokenUsage` reported for that worker. If it is absent,
291
- show `not reported`; never estimate it or copy a conversation-wide total into a worker row. If no
291
+ show `-`; never estimate it or copy a conversation-wide total into a worker row. If no
292
292
  worker session link exists, show `workerRuntime.sessionId` or `not reported`; never invent a link. This applies only
293
293
  to Provider-reported actual workers. When empty `executionSummary` is returned, omit rows; an absent
294
294
  `executionSummary` also omits rows. Configured, allowed, or available clients/executors are not evidence
@@ -37,19 +37,7 @@ export async function claudeLifecycleOutput(event, input, invoke) {
37
37
  const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
38
38
  const reason = boundedHookMessage(check?.reason);
39
39
  if (check?.decision !== 'block' || !reason.trim()) return {};
40
- if (input.stop_hook_active === true) {
41
- if (typeof check.task_context_token === 'string' && check.task_context_token.trim()) {
42
- await invoke('checkpoint_task_knowledge', {
43
- taskContextToken: check.task_context_token,
44
- disposition: 'retain_nothing',
45
- reason: 'Claude Code Stop hook fallback after one checkpoint continuation.',
46
- workLog: { status: 'incomplete', summary: 'The host stopped before the employee submitted a work summary. Review the prior task before resuming.' },
47
- ...source
48
- });
49
- }
50
- return {};
51
- }
52
- return { decision: 'block', reason };
40
+ return { systemMessage: reason };
53
41
  }
54
42
 
55
43
  export function claudeLifecycleUnavailable(event) {
@@ -80,7 +68,7 @@ export async function runClaudeLifecycleHook(args, dependencies = {}) {
80
68
  let sync = await syncConversationTranscript(app, input, 'claude_code', {
81
69
  normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
82
70
  }, guard);
83
- if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
71
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
84
72
  const entryGuard = sync.status === 'capture_disabled' ? null : guard;
85
73
  if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
86
74
  const output = await claudeLifecycleOutput(event, input, async (name, parameters) => {
@@ -91,12 +79,12 @@ export async function runClaudeLifecycleHook(args, dependencies = {}) {
91
79
  if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'claude_code', sync, {
92
80
  normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
93
81
  }, entryGuard);
94
- if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
82
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
95
83
  return withTranscriptNotice(output, sync);
96
84
  });
97
85
  } catch (error) {
98
86
  if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
99
- return { decision: 'block', reason: error.code === 'EADDRINUSE'
87
+ return { systemMessage: error.code === 'EADDRINUSE'
100
88
  ? 'The session handoff lock is occupied. Retry after the current hook finishes; if it persists, start a new host session and resume the intended Agent.'
101
89
  : 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
102
90
  }
@@ -127,7 +115,6 @@ async function main() {
127
115
  ? configured : 28000;
128
116
  const controller = new AbortController();
129
117
  let emitted = false;
130
- let guarded = false;
131
118
  const emit = (output, callback) => {
132
119
  if (emitted) return;
133
120
  emitted = true;
@@ -137,11 +124,11 @@ async function main() {
137
124
  // No prompt, transcript, path or raw exception is written to diagnostics.
138
125
  const deadline = setTimeout(() => {
139
126
  controller.abort();
140
- emit(guarded && event === 'UserPromptSubmit' ? { decision: 'block', reason: 'Fuli transcript handoff timed out. Retry task entry; the previous Agent context was not supplied.' } : claudeLifecycleUnavailable(event), () => process.exit(0));
127
+ emit(claudeLifecycleUnavailable(event), () => process.exit(0));
141
128
  }, timeoutMs);
142
129
  const abort = setTimeout(() => controller.abort(), Math.max(1, timeoutMs - 1500));
143
130
  try {
144
- const output = await runClaudeLifecycleHook(args, { signal: controller.signal, onTranscriptGuard: () => { guarded = true; } });
131
+ const output = await runClaudeLifecycleHook(args, { signal: controller.signal });
145
132
  emit(output);
146
133
  } catch {
147
134
  emit(claudeLifecycleUnavailable(event));
@@ -26,20 +26,9 @@ export async function codexStopLifecycleOutput(input, invoke) {
26
26
  if (check?.decision !== 'block' || typeof check.reason !== 'string' || !check.reason.trim()) {
27
27
  return {};
28
28
  }
29
- if (input.stop_hook_active === true) {
30
- if (typeof check.task_context_token === 'string' && check.task_context_token.trim()) {
31
- await invoke('checkpoint_task_knowledge', {
32
- taskContextToken: check.task_context_token,
33
- disposition: 'retain_nothing',
34
- reason: 'Codex Stop hook fallback after one checkpoint continuation.',
35
- workLog: { status: 'incomplete', summary: 'The host stopped before the employee submitted a work summary. Review the prior task before resuming.' },
36
- sourceApplication: 'codex',
37
- sourceSessionId: sessionId
38
- });
39
- }
40
- return {};
41
- }
42
- return { decision: 'block', reason: boundedHookMessage(check.reason) };
29
+ // Checkpoints are advisory to the host. Never force another model turn or
30
+ // fabricate a work log merely because the user is ending the conversation.
31
+ return { systemMessage: boundedHookMessage(check.reason) };
43
32
  }
44
33
 
45
34
  export async function codexLifecycleOutput(event, input, invoke) {
@@ -73,6 +62,9 @@ export async function runCodexLifecycleHook(args, dependencies = {}) {
73
62
  );
74
63
  let app;
75
64
  let leases;
65
+ let closing;
66
+ const cleanup = () => closing ??= Promise.allSettled([closeQuietly(leases), closeQuietly(app)]);
67
+ dependencies.onCleanup?.(cleanup);
76
68
  try {
77
69
  app = (dependencies.openApplication ?? openFederatedGraphApplication)({
78
70
  runtimeConfigPath
@@ -94,32 +86,31 @@ export async function runCodexLifecycleHook(args, dependencies = {}) {
94
86
  // Existing guards and all later failures remain fail-closed.
95
87
  return { systemMessage: `Fuli: ${sync.reason}. Message submission is allowed; no saved Agent context was loaded or changed.` };
96
88
  }
97
- if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
89
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
98
90
  const entryGuard = sync.status === 'capture_disabled' ? null : guard;
99
91
  if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
100
92
  const result = await codexLifecycleOutput(event, input, async (name, parameters) => {
101
- const context = await invokeTool(app, name, parameters);
93
+ const context = await invokeTool(app, name, parameters, { signal: dependencies.signal });
102
94
  if (name === 'begin_task_context') await recordTranscriptTaskEntry(entryGuard, context);
103
95
  return context;
104
96
  });
105
97
  if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'codex', sync, {
106
98
  normalize: normalizeCodexRecord, verify: verifyCodexTranscript
107
99
  }, entryGuard);
108
- if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
100
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
109
101
  return withTranscriptNotice(result, sync);
110
102
  });
111
103
  } catch (error) {
112
104
  if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
113
- return { decision: 'block', reason: error.code === 'EADDRINUSE'
105
+ return { systemMessage: error.code === 'EADDRINUSE'
114
106
  ? 'The session handoff lock is occupied. Retry after the current hook finishes; if it persists, start a new host session and resume the intended Agent.'
115
107
  : 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
116
108
  }
117
- });
109
+ }, { signal: dependencies.signal });
118
110
  write(`${JSON.stringify(output)}\n`);
119
111
  return output;
120
112
  } finally {
121
- await closeQuietly(leases);
122
- await closeQuietly(app);
113
+ await cleanup();
123
114
  }
124
115
  }
125
116
 
@@ -142,13 +133,46 @@ async function readHookInput() {
142
133
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
143
134
  }
144
135
 
145
- async function main() {
136
+ export async function runCodexLifecycleCommand(args, dependencies = {}) {
137
+ const configured = Number(args[args.indexOf('--timeout-ms') + 1]);
138
+ const timeoutMs = args.includes('--timeout-ms') && Number.isSafeInteger(configured) && configured > 0
139
+ ? Math.min(configured, 5000) : 5000;
140
+ const controller = new AbortController();
141
+ let emitted = false;
142
+ const write = value => {
143
+ if (emitted) return;
144
+ emitted = true;
145
+ (dependencies.write ?? (value => process.stdout.write(value)))(value);
146
+ };
147
+ const unavailable = JSON.stringify({ systemMessage: 'Fuli context is unavailable. Continue with the user request; saved context and checkpoint were not verified.' }) + '\n';
148
+ let cleanup = async () => {};
149
+ let deadline;
150
+ const expired = new Promise(resolve => {
151
+ deadline = setTimeout(async () => {
152
+ controller.abort();
153
+ write(unavailable);
154
+ // A broken dependency must not hold the host open. Give opened leases
155
+ // and connections a bounded chance to close before the process exits.
156
+ let grace;
157
+ await Promise.race([cleanup(), new Promise(done => { grace = setTimeout(done, 250); })]);
158
+ clearTimeout(grace);
159
+ resolve({ timedOut: true });
160
+ }, timeoutMs);
161
+ });
146
162
  try {
147
- await runCodexLifecycleHook(process.argv.slice(2));
148
- } catch {
149
- // Fail open without printing prompts, transcripts, paths or credentials.
150
- process.stdout.write('{}\n');
163
+ const running = runCodexLifecycleHook(args, { ...dependencies, signal: controller.signal, write,
164
+ onCleanup: close => { cleanup = close; } }).then(() => ({ timedOut: false }), () => {
165
+ // Never print prompts, transcripts, paths or credentials.
166
+ write(unavailable);
167
+ return { timedOut: false };
168
+ });
169
+ return await Promise.race([running, expired]);
170
+ } finally {
171
+ clearTimeout(deadline);
151
172
  }
152
173
  }
153
174
 
154
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
175
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
176
+ const result = await runCodexLifecycleCommand(process.argv.slice(2));
177
+ if (result.timedOut) process.exit(0);
178
+ }
@@ -0,0 +1,235 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, opendir, open } from 'node:fs/promises';
3
+ import { basename, extname, join } from 'node:path';
4
+
5
+ const MAX_HEADER_BYTES = 64 * 1024;
6
+ const MAX_USAGE_TAIL_BYTES = 4 * 1024 * 1024;
7
+ const MAX_CANDIDATE_FILES = 128;
8
+ const MAX_DIRECTORY_ENTRIES = 2048;
9
+ const MAX_DIRECTORY_DEPTH = 8;
10
+
11
+ /**
12
+ * Codex's native JSONL session adapter. It returns only the latest cumulative
13
+ * token_count snapshot and never returns transcript records or text.
14
+ */
15
+ export function createCodexTokenUsageResolver({
16
+ sessionsDirectory = undefined,
17
+ archivedSessionsDirectory = undefined,
18
+ codexSessionsDirectory = undefined,
19
+ codexArchivedSessionsDirectory = undefined,
20
+ codexHomeDirectory = undefined,
21
+ homeDirectory = undefined
22
+ } = {}) {
23
+ const explicitSessionsDirectory = sessionsDirectory ?? codexSessionsDirectory;
24
+ const explicitArchivedSessionsDirectory =
25
+ archivedSessionsDirectory ?? codexArchivedSessionsDirectory;
26
+ const home = typeof homeDirectory === 'string' && homeDirectory
27
+ ? homeDirectory : defaultHomeDirectory();
28
+ const codexHome = typeof codexHomeDirectory === 'string' && codexHomeDirectory
29
+ ? codexHomeDirectory
30
+ : homeDirectory === undefined ? (defaultCodexHomeDirectory() ?? (home ? join(home, '.codex') : null))
31
+ : (home ? join(home, '.codex') : null);
32
+ const useDefaultRoots = explicitSessionsDirectory === undefined &&
33
+ explicitArchivedSessionsDirectory === undefined;
34
+ const roots = [
35
+ explicitSessionsDirectory ?? (useDefaultRoots && codexHome ? join(codexHome, 'sessions') : null),
36
+ explicitArchivedSessionsDirectory ??
37
+ (useDefaultRoots && codexHome ? join(codexHome, 'archived_sessions') : null)
38
+ ]
39
+ .filter((root, index, values) => typeof root === 'string' && root && values.indexOf(root) === index);
40
+ return {
41
+ resolve: (request) => resolveCodexTokenUsage(request, { roots })
42
+ };
43
+ }
44
+
45
+ export async function resolveCodexTokenUsage(request = {}, { roots } = {}) {
46
+ const sessionId = safeSessionId(request.sessionId ?? request.session_id);
47
+ if (!sessionId) return null;
48
+ const configuredRoots = Array.isArray(roots) ? roots : roots ? [roots] : null;
49
+ const directories = (configuredRoots ?? [
50
+ defaultCodexSessionsDirectory(),
51
+ defaultCodexArchivedSessionsDirectory()
52
+ ]).filter((root, index, values) => typeof root === 'string' && root && values.indexOf(root) === index);
53
+
54
+ let inspectedEntries = 0;
55
+ let candidates = 0;
56
+ for (const root of directories) {
57
+ const files = await matchingSessionFiles(root, sessionId, {
58
+ onEntry: () => { inspectedEntries += 1; },
59
+ get entriesInspected() { return inspectedEntries; },
60
+ get candidatesFound() { return candidates; },
61
+ addCandidate: () => { candidates += 1; }
62
+ });
63
+ for (const file of files) {
64
+ const usage = await readSessionUsage(file, sessionId);
65
+ if (usage) return usage;
66
+ }
67
+ if (inspectedEntries >= MAX_DIRECTORY_ENTRIES || candidates >= MAX_CANDIDATE_FILES) break;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ async function matchingSessionFiles(root, sessionId, counters, depth = 0) {
73
+ if (depth > MAX_DIRECTORY_DEPTH || counters.entriesInspected >= MAX_DIRECTORY_ENTRIES ||
74
+ counters.candidatesFound >= MAX_CANDIDATE_FILES) return [];
75
+ let directory;
76
+ try {
77
+ const rootStat = await lstat(root);
78
+ if (!rootStat.isDirectory()) return [];
79
+ directory = await opendir(root);
80
+ } catch {
81
+ return [];
82
+ }
83
+
84
+ const files = [];
85
+ try {
86
+ for await (const entry of directory) {
87
+ counters.onEntry();
88
+ if (counters.entriesInspected > MAX_DIRECTORY_ENTRIES ||
89
+ counters.candidatesFound >= MAX_CANDIDATE_FILES) break;
90
+ const path = join(root, entry.name);
91
+ if (entry.isDirectory()) {
92
+ files.push(...await matchingSessionFiles(path, sessionId, counters, depth + 1));
93
+ continue;
94
+ }
95
+ if (!entry.isFile() || extname(entry.name) !== '.jsonl' ||
96
+ !basename(entry.name).includes(sessionId)) continue;
97
+ counters.addCandidate();
98
+ files.push(path);
99
+ }
100
+ } catch {
101
+ // A session directory can rotate while the host is running. An incomplete
102
+ // scan is an unavailable snapshot, never a reason to guess another file.
103
+ } finally {
104
+ await directory.close().catch(() => {});
105
+ }
106
+ return files;
107
+ }
108
+
109
+ async function readSessionUsage(filePath, sessionId) {
110
+ let file;
111
+ try {
112
+ file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
113
+ const stat = await file.stat();
114
+ if (!stat.isFile() || stat.size <= 0) return null;
115
+
116
+ const header = await readBytes(file, Math.min(MAX_HEADER_BYTES, stat.size), 0);
117
+ const firstLineEnd = header.indexOf(10);
118
+ if (firstLineEnd < 0 || !isSessionHeader(header.subarray(0, firstLineEnd), sessionId)) {
119
+ return null;
120
+ }
121
+
122
+ const start = Math.max(0, stat.size - MAX_USAGE_TAIL_BYTES);
123
+ const tail = await readBytes(file, stat.size - start, start);
124
+ let firstLine = 0;
125
+ if (start > 0) {
126
+ const previous = await readBytes(file, 1, start - 1);
127
+ if (previous[0] !== 10) {
128
+ firstLine = tail.indexOf(10) + 1;
129
+ if (firstLine <= 0) return null;
130
+ }
131
+ }
132
+ return latestTokenUsage(tail.subarray(firstLine));
133
+ } catch {
134
+ return null;
135
+ } finally {
136
+ await file?.close().catch(() => {});
137
+ }
138
+ }
139
+
140
+ async function readBytes(file, length, position) {
141
+ const buffer = Buffer.allocUnsafe(length);
142
+ let offset = 0;
143
+ while (offset < length) {
144
+ const result = await file.read(buffer, offset, length - offset, position + offset);
145
+ if (!result.bytesRead) break;
146
+ offset += result.bytesRead;
147
+ }
148
+ return offset === length ? buffer : buffer.subarray(0, offset);
149
+ }
150
+
151
+ function isSessionHeader(bytes, sessionId) {
152
+ try {
153
+ const record = JSON.parse(bytes.toString('utf8'));
154
+ return record?.type === 'session_meta' && record.payload?.id === sessionId;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ function latestTokenUsage(bytes) {
161
+ let latest = null;
162
+ const text = bytes.toString('utf8');
163
+ let offset = 0;
164
+ while (offset < text.length) {
165
+ const end = text.indexOf('\n', offset);
166
+ const line = text.slice(offset, end < 0 ? text.length : end).trim();
167
+ if (line.includes('"token_count"') && line.includes('"total_token_usage"')) {
168
+ try {
169
+ const record = JSON.parse(line);
170
+ const total = record?.payload?.info?.total_token_usage;
171
+ if (record?.type === 'event_msg' && record.payload?.type === 'token_count') {
172
+ const totalTokens = nonNegativeInteger(total?.total_tokens);
173
+ if (totalTokens !== null) {
174
+ latest = {
175
+ source: 'host',
176
+ totalTokens,
177
+ inputTokens: optionalNonNegativeInteger(total?.input_tokens),
178
+ outputTokens: optionalNonNegativeInteger(total?.output_tokens),
179
+ cachedInputTokens: optionalNonNegativeInteger(total?.cached_input_tokens),
180
+ cacheWriteInputTokens: optionalNonNegativeInteger(total?.cache_write_input_tokens),
181
+ reasoningOutputTokens: optionalNonNegativeInteger(total?.reasoning_output_tokens)
182
+ };
183
+ }
184
+ }
185
+ } catch {
186
+ // Ignore malformed native records and continue to later snapshots.
187
+ }
188
+ }
189
+ if (end < 0) break;
190
+ offset = end + 1;
191
+ }
192
+ return latest;
193
+ }
194
+
195
+ function optionalNonNegativeInteger(value) {
196
+ return value === null || value === undefined ? null : nonNegativeInteger(value);
197
+ }
198
+
199
+ function nonNegativeInteger(value) {
200
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
201
+ }
202
+
203
+ function safeSessionId(value) {
204
+ if (typeof value !== 'string') return null;
205
+ const normalized = value.trim();
206
+ if (!normalized || normalized.length > 256 || normalized.includes('..') ||
207
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) return null;
208
+ return normalized;
209
+ }
210
+
211
+ function defaultCodexSessionsDirectory() {
212
+ const codexHome = defaultCodexRootDirectory();
213
+ return codexHome ? join(codexHome, 'sessions') : null;
214
+ }
215
+
216
+ function defaultCodexArchivedSessionsDirectory() {
217
+ const codexHome = defaultCodexRootDirectory();
218
+ return codexHome ? join(codexHome, 'archived_sessions') : null;
219
+ }
220
+
221
+ function defaultCodexRootDirectory() {
222
+ const configured = defaultCodexHomeDirectory();
223
+ if (configured) return configured;
224
+ const home = defaultHomeDirectory();
225
+ return home ? join(home, '.codex') : null;
226
+ }
227
+
228
+ function defaultCodexHomeDirectory() {
229
+ const value = process.env.CODEX_HOME;
230
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
231
+ }
232
+
233
+ function defaultHomeDirectory() {
234
+ return process.env.HOME ?? process.env.USERPROFILE ?? null;
235
+ }
@@ -6,7 +6,6 @@ import { callAgentTool } from '../../agent-tools.js';
6
6
  import { createRuntimeLeaseClient } from '../../adaptive-runtime/lease-client.js';
7
7
  import { openFederatedGraphApplication } from '../../graphiti/federated-application.js';
8
8
  import { resolveGraphRuntimeOptions } from '../../graphiti/runtime-config.js';
9
- import { boundedHookMessage } from '../lifecycle-message.js';
10
9
 
11
10
  const EVENTS = new Set(['sessionStart', 'beforeSubmitPrompt', 'stop']);
12
11
 
@@ -18,14 +17,9 @@ export async function cursorLifecycleOutput(event, input, invoke) {
18
17
  }
19
18
  const source = { sourceApplication: 'cursor', sourceSessionId: sessionId };
20
19
  if (event === 'stop') {
21
- // One host-counted continuation at most; never restart cancelled/error turns.
22
- if (input.status !== 'completed' || (input.loop_count ?? 0) >= 1) return {};
23
- const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
24
- const notice = '\nIf this mode is read-only or checkpoint writes are denied, state that the checkpoint was not saved and do not retry. This is the only checkpoint continuation.';
25
- const reason = boundedHookMessage(check.reason, { maxBytes: 8_000 - Buffer.byteLength(notice, 'utf8') });
26
- return check.status === 'checkpoint_required' && reason.trim()
27
- ? { followup_message: reason + notice }
28
- : {};
20
+ // Cursor has no advisory Stop output. A followup_message would start
21
+ // another model turn, so pending checkpoints remain available through MCP.
22
+ return {};
29
23
  }
30
24
  const roots = input.workspace_roots;
31
25
  if (!Array.isArray(roots) || roots.length !== 1 || typeof roots[0] !== 'string') {
@@ -0,0 +1,147 @@
1
+ import { createCodexTokenUsageResolver } from './codex/token-usage.js';
2
+
3
+ const TOKEN_USAGE_SOURCES = new Set(['executor', 'host', 'dingdong']);
4
+ const DEFAULT_RESOLVER = createAgentTokenUsageResolver();
5
+
6
+ /**
7
+ * Resolve one exact worker/session identity through the runtime adapter port.
8
+ *
9
+ * A worker runtime is authoritative when present. Its session is never
10
+ * replaced with the reporting host session. A reporting host session is used
11
+ * only when no worker runtime was supplied, which covers direct host activity.
12
+ */
13
+ export function resolveAgentSession(input = {}) {
14
+ const workerRuntime = input.workerRuntime ?? input.worker_runtime;
15
+ if (workerRuntime !== undefined && workerRuntime !== null) {
16
+ if (!workerRuntime || typeof workerRuntime !== 'object' || Array.isArray(workerRuntime)) {
17
+ return null;
18
+ }
19
+ const application = workerRuntime.application;
20
+ const sessionId = safeSessionId(workerRuntime.sessionId ?? workerRuntime.session_id);
21
+ if (!validApplication(application) || !sessionId) return null;
22
+ return {
23
+ application,
24
+ sessionId,
25
+ sourceApplication: input.sourceApplication ?? input.source_application ?? null,
26
+ sourceSessionId: input.sourceSessionId ?? input.source_session_id ?? null,
27
+ scope: 'worker'
28
+ };
29
+ }
30
+
31
+ if (hasWorkerMetadata(input)) return null;
32
+ const application = input.sourceApplication ?? input.source_application;
33
+ const sessionId = safeSessionId(input.sourceSessionId ?? input.source_session_id);
34
+ if (!validApplication(application) || !sessionId) return null;
35
+ return {
36
+ application,
37
+ sessionId,
38
+ sourceApplication: application,
39
+ sourceSessionId: sessionId,
40
+ scope: 'host'
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Shared adapter port for exact session token usage.
46
+ *
47
+ * Adapters receive only the selected session identity and return a cumulative
48
+ * snapshot or null. They must not return transcript content.
49
+ */
50
+ export function createAgentTokenUsageResolver({ adapters } = {}) {
51
+ const registry = adapters instanceof Map
52
+ ? adapters
53
+ : new Map(Object.entries(adapters ?? {
54
+ codex: createCodexTokenUsageResolver()
55
+ }));
56
+ return {
57
+ async resolve(input = {}) {
58
+ const session = resolveAgentSession(input);
59
+ if (!session) return null;
60
+ const adapter = registry.get(session.application);
61
+ const resolve = typeof adapter === 'function' ? adapter : adapter?.resolve;
62
+ if (typeof resolve !== 'function') return null;
63
+ try {
64
+ return normalizeTokenUsage(await resolve.call(adapter, session));
65
+ } catch {
66
+ // Usage collection is best-effort. It must not prevent the host from
67
+ // recording otherwise valid worker activity.
68
+ return null;
69
+ }
70
+ }
71
+ };
72
+ }
73
+
74
+ export async function enrichProjectAgentTaskActivity(input = {}, resolver = DEFAULT_RESOLVER) {
75
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return input;
76
+ const actorKind = input.actorKind ?? input.actor_kind;
77
+ if (actorKind === 'human') return input;
78
+ // Null is an explicit report (including an explicit zero inside the object),
79
+ // so only an omitted value is eligible for automatic collection.
80
+ const explicitTokenUsage = (Object.hasOwn(input, 'tokenUsage') && input.tokenUsage !== undefined) ||
81
+ (Object.hasOwn(input, 'token_usage') && input.token_usage !== undefined);
82
+ if (explicitTokenUsage) return input;
83
+
84
+ const resolve = typeof resolver === 'function' ? resolver : resolver?.resolve;
85
+ if (typeof resolve !== 'function') return input;
86
+ try {
87
+ const tokenUsage = normalizeTokenUsage(await resolve.call(resolver, input));
88
+ return tokenUsage ? { ...input, tokenUsage } : input;
89
+ } catch {
90
+ return input;
91
+ }
92
+ }
93
+
94
+ export async function resolveProjectAgentTaskActivityTokenUsage(input, resolver = DEFAULT_RESOLVER) {
95
+ const enriched = await enrichProjectAgentTaskActivity(input, resolver);
96
+ if (!enriched || typeof enriched !== 'object') return null;
97
+ return enriched.tokenUsage !== undefined ? enriched.tokenUsage : enriched.token_usage ?? null;
98
+ }
99
+
100
+ function normalizeTokenUsage(value) {
101
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
102
+ const source = value.source;
103
+ const totalTokens = nonNegativeInteger(value.totalTokens ?? value.total_tokens);
104
+ if (!TOKEN_USAGE_SOURCES.has(source) || totalTokens === null) return null;
105
+ return {
106
+ source,
107
+ totalTokens,
108
+ inputTokens: optionalNonNegativeInteger(value.inputTokens ?? value.input_tokens),
109
+ outputTokens: optionalNonNegativeInteger(value.outputTokens ?? value.output_tokens),
110
+ cachedInputTokens: optionalNonNegativeInteger(value.cachedInputTokens ?? value.cached_input_tokens),
111
+ cacheWriteInputTokens: optionalNonNegativeInteger(
112
+ value.cacheWriteInputTokens ?? value.cache_write_input_tokens
113
+ ),
114
+ reasoningOutputTokens: optionalNonNegativeInteger(
115
+ value.reasoningOutputTokens ?? value.reasoning_output_tokens
116
+ )
117
+ };
118
+ }
119
+
120
+ function optionalNonNegativeInteger(value) {
121
+ return value === null || value === undefined ? null : nonNegativeInteger(value);
122
+ }
123
+
124
+ function nonNegativeInteger(value) {
125
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
126
+ }
127
+
128
+ function validApplication(value) {
129
+ return typeof value === 'string' && value.length > 0 && value.length <= 64;
130
+ }
131
+
132
+ function hasWorkerMetadata(input) {
133
+ return [
134
+ input.workerId ?? input.worker_id,
135
+ input.workerLabel ?? input.worker_label,
136
+ input.workerStatus ?? input.worker_status,
137
+ input.workerOccupationEmoji ?? input.worker_occupation_emoji
138
+ ].some((value) => value !== undefined && value !== null && value !== '');
139
+ }
140
+
141
+ function safeSessionId(value) {
142
+ if (typeof value !== 'string') return null;
143
+ const normalized = value.trim();
144
+ if (!normalized || normalized.length > 256 || normalized.includes('..') ||
145
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) return null;
146
+ return normalized;
147
+ }
@@ -92,6 +92,7 @@ export function openFederatedGraphApplication({
92
92
  config,
93
93
  capturePolicyStore,
94
94
  agentAccessPolicyStore,
95
+ tokenUsageResolver,
95
96
  fetchImpl = globalThis.fetch,
96
97
  env = process.env
97
98
  }) {
@@ -105,6 +106,7 @@ export function openFederatedGraphApplication({
105
106
  agentAccessPolicyStore: agentAccessPolicyStore ?? new AgentAccessPolicyStore(
106
107
  agentAccessPolicyPathForRuntime(runtimeConfigPath)
107
108
  ),
109
+ tokenUsageResolver,
108
110
  consoleUrl: sourceConsoleUrl(runtimeConfigPath),
109
111
  providerRequestTimeoutMs: providerRequestTimeoutFromEnv(env)
110
112
  });
@@ -140,7 +142,8 @@ export class FederatedGraphApplication extends ProjectAgentControlPlaneApplicati
140
142
  consoleUrl = sourceConsoleUrl(null),
141
143
  projectPathResolver = resolvePersonalProjectPath,
142
144
  taskContextRegistry = null,
143
- providerRequestTimeoutMs = undefined
145
+ providerRequestTimeoutMs = undefined,
146
+ tokenUsageResolver = undefined
144
147
  } = {}) {
145
148
  super();
146
149
  this.graphiti = true;
@@ -149,6 +152,7 @@ export class FederatedGraphApplication extends ProjectAgentControlPlaneApplicati
149
152
  this.agentAccessPolicyStore = agentAccessPolicyStore;
150
153
  this.consoleUrl = consoleUrl;
151
154
  this.projectPathResolver = projectPathResolver;
155
+ this.tokenUsageResolver = tokenUsageResolver;
152
156
  this.personal = new GraphitiProviderClient({
153
157
  baseUrl: config.personal.providerUrl,
154
158
  accessToken: config.personal.accessToken,
@@ -380,7 +380,7 @@ export function providerProjectAgentTaskActivity(input) {
380
380
  }
381
381
  if (hasAny(input, ['tokenUsage', 'token_usage'])) {
382
382
  result.token_usage = providerProjectAgentTokenUsage(
383
- input.tokenUsage ?? input.token_usage
383
+ input.tokenUsage !== undefined ? input.tokenUsage : input.token_usage
384
384
  );
385
385
  }
386
386
  if (hasAny(input, ['workerRuntime', 'worker_runtime'])) {