fuli-context 0.8.0 → 0.8.1

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 (121) hide show
  1. package/README.md +4 -0
  2. package/README.zh-CN.md +4 -1
  3. package/dist/web/assets/{AboutPage-BjMmpwJB.js → AboutPage-fBq5VFoQ.js} +1 -1
  4. package/dist/web/assets/AgentHand-CMZk5GjY.js +1 -0
  5. package/dist/web/assets/AgentName-Cydd0UI4.css +1 -0
  6. package/dist/web/assets/AgentName-DRZHAIB2.js +1 -0
  7. package/dist/web/assets/AgentProfilePage-BPQEfMhB.js +1 -0
  8. package/dist/web/assets/AgentsDirectoryPage-CKZfrcuD.js +1 -0
  9. package/dist/web/assets/{ConnectionsPage-BKGxFDos.js → ConnectionsPage-COb56qnk.js} +1 -1
  10. package/dist/web/assets/EmployeeRecruitDialog-BLTpjCcK.js +1 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-CQwZnTA0.css +1 -0
  12. package/dist/web/assets/EmployeeWorkbenchPage-HUd-gne2.js +3 -0
  13. package/dist/web/assets/{GrowthLoading-WXCj8b0r.js → GrowthLoading-Bu_kH-I8.js} +1 -1
  14. package/dist/web/assets/{KnowledgeEditDialog-Bkie08ZA.js → KnowledgeEditDialog-BTwXXRCD.js} +1 -1
  15. package/dist/web/assets/{KnowledgeInspector-Cez-Z_95.js → KnowledgeInspector-Ct5hl2--.js} +1 -1
  16. package/dist/web/assets/{KnowledgeOrganizerPage-BYmTPA_8.js → KnowledgeOrganizerPage-DMC8Hh43.js} +1 -1
  17. package/dist/web/assets/KnowledgePage-uXW1Eovz.js +1 -0
  18. package/dist/web/assets/{KnowledgeWorkspace-DKij2_B4.js → KnowledgeWorkspace-DmHIc0jb.js} +1 -1
  19. package/dist/web/assets/{NotFoundPage-HGV-q-bh.js → NotFoundPage-C0yOR9T9.js} +1 -1
  20. package/dist/web/assets/{OverviewPage-DE29_F_Y.js → OverviewPage-BreX_kb4.js} +1 -1
  21. package/dist/web/assets/{PersonalProfilePage-B95DFc7x.js → PersonalProfilePage-CX1a6_E3.js} +1 -1
  22. package/dist/web/assets/PersonalProjectsPage-Br1S8ETM.js +1 -0
  23. package/dist/web/assets/ProjectAgentsPage-CA04h1Uf.js +13 -0
  24. package/dist/web/assets/ProjectAgentsPage-CZWWV4Kw.css +1 -0
  25. package/dist/web/assets/{ProjectScopePicker-Cu5PdxF6.js → ProjectScopePicker-DVHfXX1f.js} +1 -1
  26. package/dist/web/assets/{PublicProjectsPage-BY4B3_t3.js → PublicProjectsPage-CtctKml1.js} +1 -1
  27. package/dist/web/assets/{ReviewPage-C9vCesNu.js → ReviewPage-DUqaK-do.js} +1 -1
  28. package/dist/web/assets/{SearchableSelect-214TvfYg.js → SearchableSelect-BZtyYmZN.js} +1 -1
  29. package/dist/web/assets/{SettingsPage-D9TnzcFc.js → SettingsPage-CB4ymLr8.js} +1 -1
  30. package/dist/web/assets/{SharedJefaReportPage-CdSGI_OE.js → SharedJefaReportPage-CbFtrDCY.js} +1 -1
  31. package/dist/web/assets/{WritingTastePage-CUE1dU8f.js → WritingTastePage-B7lSzs2z.js} +1 -1
  32. package/dist/web/assets/agent-profile-CdKOh5xw.css +1 -0
  33. package/dist/web/assets/agent-profile.css_vue_type_style_index_0_src_true_lang-C22UdfU0.js +1 -0
  34. package/dist/web/assets/catalog-DU4Kfd-N.js +1 -0
  35. package/dist/web/assets/{client-Bl4oiW3E.js → client-CEHkJJ-5.js} +1 -1
  36. package/dist/web/assets/{console-BAIVNC-N.js → console--CZxc3PW.js} +1 -1
  37. package/dist/web/assets/{conversation-launcher-settings-DpRI_Jp2.js → conversation-launcher-settings-Co1RlTFH.js} +1 -1
  38. package/dist/web/assets/{i18n-CVFADb_M.js → i18n-BtOOoyJd.js} +3 -3
  39. package/dist/web/assets/{identity-Dt0WZgqo.js → identity-CsowvtIm.js} +1 -1
  40. package/dist/web/assets/index-CWMqDq8g.css +1 -0
  41. package/dist/web/assets/index-MKmHNBtc.js +2 -0
  42. package/dist/web/assets/{model-B8sOxih0.js → model-CGpllrds.js} +1 -1
  43. package/dist/web/assets/{personal-profile-graph-aAqex7ty.js → personal-profile-graph-BNTG4PSY.js} +1 -1
  44. package/dist/web/assets/profile-model-BQO4zNs9.js +1 -0
  45. package/dist/web/assets/{shared-report-BQD6l_cS.js → shared-report-BFUJqqQe.js} +1 -1
  46. package/dist/web/assets/task-evidence-BUu7yYIn.js +1 -0
  47. package/dist/web/assets/{useApi-CROJJdhE-CuJ16v2S.js → useApi-CROJJdhE-raNlHRDl.js} +1 -1
  48. package/dist/web/assets/{useModalDialog-Ddv2XJQi.js → useModalDialog-CroGk2Cs.js} +1 -1
  49. package/dist/web/assets/{vue-router-C8KuEZ6a.js → vue-router-JFYBLXB1.js} +1 -1
  50. package/dist/web/index.html +12 -10
  51. package/docs/agent-conversations-and-collaboration.md +115 -0
  52. package/docs/project-agent-memory.md +28 -0
  53. package/graph-provider/fuli_graph/agent_conversation_models.py +72 -0
  54. package/graph-provider/fuli_graph/agent_conversation_routes.py +47 -0
  55. package/graph-provider/fuli_graph/agent_loan_models.py +28 -0
  56. package/graph-provider/fuli_graph/agent_quality_policy.py +151 -0
  57. package/graph-provider/fuli_graph/agent_verification_models.py +22 -0
  58. package/graph-provider/fuli_graph/conversation_context.py +30 -0
  59. package/graph-provider/fuli_graph/project_agent_executor_models.py +3 -0
  60. package/graph-provider/fuli_graph/project_agent_memory_routes.py +2 -0
  61. package/graph-provider/fuli_graph/project_agent_models.py +10 -0
  62. package/graph-provider/fuli_graph/project_agent_task_models.py +4 -0
  63. package/graph-provider/fuli_graph/runtime.py +14 -0
  64. package/graph-provider/fuli_graph/store.py +6 -0
  65. package/graph-provider/fuli_graph/store_agent_conversations.py +206 -0
  66. package/graph-provider/fuli_graph/store_agent_loans.py +146 -0
  67. package/graph-provider/fuli_graph/store_agent_verification.py +111 -0
  68. package/graph-provider/fuli_graph/store_project_agent_executors.py +2 -0
  69. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +6 -0
  70. package/graph-provider/fuli_graph/store_project_agent_tasks.py +20 -8
  71. package/graph-provider/fuli_graph/store_task_contexts.py +9 -0
  72. package/graph-provider/fuli_graph/task_context_routes.py +7 -0
  73. package/graph-provider/pyproject.toml +1 -1
  74. package/npm-shrinkwrap.json +2 -2
  75. package/package.json +2 -1
  76. package/skills/operating-fuli/SKILL.md +4 -0
  77. package/src/agent-tools/agent-collaboration-definitions.js +30 -0
  78. package/src/agent-tools/agent-conversation-definitions.js +21 -0
  79. package/src/agent-tools/graph-definitions.js +17 -33
  80. package/src/agent-tools/graph-handlers.js +17 -1
  81. package/src/agent-tools/interface-catalog.js +2 -0
  82. package/src/agent-tools/project-agent-definitions.js +21 -0
  83. package/src/agent-tools/project-relation-definitions.js +36 -0
  84. package/src/agents/claude-code/conversation-transcript.js +20 -0
  85. package/src/agents/claude-code/lifecycle-hook.js +154 -0
  86. package/src/agents/claude-code/lifecycle-hooks.js +20 -0
  87. package/src/agents/codex/conversation-transcript.js +22 -0
  88. package/src/agents/codex/lifecycle-hook.js +47 -8
  89. package/src/agents/codex/lifecycle-hooks.js +6 -6
  90. package/src/agents/cursor/lifecycle-hook.js +5 -4
  91. package/src/app/agent-request-context.js +6 -1
  92. package/src/app/test-tools.js +26 -0
  93. package/src/conversations/sync-transcript.js +144 -0
  94. package/src/conversations/task-context-view.js +28 -0
  95. package/src/conversations/transcript-guard.js +54 -0
  96. package/src/conversations/transcript-reader.js +113 -0
  97. package/src/graphiti/agent-collaboration.js +62 -0
  98. package/src/graphiti/agent-conversations.js +75 -0
  99. package/src/graphiti/agent-knowledge-workflows.js +7 -1
  100. package/src/graphiti/project-agent-control-plane.js +22 -2
  101. package/src/graphiti/project-agent-mapping.js +19 -1
  102. package/src/graphiti/project-agent-task-entry.js +11 -1
  103. package/src/graphiti/project-agent-workflows.js +1 -0
  104. package/src/graphiti/provider-client.js +17 -0
  105. package/src/graphiti/verification-preference.js +24 -0
  106. package/src/http/graph-api-router.js +17 -0
  107. package/src/mcp/create-mcp-server.js +13 -4
  108. package/src/mcp/session-id.js +4 -0
  109. package/src/mcp/tool-annotations.js +8 -1
  110. package/src/setup/agent-installation-status.js +1 -0
  111. package/src/setup/claude-code-config.js +13 -19
  112. package/dist/web/assets/AgentHand-BfLYPGjg.js +0 -1
  113. package/dist/web/assets/EmployeeRecruitDialog-D-W043v4.js +0 -1
  114. package/dist/web/assets/EmployeeWorkbenchPage-Cz7y-IFD.css +0 -1
  115. package/dist/web/assets/EmployeeWorkbenchPage-HHQPxdqI.js +0 -3
  116. package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +0 -1
  117. package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +0 -1
  118. package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +0 -1
  119. package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +0 -12
  120. package/dist/web/assets/index-BUMq5zNh.js +0 -2
  121. package/dist/web/assets/index-C0ci8dnU.css +0 -1
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ import { withTranscriptGuard } from '../../conversations/transcript-guard.js';
3
+ import { compactTaskContext } from '../../conversations/task-context-view.js';
4
+ import { syncConversationTranscript, withTranscriptNotice, claimTranscriptBoundary, recordTranscriptTaskEntry } from '../../conversations/sync-transcript.js';
5
+ import { normalizeClaudeRecord, verifyClaudeTranscript } from './conversation-transcript.js';
6
+ import { resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { callAgentTool } from '../../agent-tools.js';
10
+ import { createRuntimeLeaseClient } from '../../adaptive-runtime/lease-client.js';
11
+ import { openFederatedGraphApplication } from '../../graphiti/federated-application.js';
12
+ import { resolveGraphRuntimeOptions } from '../../graphiti/runtime-config.js';
13
+ import { hookAdditionalContextToolResult } from '../../mcp/tool-result.js';
14
+ import { boundedHookMessage } from '../lifecycle-message.js';
15
+
16
+ export async function claudeLifecycleOutput(event, input, invoke) {
17
+ if (!['UserPromptSubmit', 'Stop'].includes(event)) throw new TypeError('Unsupported Claude lifecycle event');
18
+ const sessionId = input?.session_id;
19
+ if (typeof sessionId !== 'string' || !sessionId.trim() || sessionId.length > 256) {
20
+ throw new TypeError('Claude lifecycle requires a host session identifier');
21
+ }
22
+ const source = { sourceApplication: 'claude_code', sourceSessionId: sessionId };
23
+ if (event === 'UserPromptSubmit') {
24
+ if (typeof input.cwd !== 'string' || !input.cwd.trim() || typeof input.prompt !== 'string') {
25
+ throw new TypeError('Claude task entry requires cwd and prompt');
26
+ }
27
+ const context = await invoke('begin_task_context', {
28
+ sessionId, projectPath: input.cwd, taskPrompt: input.prompt.slice(0, 8192), ...source
29
+ });
30
+ return JSON.parse(hookAdditionalContextToolResult(compactTaskContext(context), {
31
+ hookEventName: event,
32
+ label: 'Fuli task context. Apply effective_preferences and use taskContextToken for the final checkpoint.',
33
+ limitBytes: 128 * 1024,
34
+ itemLimit: 1000
35
+ }).content[0].text);
36
+ }
37
+ const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
38
+ const reason = boundedHookMessage(check?.reason);
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 };
53
+ }
54
+
55
+ export function claudeLifecycleUnavailable(event) {
56
+ const warning = 'Fuli context is unavailable; memory recovery and task checkpoint are not verified. '
57
+ + 'Continue with the user request and use Fuli MCP when it becomes available.';
58
+ return event === 'UserPromptSubmit'
59
+ ? { systemMessage: warning, hookSpecificOutput: { hookEventName: event, additionalContext: warning } }
60
+ : { systemMessage: warning };
61
+ }
62
+
63
+ export async function runClaudeLifecycleHook(args, dependencies = {}) {
64
+ const event = args[args.indexOf('--event') + 1];
65
+ const input = await (dependencies.readInput ?? readHookInput)();
66
+ if (dependencies.invoke) return claudeLifecycleOutput(event, input, dependencies.invoke);
67
+ const { runtimeConfigPath } = (dependencies.resolveRuntimeOptions ?? resolveGraphRuntimeOptions)(
68
+ args, dependencies.env ?? process.env
69
+ );
70
+ let app;
71
+ let leases;
72
+ try {
73
+ app = (dependencies.openApplication ?? openFederatedGraphApplication)({ runtimeConfigPath });
74
+ leases = (dependencies.createLeases ?? createRuntimeLeaseClient)({ runtimeConfigPath });
75
+ const request = { signal: dependencies.signal };
76
+ return await leases.withGraphLease('claude-code-lifecycle', async () => {
77
+ try {
78
+ if (app.getCapturePolicy?.().enabled && input.transcript_path) dependencies.onTranscriptGuard?.();
79
+ return await withTranscriptGuard(runtimeConfigPath, input, 'claude_code', app.getCapturePolicy?.().enabled, async guard => {
80
+ let sync = await syncConversationTranscript(app, input, 'claude_code', {
81
+ normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
82
+ }, guard);
83
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
84
+ const entryGuard = sync.status === 'capture_disabled' ? null : guard;
85
+ if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
86
+ const output = await claudeLifecycleOutput(event, input, async (name, parameters) => {
87
+ const context = await (dependencies.callTool ?? callAgentTool)(app, name, parameters, request);
88
+ if (name === 'begin_task_context') await recordTranscriptTaskEntry(entryGuard, context);
89
+ return context;
90
+ });
91
+ if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'claude_code', sync, {
92
+ normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
93
+ }, entryGuard);
94
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
95
+ return withTranscriptNotice(output, sync);
96
+ });
97
+ } catch (error) {
98
+ if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
99
+ return { decision: 'block', reason: error.code === 'EADDRINUSE'
100
+ ? '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
+ : 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
102
+ }
103
+ }, request);
104
+ } finally {
105
+ await Promise.allSettled([close(leases), close(app)]);
106
+ }
107
+ }
108
+
109
+ async function close(resource) { await resource?.close(); }
110
+
111
+ async function readHookInput() {
112
+ const chunks = [];
113
+ let size = 0;
114
+ for await (const chunk of process.stdin) {
115
+ size += chunk.length;
116
+ if (size > 256 * 1024) throw new TypeError('Hook input exceeds limit');
117
+ chunks.push(chunk);
118
+ }
119
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
120
+ }
121
+
122
+ async function main() {
123
+ const args = process.argv.slice(2);
124
+ const event = args[args.indexOf('--event') + 1];
125
+ const configured = args.includes('--timeout-ms') ? Number(args[args.indexOf('--timeout-ms') + 1]) : 28000;
126
+ const timeoutMs = Number.isSafeInteger(configured) && configured > 0 && configured <= 2_147_483_647
127
+ ? configured : 28000;
128
+ const controller = new AbortController();
129
+ let emitted = false;
130
+ let guarded = false;
131
+ const emit = (output, callback) => {
132
+ if (emitted) return;
133
+ emitted = true;
134
+ process.stdout.write(`${JSON.stringify(output)}\n`, callback);
135
+ };
136
+ // Finish before the host timeout, even if a dependency ignores cancellation.
137
+ // No prompt, transcript, path or raw exception is written to diagnostics.
138
+ const deadline = setTimeout(() => {
139
+ 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));
141
+ }, timeoutMs);
142
+ const abort = setTimeout(() => controller.abort(), Math.max(1, timeoutMs - 1500));
143
+ try {
144
+ const output = await runClaudeLifecycleHook(args, { signal: controller.signal, onTranscriptGuard: () => { guarded = true; } });
145
+ emit(output);
146
+ } catch {
147
+ emit(claudeLifecycleUnavailable(event));
148
+ } finally {
149
+ clearTimeout(abort);
150
+ clearTimeout(deadline);
151
+ }
152
+ }
153
+
154
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,20 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { quoteShellArgument } from '../../cli/shell-argument.js';
3
+
4
+ export const CLAUDE_LIFECYCLE_MARKER = '--fuli-claude-lifecycle';
5
+
6
+ export function claudeLifecycleCommand(context, event, timeoutSec) {
7
+ const script = context.claudeLifecycleHookPath ??
8
+ join(dirname(context.mcpServerPath), 'agents', 'claude-code', 'lifecycle-hook.js');
9
+ return [
10
+ context.nodePath, script, CLAUDE_LIFECYCLE_MARKER,
11
+ '--runtime-config', context.runtimeConfigPath, '--event', event,
12
+ '--timeout-ms', String(Math.max(100, timeoutSec * 1000 - 2000))
13
+ ].map(value => quoteShellArgument(value, context.platform)).join(' ');
14
+ }
15
+
16
+ export function isClaudeLifecycleCommand(hook) {
17
+ return hook?.type === 'command' && typeof hook.command === 'string' &&
18
+ hook.command.includes(CLAUDE_LIFECYCLE_MARKER) &&
19
+ hook.command.includes('lifecycle-hook.js');
20
+ }
@@ -0,0 +1,22 @@
1
+ // Codex rollout v1: visible response items only; event_msg mirrors are omitted.
2
+ export function normalizeCodexRecord(record) {
3
+ if (record?.type !== 'response_item') return [];
4
+ const item = record.payload;
5
+ if (item?.type === 'message' && ['user', 'assistant'].includes(item.role)) {
6
+ const content = (item.content ?? []).filter(block => ['input_text', 'output_text'].includes(block.type))
7
+ .map(block => block.text ?? '').join('\n');
8
+ return content ? [{ role: item.role, kind: 'message', content }] : [];
9
+ }
10
+ if (['function_call', 'custom_tool_call'].includes(item?.type)) {
11
+ return [{ role: 'assistant', kind: 'tool_call', content: JSON.stringify({
12
+ call_id: item.call_id, name: item.name, arguments: item.arguments ?? item.input
13
+ }) }];
14
+ }
15
+ if (['function_call_output', 'custom_tool_call_output'].includes(item?.type)) {
16
+ return [{ role: 'tool', kind: 'tool_result', content: JSON.stringify({ call_id: item.call_id, output: item.output }) }];
17
+ }
18
+ return [];
19
+ }
20
+ export function verifyCodexTranscript(firstRecord, sessionId) {
21
+ return firstRecord?.type === 'session_meta' && firstRecord.payload?.id === sessionId;
22
+ }
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { withTranscriptGuard } from '../../conversations/transcript-guard.js';
3
+ import { compactTaskContext } from '../../conversations/task-context-view.js';
4
+ import { syncConversationTranscript, withTranscriptNotice, claimTranscriptBoundary, recordTranscriptTaskEntry } from '../../conversations/sync-transcript.js';
5
+ import { normalizeCodexRecord, verifyCodexTranscript } from './conversation-transcript.js';
6
+ import { hookAdditionalContextToolResult } from '../../mcp/tool-result.js';
2
7
  import { resolve } from 'node:path';
3
8
  import { fileURLToPath } from 'node:url';
4
9
 
@@ -37,13 +42,26 @@ export async function codexStopLifecycleOutput(input, invoke) {
37
42
  return { decision: 'block', reason: boundedHookMessage(check.reason) };
38
43
  }
39
44
 
45
+ export async function codexLifecycleOutput(event, input, invoke) {
46
+ if (event === 'Stop') return codexStopLifecycleOutput(input, invoke);
47
+ if (!input?.session_id || typeof input.cwd !== 'string' || typeof input.prompt !== 'string') {
48
+ throw new TypeError('Codex task entry requires session, cwd and prompt');
49
+ }
50
+ const context = await invoke('begin_task_context', { sessionId: input.session_id,
51
+ turnId: input.turn_id ?? null, projectPath: input.cwd, taskPrompt: input.prompt.slice(0, 8192),
52
+ sourceApplication: 'codex', sourceSessionId: input.session_id });
53
+ return JSON.parse(hookAdditionalContextToolResult(compactTaskContext(context), { hookEventName: event,
54
+ label: 'Fuli task context. Apply effective_preferences and checkpoint with taskContextToken.',
55
+ limitBytes: 128 * 1024, itemLimit: 1000 }).content[0].text);
56
+ }
57
+
40
58
  export async function runCodexLifecycleHook(args, dependencies = {}) {
41
59
  const event = args[args.indexOf('--event') + 1];
42
- if (event !== 'Stop') throw new TypeError('Missing Codex lifecycle event');
60
+ if (!['Stop', 'UserPromptSubmit'].includes(event)) throw new TypeError('Missing Codex lifecycle event');
43
61
  const input = await (dependencies.readInput ?? readHookInput)();
44
62
  const write = dependencies.write ?? ((value) => process.stdout.write(value));
45
63
  if (dependencies.invoke) {
46
- const output = await codexStopLifecycleOutput(input, dependencies.invoke);
64
+ const output = await codexLifecycleOutput(event, input, dependencies.invoke);
47
65
  write(`${JSON.stringify(output)}\n`);
48
66
  return output;
49
67
  }
@@ -63,12 +81,33 @@ export async function runCodexLifecycleHook(args, dependencies = {}) {
63
81
  runtimeConfigPath
64
82
  });
65
83
  const invokeTool = dependencies.callTool ?? callAgentTool;
66
- const output = await leases.withGraphLease('codex-lifecycle', () =>
67
- codexStopLifecycleOutput(
68
- input,
69
- (name, parameters) => invokeTool(app, name, parameters)
70
- )
71
- );
84
+ const output = await leases.withGraphLease('codex-lifecycle', async () => {
85
+ try {
86
+ return await withTranscriptGuard(runtimeConfigPath, input, 'codex', app.getCapturePolicy?.().enabled, async guard => {
87
+ let sync = await syncConversationTranscript(app, input, 'codex', {
88
+ normalize: normalizeCodexRecord, verify: verifyCodexTranscript
89
+ }, guard);
90
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
91
+ const entryGuard = sync.status === 'capture_disabled' ? null : guard;
92
+ if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
93
+ const result = await codexLifecycleOutput(event, input, async (name, parameters) => {
94
+ const context = await invokeTool(app, name, parameters);
95
+ if (name === 'begin_task_context') await recordTranscriptTaskEntry(entryGuard, context);
96
+ return context;
97
+ });
98
+ if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'codex', sync, {
99
+ normalize: normalizeCodexRecord, verify: verifyCodexTranscript
100
+ }, entryGuard);
101
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
102
+ return withTranscriptNotice(result, sync);
103
+ });
104
+ } catch (error) {
105
+ if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
106
+ return { decision: 'block', reason: error.code === 'EADDRINUSE'
107
+ ? '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.'
108
+ : 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
109
+ }
110
+ });
72
111
  write(`${JSON.stringify(output)}\n`);
73
112
  return output;
74
113
  } finally {
@@ -18,8 +18,8 @@ export function withCodexLifecycleHooks(current, { timeout = 30, context = null
18
18
  }],
19
19
  ['Stop', 'verify_task_checkpoint', { sessionId: '${session_id}' }]
20
20
  ]) {
21
- const handler = event === 'Stop' && context
22
- ? { type: 'command', command: codexStopCommand(context), timeout }
21
+ const handler = context
22
+ ? { type: 'command', command: codexStopCommand(context, event), timeout }
23
23
  : { type: 'mcp_tool', server: 'fuli', tool, input, timeout };
24
24
  hooks[event] = [...(hooks[event] ?? []), { hooks: [handler] }];
25
25
  }
@@ -88,12 +88,12 @@ export function withCodexTomlLifecycleHooks(source, { timeout = 30, context = nu
88
88
  const lines = clean.replaceAll('\r\n', '\n').split('\n');
89
89
  while (lines.length && !lines.at(-1).trim()) lines.pop();
90
90
  const blocks = [
91
- ...codexTomlHookBlock('UserPromptSubmit', 'begin_task_context', {
91
+ ...(context ? codexTomlCommandHookBlock('UserPromptSubmit', codexStopCommand(context, 'UserPromptSubmit'), timeout) : codexTomlHookBlock('UserPromptSubmit', 'begin_task_context', {
92
92
  sessionId: '${session_id}',
93
93
  turnId: '${turn_id}',
94
94
  projectPath: '${cwd}',
95
95
  taskPrompt: '${prompt}'
96
- }, timeout),
96
+ }, timeout)),
97
97
  ...(context
98
98
  ? codexTomlCommandHookBlock('Stop', codexStopCommand(context), timeout)
99
99
  : codexTomlHookBlock('Stop', 'verify_task_checkpoint', {
@@ -212,7 +212,7 @@ function isManagedCodexHook(hook) {
212
212
  hook.command.includes(COMMAND_MARKER) && hook.command.includes('lifecycle-hook.js');
213
213
  }
214
214
 
215
- function codexStopCommand(context) {
215
+ function codexStopCommand(context, event = 'Stop') {
216
216
  const script = context.codexLifecycleHookPath ??
217
217
  join(dirname(context.mcpServerPath), 'agents', 'codex', 'lifecycle-hook.js');
218
218
  return [
@@ -222,7 +222,7 @@ function codexStopCommand(context) {
222
222
  '--runtime-config',
223
223
  context.runtimeConfigPath,
224
224
  '--event',
225
- 'Stop'
225
+ event
226
226
  ].map(value => quoteShellArgument(value, context.platform)).join(' ');
227
227
  }
228
228
 
@@ -18,12 +18,13 @@ export async function cursorLifecycleOutput(event, input, invoke) {
18
18
  }
19
19
  const source = { sourceApplication: 'cursor', sourceSessionId: sessionId };
20
20
  if (event === 'stop') {
21
- // Never restart a cancelled/error turn or loop indefinitely on an outage.
22
- if (input.status !== 'completed' || (input.loop_count ?? 0) >= 2) return {};
21
+ // One host-counted continuation at most; never restart cancelled/error turns.
22
+ if (input.status !== 'completed' || (input.loop_count ?? 0) >= 1) return {};
23
23
  const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
24
- const reason = boundedHookMessage(check.reason);
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') });
25
26
  return check.status === 'checkpoint_required' && reason.trim()
26
- ? { followup_message: reason }
27
+ ? { followup_message: reason + notice }
27
28
  : {};
28
29
  }
29
30
  const roots = input.workspace_roots;
@@ -1,3 +1,4 @@
1
+ import { testToolsEnabled } from './test-tools.js';
1
2
  import { AsyncLocalStorage } from 'node:async_hooks';
2
3
 
3
4
  const requestContext = new AsyncLocalStorage();
@@ -8,7 +9,7 @@ export function runWithAgentRequestContext(value, operation) {
8
9
  }
9
10
  const signal = abortSignal(value?.signal);
10
11
  signal?.throwIfAborted();
11
- return signal ? requestContext.run({ signal }, operation) : operation();
12
+ return requestContext.run({ signal, testToolsEnabled: value?.testToolsEnabled ?? testToolsEnabled() }, operation);
12
13
  }
13
14
 
14
15
  export function activeAgentRequestSignal() {
@@ -21,3 +22,7 @@ function abortSignal(value) {
21
22
  ? value
22
23
  : null;
23
24
  }
25
+
26
+ export function activeTestToolsEnabled() {
27
+ return requestContext.getStore()?.testToolsEnabled ?? testToolsEnabled();
28
+ }
@@ -0,0 +1,26 @@
1
+ import { ApplicationError, ApplicationErrorCode } from './application-error.js';
2
+
3
+ /** Tools that must not appear on default production MCP surfaces. */
4
+ export const TEST_ONLY_TOOL_NAMES = Object.freeze([
5
+ 'cleanup_test_project_agents'
6
+ ]);
7
+
8
+ const TEST_ONLY_TOOL_NAME_SET = new Set(TEST_ONLY_TOOL_NAMES);
9
+
10
+ export function isTestOnlyToolName(name) {
11
+ return TEST_ONLY_TOOL_NAME_SET.has(name);
12
+ }
13
+
14
+ /** Opt-in via FULI_ENABLE_TEST_TOOLS=1|true for local harnesses and e2e. */
15
+ export function testToolsEnabled(env = process.env) {
16
+ const value = env?.FULI_ENABLE_TEST_TOOLS;
17
+ return value === '1' || value === 'true';
18
+ }
19
+
20
+ export function assertTestToolsEnabled(env = process.env) {
21
+ if (testToolsEnabled(env)) return;
22
+ throw new ApplicationError(
23
+ ApplicationErrorCode.VALIDATION,
24
+ 'cleanup_test_project_agents is disabled unless FULI_ENABLE_TEST_TOOLS=1'
25
+ );
26
+ }
@@ -0,0 +1,144 @@
1
+ import { initialTranscriptBoundary, readTranscriptBatch } from './transcript-reader.js';
2
+ import { transcriptDigest } from './transcript-guard.js';
3
+
4
+ const pending = reason => ({ status: 'partial', entryBlocked: true, reason });
5
+ const retryReason = 'Transcript handoff is pending. Retry task entry to continue its saved progress; no new Agent context was supplied';
6
+
7
+ export async function syncConversationTranscript(application, input, sourceApplication, adapter, guard = null) {
8
+ if (!application.getCapturePolicy?.().enabled) return { status: 'capture_disabled' };
9
+ if (!input?.transcript_path) return { status: 'partial', reason: 'Host did not supply a visible transcript' };
10
+ let committedPages = 0;
11
+ try {
12
+ const task = await application.personal.currentTaskContext({ personal_space_id: application.config.personal.spaceId,
13
+ session_id: input.session_id, source_application: sourceApplication });
14
+ if (!task?.project_agent_id) return guard?.value ? pending(retryReason) : { status: 'unassigned' };
15
+ const scope = { personal_space_id: application.config.personal.spaceId, personal_project_id: task.personal_project_id,
16
+ agent_id: task.project_agent_id, source_application: sourceApplication };
17
+ const policy = await application.personal.conversation('query', { ...scope, mode: 'policy' });
18
+ if (!policy.enabled) return { status: 'capture_disabled' };
19
+ let session = await application.personal.conversation('query', { ...scope, mode: 'session', session_id: input.session_id });
20
+ if (!session.initialized) return guard?.value ? pending('Transcript initialization changed during handoff. Start a new host session and resume the intended Agent') : { status: 'partial', reason: 'Transcript capture needs a verified task entry boundary' };
21
+ if (guard?.value?.phase === 'claim') {
22
+ if (guard.value.nextOwner !== transcriptDigest(task.token)) return pending('Transcript task ownership changed during handoff. Start a new host session and resume the intended Agent');
23
+ await application.personal.conversation('boundary', { ...scope, session_id: input.session_id,
24
+ task_context_token: task.token, expected_cursor: guard.value.boundary, cursor: guard.value.boundary });
25
+ await guard.clear();
26
+ } else if (guard?.value && guard.value.owner !== transcriptDigest(task.token)) {
27
+ return pending('Transcript task ownership changed during handoff. Start a new host session and resume the intended Agent');
28
+ }
29
+ if (typeof input.prompt === 'string' && guard && !guard.value) {
30
+ await guard.write({ version: 1, phase: 'scan', owner: transcriptDigest(task.token),
31
+ prompt: transcriptDigest(input.prompt), scanCursor: session.cursor ?? 0, userMessages: 0, omitted: false });
32
+ }
33
+ if (guard?.value && typeof input.prompt === 'string' && guard.value.prompt !== transcriptDigest(input.prompt)) {
34
+ return pending('An earlier transcript handoff is pending. Retry the earlier task entry before sending a different prompt');
35
+ }
36
+ if (guard?.value?.phase === 'beginning') return pending('Task entry was interrupted before its new ownership was recorded. Start a new host session and resume the intended Agent; this uncertain native tail was not imported');
37
+ if (guard?.value?.phase === 'scan') {
38
+ for (let page = 0; page < 8; page++) {
39
+ const state = guard.value;
40
+ const batch = await readTranscriptBatch(input.transcript_path, input.session_id, state.scanCursor, adapter,
41
+ { stopBeforeDigest: state.prompt });
42
+ const next = { ...state, scanCursor: batch.cursor,
43
+ userMessages: state.userMessages + (batch.userMessages ?? 0), omitted: state.omitted || batch.omitted === true,
44
+ uncertainFrom: state.uncertainFrom ?? batch.uncertainFrom ?? null };
45
+ if (batch.atPromptBoundary) {
46
+ await guard.write({ ...next, scanCursor: batch.promptEndCursor,
47
+ candidate: batch.cursor, userMessages: next.userMessages + 1 });
48
+ continue;
49
+ }
50
+ // A repeated prompt in old backlog is not the new turn if visible
51
+ // messages follow it. Only the last visible prompt can prove a handoff.
52
+ if (batch.events.length) next.candidate = null;
53
+ // EOF is a prospective boundary only if no unverified user record or
54
+ // omitted fragment was traversed and the last native write is complete.
55
+ if (!batch.hasMore && batch.completeRecord && (Number.isSafeInteger(next.candidate) || (!next.userMessages && !next.omitted))) {
56
+ await guard.write({ ...next, phase: 'ready', boundary: next.candidate ?? batch.cursor });
57
+ break;
58
+ }
59
+ if (!batch.hasMore && next.uncertainFrom !== null) next.ambiguousFrom = next.uncertainFrom;
60
+ await guard.write(next);
61
+ if (!batch.hasMore) return pending('The current prompt could not be verified in native history. Retry after the host finishes writing; if it cannot record a blocked prompt, start a new host session and resume the intended Agent');
62
+ if (batch.cursor === state.scanCursor) return pending(retryReason);
63
+ }
64
+ if (guard.value.phase === 'scan') return pending(retryReason);
65
+ }
66
+ if (guard?.value && (session.cursor ?? 0) > guard.value.boundary) return pending('Transcript cursor moved beyond the verified handoff boundary; no scope was changed');
67
+ let result, userMessages = 0, omitted = false;
68
+ for (let page = 0; page < 8; page += 1) {
69
+ const batch = await readTranscriptBatch(input.transcript_path, input.session_id, session.cursor ?? 0, adapter,
70
+ guard?.value ? { stopAtCursor: Math.max(session.cursor ?? 0, guard.value.ambiguousFrom ?? guard.value.boundary) } : { stopBeforePrompt: input.prompt ?? null });
71
+ userMessages += batch.userMessages ?? 0; omitted ||= batch.omitted === true;
72
+ // Without a durable guard, an unmatched user record cannot be attributed
73
+ // to the prior owner merely because the file currently ends after it.
74
+ if (!guard && typeof input.prompt === 'string' && userMessages && !batch.atPromptBoundary) return pending(retryReason);
75
+ result = await application.personal.conversation('append', { ...scope, session_id: input.session_id,
76
+ task_context_token: task.token, events: batch.events, expected_cursor: session.cursor ?? 0, cursor: batch.cursor });
77
+ if (result.status === 'saved') committedPages++;
78
+ if (!batch.hasMore && guard?.value?.ambiguousFrom !== undefined && batch.cursor < guard.value.boundary) {
79
+ const boundary = guard.value.boundary;
80
+ result = await application.personal.conversation('append', { ...scope, session_id: input.session_id,
81
+ task_context_token: task.token, expected_cursor: batch.cursor, cursor: boundary,
82
+ events: [{ event_id: `jsonl-v1:${batch.cursor}:${boundary}:ambiguous`, role: 'tool', kind: 'tool_result',
83
+ content: '[Visible transcript content omitted: ambiguous task ownership; original remains in native history.]' }] });
84
+ batch.cursor = boundary;
85
+ }
86
+ if (!batch.hasMore) return { ...result, cursor: batch.cursor,
87
+ atPromptBoundary: Boolean(guard?.value || batch.atPromptBoundary || (typeof input.prompt === 'string' && !userMessages && !omitted && batch.completeRecord)),
88
+ entryBlocked: Boolean(guard?.value && typeof input.prompt !== 'string'),
89
+ coverage: 'supported_visible_events', parser: 'jsonl-v1', ...(guard?.value && typeof input.prompt !== 'string' ? { reason: retryReason } : {}) };
90
+ if (batch.cursor === (session.cursor ?? 0)) break;
91
+ session = { ...session, cursor: batch.cursor };
92
+ }
93
+ return pending(retryReason);
94
+ } catch {
95
+ return { status: committedPages ? 'partial' : 'unsaved', entryBlocked: typeof input.prompt === 'string',
96
+ reason: committedPages ? 'Some transcript pages were saved before capture failed; the next hook resumes from committed progress'
97
+ : 'Visible transcript capture could not be confirmed; native history will be retried at the next hook' };
98
+ }
99
+ }
100
+
101
+ export async function recordTranscriptTaskEntry(guard, context) {
102
+ if (!guard?.value) return;
103
+ const token = context?.taskContextToken ?? context?.task_context_token;
104
+ if (typeof token !== 'string' || !token) throw new Error('Task entry did not return its ownership token');
105
+ await guard.write({ ...guard.value, phase: 'claim', nextOwner: transcriptDigest(token) });
106
+ }
107
+
108
+ export function withTranscriptNotice(output, sync) {
109
+ if (!sync?.entryBlocked && !['unsaved', 'partial'].includes(sync?.status)) return output;
110
+ return { ...output, systemMessage: [output.systemMessage, `Fuli: ${sync.reason}.`].filter(Boolean).join('\n') };
111
+ }
112
+
113
+ // A scope switch is safe only after the old scope was flushed up to the current
114
+ // prompt. Failed flushes keep the old owner, so new Agents cannot inherit an
115
+ // unrelated session tail. Native files remain available for explicit recovery.
116
+ export async function claimTranscriptBoundary(application, input, sourceApplication, sync, adapter, guard = null) {
117
+ if (!application.getCapturePolicy?.().enabled || !input.transcript_path) return sync;
118
+ try {
119
+ const task = await application.personal.currentTaskContext({ personal_space_id: application.config.personal.spaceId,
120
+ session_id: input.session_id, source_application: sourceApplication });
121
+ if (!task?.project_agent_id) return sync;
122
+ if (guard?.value && (guard.value.phase !== 'claim' || guard.value.nextOwner !== transcriptDigest(task.token))) return pending('Transcript claim does not match the task entry that created it');
123
+ const scope = { personal_space_id: application.config.personal.spaceId,
124
+ personal_project_id: task.personal_project_id, agent_id: task.project_agent_id,
125
+ source_application: sourceApplication, session_id: input.session_id };
126
+ const policy = await application.personal.conversation('query', { ...scope, mode: 'policy' });
127
+ if (!policy.enabled) return { status: 'capture_disabled' };
128
+ const session = await application.personal.conversation('query', { ...scope, mode: 'session' });
129
+ if (!session.initialized) {
130
+ const cursor = await initialTranscriptBoundary(input.transcript_path, input.session_id, input.prompt, adapter);
131
+ await application.personal.conversation('boundary', { ...scope, task_context_token: task.token,
132
+ expected_cursor: 0, cursor, initialize_cursor: true });
133
+ return { status: 'ready', coverage: 'from_current_task' };
134
+ }
135
+ if (sync?.status === 'saved' && sync.atPromptBoundary) {
136
+ await application.personal.conversation('boundary', { ...scope, task_context_token: task.token,
137
+ expected_cursor: sync.cursor, cursor: sync.cursor });
138
+ await guard?.clear();
139
+ }
140
+ return sync;
141
+ } catch {
142
+ return { status: 'unsaved', entryBlocked: true, reason: 'Transcript boundary is unverified; saved task context is still available' };
143
+ }
144
+ }
@@ -0,0 +1,28 @@
1
+ // Preserve every effective instruction; remove evidence envelopes from the hot
2
+ // path. Full records remain available through explicit read tools.
3
+ export function compactTaskContext(value) {
4
+ if (!value || typeof value !== 'object') return value;
5
+ const result = { ...value };
6
+ if (Array.isArray(value.effective_preferences)) result.effective_preferences = value.effective_preferences.map(item => ({
7
+ instruction: item.instruction, preference_key: item.preference_key,
8
+ preference_scope: item.preference_scope, confirmation_status: item.confirmation_status
9
+ }));
10
+ const continuity = value.project_agent_context;
11
+ if (continuity?.memory) {
12
+ const memory = continuity.memory;
13
+ const current = memory.current;
14
+ const notes = current?.memory;
15
+ result.project_agent_context = { ...continuity, knowledge: undefined, recent_tasks: undefined,
16
+ memory: { ...memory, history: undefined, workLog: undefined,
17
+ current: current && { ...current, memory: notes && {
18
+ summary: notes.summary?.slice(0, 800),
19
+ decisions: notes.decisions?.slice(0, 3).map(note => note.slice(0, 160)),
20
+ openThreads: notes.openThreads?.slice(0, 3).map(note => note.slice(0, 160)),
21
+ nextActions: notes.nextActions?.slice(0, 3).map(note => note.slice(0, 160))
22
+ } },
23
+ view: 'preview', requiresFullReadBeforeWrite: true,
24
+ guidance: 'Use get_project_agent_memory before merging or checkpointing working memory; this preview omits history and may truncate notes.'
25
+ } };
26
+ }
27
+ return result;
28
+ }
@@ -0,0 +1,54 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, rm, lstat } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { createServer } from 'node:net';
5
+
6
+ export const transcriptDigest = value => createHash('sha256').update(value).digest('hex');
7
+
8
+ // Only opaque hashes and byte offsets are stored locally. The per-session lock
9
+ // covers scan, begin, and claim; a second hook must retry rather than race it.
10
+ export async function withTranscriptGuard(runtimeConfigPath, input, source, enabled, operation) {
11
+ if (!enabled || !input?.transcript_path || !input?.session_id) return operation(null);
12
+ const directory = join(dirname(runtimeConfigPath), 'transcript-guards');
13
+ await mkdir(directory, { recursive: true, mode: 0o700 });
14
+ if (!(await lstat(directory)).isDirectory()) throw new Error('Transcript guard directory is unavailable');
15
+ const key = transcriptDigest(`${runtimeConfigPath}\0${source}\0${input.session_id}`);
16
+ const path = join(directory, `${key}.json`);
17
+ // A loopback mutex is released by the OS even if the process is killed.
18
+ // Port collisions fail closed and request a retry; no transcript is served.
19
+ const lock = createServer(socket => socket.destroy());
20
+ const lockKey = transcriptDigest(`${directory}\0${key}`);
21
+ const port = 49152 + (parseInt(lockKey.slice(0, 8), 16) % 16384);
22
+ await new Promise((resolve, reject) => {
23
+ lock.once('error', reject);
24
+ lock.listen({ host: '127.0.0.1', port, exclusive: true }, resolve);
25
+ });
26
+ try {
27
+ let value = null;
28
+ try {
29
+ if (!(await lstat(path)).isFile()) throw new Error('Invalid transcript guard');
30
+ value = JSON.parse(await readFile(path, 'utf8'));
31
+ if (value.version !== 1 || !['scan', 'ready', 'beginning', 'claim'].includes(value.phase)
32
+ || !/^[a-f0-9]{64}$/.test(value.owner ?? '') || !/^[a-f0-9]{64}$/.test(value.prompt ?? '')
33
+ || !Number.isSafeInteger(value.scanCursor) || value.scanCursor < 0
34
+ || (value.boundary !== undefined && (!Number.isSafeInteger(value.boundary) || value.boundary < 0))) {
35
+ throw new Error('Invalid transcript guard state');
36
+ }
37
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
38
+ const guard = {
39
+ get value() { return value; },
40
+ async write(next) {
41
+ const temporary = `${path}.${randomUUID()}`;
42
+ try {
43
+ const file = await open(temporary, 'wx', 0o600);
44
+ try { await file.writeFile(JSON.stringify(next)); await file.sync(); }
45
+ finally { await file.close(); }
46
+ await rename(temporary, path);
47
+ value = next;
48
+ } finally { await rm(temporary, { force: true }); }
49
+ },
50
+ async clear() { await rm(path, { force: true }); value = null; }
51
+ };
52
+ return await operation(guard);
53
+ } finally { await new Promise(resolve => lock.close(resolve)); }
54
+ }