@gajae-code/coding-agent 0.8.2 → 0.9.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/CHANGELOG.md +29 -0
- package/dist/types/append-only-mode.d.ts +31 -0
- package/dist/types/capability/rule.d.ts +8 -0
- package/dist/types/cli/fixture-report.d.ts +20 -0
- package/dist/types/cli/fixture-report.test.d.ts +1 -0
- package/dist/types/cli/nofile-limit.d.ts +11 -0
- package/dist/types/cli/update-cli.d.ts +22 -0
- package/dist/types/config/settings-schema.d.ts +105 -1
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/export/ttsr.d.ts +14 -3
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/hooks/native-skill-hook.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +5 -1
- package/dist/types/modes/components/status-line.d.ts +9 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
- package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/notifications/config.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +8 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -0
- package/dist/types/sdk.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +43 -1
- package/dist/types/session/file-mention-pruning.d.ts +8 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +17 -1
- package/dist/types/skill-state/active-state.d.ts +18 -1
- package/dist/types/system-prompt.d.ts +18 -2
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/fork-context-budget.d.ts +7 -0
- package/dist/types/task/token-log.d.ts +30 -0
- package/dist/types/task/token-log.test.d.ts +1 -0
- package/dist/types/tools/bisect.d.ts +81 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/utils/file-mentions.d.ts +14 -0
- package/dist/types/utils/git.d.ts +32 -0
- package/dist/types/web/scrapers/youtube.d.ts +8 -0
- package/package.json +7 -7
- package/src/append-only-mode.ts +58 -0
- package/src/autoresearch/prompt.md +1 -1
- package/src/autoresearch/tools/log-experiment.ts +1 -1
- package/src/capability/rule.ts +9 -0
- package/src/cli/config-cli.ts +27 -3
- package/src/cli/fixture-report.test.ts +230 -0
- package/src/cli/fixture-report.ts +171 -0
- package/src/cli/nofile-limit.ts +48 -0
- package/src/cli/update-cli.ts +85 -9
- package/src/cli.ts +26 -0
- package/src/commands/contribution-prep.ts +1 -1
- package/src/config/settings-schema.ts +68 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
- package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
- package/src/defaults/gjc/skills/team/SKILL.md +16 -13
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
- package/src/defaults/gjc-defaults.ts +9 -0
- package/src/discovery/helpers.ts +10 -0
- package/src/discovery/index.ts +1 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +60 -2
- package/src/export/ttsr.ts +96 -18
- package/src/extensibility/extensions/runner.ts +181 -219
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
- package/src/gjc-runtime/deep-interview-state.ts +31 -2
- package/src/gjc-runtime/session-state-sidecar.ts +208 -13
- package/src/gjc-runtime/state-writer.ts +15 -0
- package/src/gjc-runtime/team-runtime.ts +164 -15
- package/src/gjc-runtime/ultragoal-guard.ts +55 -66
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1593 -143
- package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
- package/src/gjc-runtime/workflow-manifest.ts +74 -1
- package/src/goals/runtime.ts +10 -2
- package/src/hindsight/backend.ts +1 -3
- package/src/hooks/native-skill-hook.ts +95 -4
- package/src/internal-urls/docs-index.generated.ts +14 -5
- package/src/main.ts +43 -2
- package/src/memories/index.ts +12 -1
- package/src/modes/components/assistant-message.ts +241 -82
- package/src/modes/components/custom-editor.ts +1 -0
- package/src/modes/components/status-line.ts +167 -54
- package/src/modes/components/tool-execution.ts +44 -19
- package/src/modes/controllers/command-controller.ts +39 -2
- package/src/modes/controllers/event-controller.ts +66 -47
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +9 -1
- package/src/modes/rpc/rpc-mode.ts +29 -3
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/modes/shared/agent-wire/command-validation.ts +4 -0
- package/src/modes/shared/agent-wire/event-contract.ts +22 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +17 -4
- package/src/notifications/config.ts +16 -4
- package/src/notifications/index.ts +78 -5
- package/src/notifications/telegram-daemon-cli.ts +2 -2
- package/src/notifications/telegram-daemon-control.ts +2 -2
- package/src/notifications/telegram-daemon.ts +85 -8
- package/src/notifications/threaded-render.ts +17 -1
- package/src/prompts/agents/architect.md +19 -10
- package/src/prompts/agents/critic.md +16 -18
- package/src/prompts/agents/executor.md +14 -11
- package/src/prompts/agents/planner.md +18 -13
- package/src/prompts/ci-green-request.md +3 -8
- package/src/prompts/goals/goal-continuation.md +0 -5
- package/src/prompts/goals/goal-mode-active.md +0 -5
- package/src/prompts/system/agent-creation-architect.md +2 -2
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/system/plan-mode-subagent.md +3 -3
- package/src/prompts/system/project-prompt.md +0 -13
- package/src/prompts/system/subagent-system-prompt.md +3 -2
- package/src/prompts/system/system-prompt.md +26 -19
- package/src/prompts/system/volatile-project-context.md +10 -0
- package/src/prompts/system/web-search.md +4 -7
- package/src/prompts/tools/apply-patch.md +6 -0
- package/src/prompts/tools/ast-edit.md +1 -0
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/bisect.md +22 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +0 -2
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/github.md +6 -6
- package/src/prompts/tools/goal.md +1 -3
- package/src/prompts/tools/image-gen.md +12 -5
- package/src/prompts/tools/irc.md +5 -4
- package/src/prompts/tools/job.md +2 -2
- package/src/prompts/tools/lsp.md +1 -1
- package/src/prompts/tools/monitor.md +2 -2
- package/src/prompts/tools/patch.md +2 -2
- package/src/prompts/tools/replace.md +2 -21
- package/src/prompts/tools/rewind.md +1 -1
- package/src/prompts/tools/search-tool-bm25.md +1 -11
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/skill.md +3 -2
- package/src/prompts/tools/ssh.md +2 -1
- package/src/prompts/tools/subagent.md +2 -2
- package/src/prompts/tools/task.md +4 -5
- package/src/prompts/tools/todo-write.md +1 -1
- package/src/prompts/tools/vim.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/prompts/tools/write.md +8 -2
- package/src/sdk.ts +32 -22
- package/src/session/agent-session.ts +732 -107
- package/src/session/file-mention-pruning.ts +64 -0
- package/src/session/messages.ts +4 -0
- package/src/session/session-manager.ts +59 -2
- package/src/skill-state/active-state.ts +141 -5
- package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
- package/src/slash-commands/builtin-registry.ts +65 -0
- package/src/system-prompt.ts +57 -16
- package/src/task/executor.ts +125 -20
- package/src/task/fork-context-advisory.ts +2 -7
- package/src/task/fork-context-budget.ts +9 -0
- package/src/task/index.ts +4 -3
- package/src/task/output-manager.ts +4 -2
- package/src/task/token-log.test.ts +188 -0
- package/src/task/token-log.ts +179 -0
- package/src/tools/bisect.ts +367 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/output-meta.ts +116 -4
- package/src/tools/search.ts +8 -2
- package/src/tools/skill.ts +15 -0
- package/src/utils/file-mentions.ts +39 -7
- package/src/utils/git.ts +73 -0
- package/src/web/scrapers/youtube.ts +24 -12
|
@@ -1,14 +1,161 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
1
3
|
import * as path from "node:path";
|
|
2
4
|
import { loadCustomTools } from "../custom-tools/loader";
|
|
3
5
|
import type { CustomTool } from "../custom-tools/types";
|
|
4
|
-
import { loadEffectiveGjcPluginRegistry } from "./registry";
|
|
5
|
-
import { type SessionQuarantine,
|
|
6
|
+
import { loadEffectiveGjcPluginRegistry, registryPathForScope } from "./registry";
|
|
7
|
+
import { type SessionQuarantine, type SessionValidationResult, validateSessionBundles } from "./session-validation";
|
|
8
|
+
import type { GjcPluginRegistryEntry, GjcPluginScope } from "./types";
|
|
6
9
|
|
|
7
10
|
export interface AlwaysOnPluginTools {
|
|
8
11
|
tools: CustomTool[];
|
|
9
12
|
quarantine: SessionQuarantine[];
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
interface FileSnapshot {
|
|
16
|
+
path: string;
|
|
17
|
+
mtimeMs: number;
|
|
18
|
+
ctimeMs: number;
|
|
19
|
+
size: number;
|
|
20
|
+
ino: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ValidatedPluginRegistry {
|
|
24
|
+
effective: GjcPluginRegistryEntry[];
|
|
25
|
+
active: GjcPluginRegistryEntry[];
|
|
26
|
+
quarantine: SessionQuarantine[];
|
|
27
|
+
validation: SessionValidationResult;
|
|
28
|
+
registryFiles: FileSnapshot[];
|
|
29
|
+
pluginFiles: FileSnapshot[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CachedValidatedPluginRegistry extends ValidatedPluginRegistry {
|
|
33
|
+
registryKey: string;
|
|
34
|
+
pluginKey: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const validatedRegistryCache = new Map<string, CachedValidatedPluginRegistry>();
|
|
38
|
+
const hashCache = new Map<string, string>();
|
|
39
|
+
// Bound the digest memo so long sessions with plugin churn cannot grow it
|
|
40
|
+
// unboundedly; entries are re-derivable from disk at the cost of one read.
|
|
41
|
+
const HASH_CACHE_MAX_ENTRIES = 512;
|
|
42
|
+
const registryScopes: GjcPluginScope[] = ["user", "project"];
|
|
43
|
+
|
|
44
|
+
async function snapshotExistingFile(filePath: string): Promise<FileSnapshot | null> {
|
|
45
|
+
try {
|
|
46
|
+
const stat = await fs.stat(filePath);
|
|
47
|
+
return { path: filePath, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, size: stat.size, ino: stat.ino };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function snapshotsKey(snapshots: readonly FileSnapshot[]): string {
|
|
55
|
+
return snapshots.map(s => `${s.path}:${s.mtimeMs}:${s.ctimeMs}:${s.size}:${s.ino}`).join("|");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function snapshotRegistryFiles(cwd: string): Promise<FileSnapshot[]> {
|
|
59
|
+
const snapshots = await Promise.all(
|
|
60
|
+
registryScopes.map(scope => snapshotExistingFile(registryPathForScope(scope, cwd))),
|
|
61
|
+
);
|
|
62
|
+
return snapshots.filter((s): s is FileSnapshot => s !== null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function snapshotPluginFiles(entries: readonly GjcPluginRegistryEntry[]): Promise<FileSnapshot[]> {
|
|
66
|
+
const snapshots: FileSnapshot[] = [];
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.enabled) continue;
|
|
69
|
+
for (const file of entry.copiedFiles) {
|
|
70
|
+
const abs = path.join(entry.pluginRoot, file.relativePath);
|
|
71
|
+
const snapshot = await snapshotExistingFile(abs);
|
|
72
|
+
if (!snapshot) {
|
|
73
|
+
snapshots.push({ path: abs, mtimeMs: Number.NaN, ctimeMs: Number.NaN, size: Number.NaN, ino: Number.NaN });
|
|
74
|
+
} else {
|
|
75
|
+
snapshots.push(snapshot);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return snapshots;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sha256(buf: Buffer): string {
|
|
83
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function hashFile(snapshot: FileSnapshot): Promise<string> {
|
|
87
|
+
const key = `${snapshot.path}:${snapshot.mtimeMs}:${snapshot.ctimeMs}:${snapshot.size}:${snapshot.ino}`;
|
|
88
|
+
const cached = hashCache.get(key);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
const digest = sha256(await fs.readFile(snapshot.path));
|
|
91
|
+
if (hashCache.size >= HASH_CACHE_MAX_ENTRIES) {
|
|
92
|
+
// FIFO eviction is sufficient: the memo only avoids re-reads within a
|
|
93
|
+
// session; correctness never depends on a hit.
|
|
94
|
+
const oldest = hashCache.keys().next().value;
|
|
95
|
+
if (oldest !== undefined) hashCache.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
hashCache.set(key, digest);
|
|
98
|
+
return digest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function verifyEntryHashesCached(entry: GjcPluginRegistryEntry): Promise<SessionQuarantine | null> {
|
|
102
|
+
for (const file of entry.copiedFiles) {
|
|
103
|
+
const abs = path.join(entry.pluginRoot, file.relativePath);
|
|
104
|
+
const snapshot = await snapshotExistingFile(abs);
|
|
105
|
+
if (!snapshot) {
|
|
106
|
+
return {
|
|
107
|
+
plugin: entry.name,
|
|
108
|
+
surfaceId: `plugin:${entry.name}`,
|
|
109
|
+
code: "runtime_mismatch",
|
|
110
|
+
message: `Installed file missing: ${file.relativePath}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if ((await hashFile(snapshot)) !== file.sha256) {
|
|
114
|
+
return {
|
|
115
|
+
plugin: entry.name,
|
|
116
|
+
surfaceId: `plugin:${entry.name}`,
|
|
117
|
+
code: "runtime_mismatch",
|
|
118
|
+
message: `Installed file hash drift: ${file.relativePath}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loadValidatedPluginRegistry(cwd: string): Promise<ValidatedPluginRegistry> {
|
|
126
|
+
const registryFiles = await snapshotRegistryFiles(cwd);
|
|
127
|
+
const registryKey = snapshotsKey(registryFiles);
|
|
128
|
+
const cached = validatedRegistryCache.get(cwd);
|
|
129
|
+
if (cached && cached.registryKey === registryKey) {
|
|
130
|
+
const pluginFiles = await snapshotPluginFiles(cached.effective);
|
|
131
|
+
const pluginKey = snapshotsKey(pluginFiles);
|
|
132
|
+
if (cached.pluginKey === pluginKey) return cached;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const effective = await loadEffectiveGjcPluginRegistry(cwd);
|
|
136
|
+
const currentRegistryFiles = await snapshotRegistryFiles(cwd);
|
|
137
|
+
const preQuarantine: SessionQuarantine[] = [];
|
|
138
|
+
for (const entry of effective) {
|
|
139
|
+
if (!entry.enabled) continue;
|
|
140
|
+
const drift = await verifyEntryHashesCached(entry);
|
|
141
|
+
if (drift) preQuarantine.push(drift);
|
|
142
|
+
}
|
|
143
|
+
const validation = validateSessionBundles(effective, {}, preQuarantine);
|
|
144
|
+
const pluginFiles = await snapshotPluginFiles(effective);
|
|
145
|
+
const next: CachedValidatedPluginRegistry = {
|
|
146
|
+
effective,
|
|
147
|
+
active: validation.active,
|
|
148
|
+
quarantine: validation.quarantine,
|
|
149
|
+
validation,
|
|
150
|
+
registryFiles: currentRegistryFiles,
|
|
151
|
+
pluginFiles,
|
|
152
|
+
registryKey: snapshotsKey(currentRegistryFiles),
|
|
153
|
+
pluginKey: snapshotsKey(pluginFiles),
|
|
154
|
+
};
|
|
155
|
+
validatedRegistryCache.set(cwd, next);
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
12
159
|
/**
|
|
13
160
|
* Load the always-on plugin tool surfaces for the effective registry at `cwd`.
|
|
14
161
|
*
|
|
@@ -26,22 +173,15 @@ export async function loadAlwaysOnPluginTools(input: {
|
|
|
26
173
|
cwd: string;
|
|
27
174
|
reservedToolNames: string[];
|
|
28
175
|
}): Promise<AlwaysOnPluginTools> {
|
|
29
|
-
const
|
|
176
|
+
const validated = await loadValidatedPluginRegistry(input.cwd);
|
|
177
|
+
const { effective } = validated;
|
|
30
178
|
if (effective.length === 0) return { tools: [], quarantine: [] };
|
|
31
179
|
|
|
32
|
-
// Hash-drift quarantine before importing any plugin code.
|
|
33
|
-
const preQuarantine: SessionQuarantine[] = [];
|
|
34
|
-
for (const entry of effective) {
|
|
35
|
-
if (!entry.enabled) continue;
|
|
36
|
-
const drift = await verifyEntryHashes(entry);
|
|
37
|
-
if (drift) preQuarantine.push(drift);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
180
|
const reserved = new Set(input.reservedToolNames);
|
|
41
181
|
const { active, quarantine } = validateSessionBundles(
|
|
42
182
|
effective,
|
|
43
183
|
{ toolNames: input.reservedToolNames },
|
|
44
|
-
|
|
184
|
+
validated.quarantine,
|
|
45
185
|
);
|
|
46
186
|
|
|
47
187
|
// Map declared (path -> name) for every active always-on tool surface.
|
|
@@ -109,15 +249,8 @@ export async function loadAlwaysOnPluginTools(input: {
|
|
|
109
249
|
* plugins are installed/enabled. Safe to call unconditionally at session start.
|
|
110
250
|
*/
|
|
111
251
|
export async function renderAlwaysOnSystemAppendices(input: { cwd: string }): Promise<string> {
|
|
112
|
-
const effective = await
|
|
252
|
+
const { effective, active } = await loadValidatedPluginRegistry(input.cwd);
|
|
113
253
|
if (effective.length === 0) return "";
|
|
114
|
-
const preQuarantine: SessionQuarantine[] = [];
|
|
115
|
-
for (const entry of effective) {
|
|
116
|
-
if (!entry.enabled) continue;
|
|
117
|
-
const drift = await verifyEntryHashes(entry);
|
|
118
|
-
if (drift) preQuarantine.push(drift);
|
|
119
|
-
}
|
|
120
|
-
const { active } = validateSessionBundles(effective, {}, preQuarantine);
|
|
121
254
|
const { renderPluginAppendices } = await import("./prompt-appendix");
|
|
122
255
|
return (await renderPluginAppendices(active)).system;
|
|
123
256
|
}
|
|
@@ -131,15 +264,8 @@ export async function renderAgentPromptAdditions(input: {
|
|
|
131
264
|
cwd: string;
|
|
132
265
|
agentName: string;
|
|
133
266
|
}): Promise<{ appendix: string; advertisement: string }> {
|
|
134
|
-
const effective = await
|
|
267
|
+
const { effective, active } = await loadValidatedPluginRegistry(input.cwd);
|
|
135
268
|
if (effective.length === 0) return { appendix: "", advertisement: "" };
|
|
136
|
-
const preQuarantine: SessionQuarantine[] = [];
|
|
137
|
-
for (const entry of effective) {
|
|
138
|
-
if (!entry.enabled) continue;
|
|
139
|
-
const drift = await verifyEntryHashes(entry);
|
|
140
|
-
if (drift) preQuarantine.push(drift);
|
|
141
|
-
}
|
|
142
|
-
const { active } = validateSessionBundles(effective, {}, preQuarantine);
|
|
143
269
|
const { renderPluginAppendices } = await import("./prompt-appendix");
|
|
144
270
|
const { buildAgentSubskillAdvertisement } = await import("./injection");
|
|
145
271
|
const rendered = await renderPluginAppendices(active);
|
|
@@ -158,15 +284,8 @@ export async function renderSkillAdvertisement(input: {
|
|
|
158
284
|
skillName: string;
|
|
159
285
|
phase?: string;
|
|
160
286
|
}): Promise<string> {
|
|
161
|
-
const effective = await
|
|
287
|
+
const { effective, active } = await loadValidatedPluginRegistry(input.cwd);
|
|
162
288
|
if (effective.length === 0) return "";
|
|
163
|
-
const preQuarantine: SessionQuarantine[] = [];
|
|
164
|
-
for (const entry of effective) {
|
|
165
|
-
if (!entry.enabled) continue;
|
|
166
|
-
const drift = await verifyEntryHashes(entry);
|
|
167
|
-
if (drift) preQuarantine.push(drift);
|
|
168
|
-
}
|
|
169
|
-
const { active } = validateSessionBundles(effective, {}, preQuarantine);
|
|
170
289
|
const { buildSubskillAdvertisement } = await import("./injection");
|
|
171
290
|
return buildSubskillAdvertisement(active, input.skillName, input.phase);
|
|
172
291
|
}
|
|
@@ -181,15 +300,8 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
|
|
|
181
300
|
configs: Record<string, any>;
|
|
182
301
|
quarantine: SessionQuarantine[];
|
|
183
302
|
}> {
|
|
184
|
-
const effective = await
|
|
303
|
+
const { effective, active, quarantine } = await loadValidatedPluginRegistry(input.cwd);
|
|
185
304
|
if (effective.length === 0) return { configs: {}, quarantine: [] };
|
|
186
|
-
const preQuarantine: SessionQuarantine[] = [];
|
|
187
|
-
for (const entry of effective) {
|
|
188
|
-
if (!entry.enabled) continue;
|
|
189
|
-
const drift = await verifyEntryHashes(entry);
|
|
190
|
-
if (drift) preQuarantine.push(drift);
|
|
191
|
-
}
|
|
192
|
-
const { active, quarantine } = validateSessionBundles(effective, {}, preQuarantine);
|
|
193
305
|
const { assertMcpInstallPolicy, assertDnsResolvesPublic, assertUrlAllowed } = await import("./mcp-policy");
|
|
194
306
|
const nodePath = await import("node:path");
|
|
195
307
|
|
|
@@ -163,13 +163,39 @@ const HOISTED_STATE_FIELDS = [
|
|
|
163
163
|
"threshold_source",
|
|
164
164
|
] as const;
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Envelope-reserved keys that are never legitimate interview `state` fields.
|
|
168
|
+
*
|
|
169
|
+
* A malformed write that wraps a whole envelope under `state`
|
|
170
|
+
* (`gjc state deep-interview write --input '{"state": <envelope>}'`) leaks these
|
|
171
|
+
* into the nested state. Because normalization otherwise preserves unknown
|
|
172
|
+
* nested fields, they would accrete a recursive `state.state` chain (plus stale
|
|
173
|
+
* `receipt`/`skill`/`version`/... duplicates) that no later merge or write ever
|
|
174
|
+
* removes, permanently corrupting the shape. They are stripped from `state` on
|
|
175
|
+
* normalize so the canonical envelope self-heals on the next write.
|
|
176
|
+
*/
|
|
177
|
+
const ENVELOPE_RESERVED_STATE_KEYS = [
|
|
178
|
+
"state",
|
|
179
|
+
"receipt",
|
|
180
|
+
"skill",
|
|
181
|
+
"version",
|
|
182
|
+
"updated_at",
|
|
183
|
+
"active",
|
|
184
|
+
"current_phase",
|
|
185
|
+
"state_revision",
|
|
186
|
+
"session_id",
|
|
187
|
+
] as const;
|
|
188
|
+
|
|
166
189
|
/**
|
|
167
190
|
* Canonicalize a deep-interview envelope: interview data nested under `state`,
|
|
168
191
|
* legacy flattened fields hoisted in losslessly, transcript duplicates removed
|
|
169
192
|
* from the top level, and `rounds`/`established_facts` guaranteed to be arrays.
|
|
170
193
|
*
|
|
171
|
-
* Idempotent: a canonical envelope is returned unchanged in shape.
|
|
172
|
-
* unknown envelope
|
|
194
|
+
* Idempotent: a canonical envelope is returned unchanged in shape. Preserves all
|
|
195
|
+
* unknown envelope and nested fields except the envelope-reserved keys that leak
|
|
196
|
+
* into `state` (see `ENVELOPE_RESERVED_STATE_KEYS`), which are stripped so a
|
|
197
|
+
* malformed envelope-in-state write cannot permanently nest state. Never mutates
|
|
198
|
+
* the input.
|
|
173
199
|
*/
|
|
174
200
|
export function normalizeDeepInterviewEnvelope(value: unknown): DeepInterviewStateEnvelope {
|
|
175
201
|
const envelope: DeepInterviewStateEnvelope = isPlainObject(value) ? { ...value } : {};
|
|
@@ -182,6 +208,9 @@ export function normalizeDeepInterviewEnvelope(value: unknown): DeepInterviewSta
|
|
|
182
208
|
for (const field of HOISTED_STATE_FIELDS) {
|
|
183
209
|
if (inner[field] === undefined && envelope[field] !== undefined) inner[field] = envelope[field];
|
|
184
210
|
}
|
|
211
|
+
for (const field of ENVELOPE_RESERVED_STATE_KEYS) {
|
|
212
|
+
if (field in inner) delete inner[field];
|
|
213
|
+
}
|
|
185
214
|
|
|
186
215
|
if (!Array.isArray(inner.rounds)) inner.rounds = [];
|
|
187
216
|
if (!Array.isArray(inner.established_facts)) inner.established_facts = [];
|
|
@@ -8,11 +8,27 @@ import { sessionRuntimeDir } from "./session-layout";
|
|
|
8
8
|
export const GJC_COORDINATOR_SESSION_STATE_FILE_ENV = "GJC_COORDINATOR_SESSION_STATE_FILE";
|
|
9
9
|
export const GJC_COORDINATOR_SESSION_ID_ENV = "GJC_COORDINATOR_SESSION_ID";
|
|
10
10
|
export const GJC_COORDINATOR_SESSION_BRANCH_ENV = "GJC_COORDINATOR_SESSION_BRANCH";
|
|
11
|
+
const GJC_SESSION_PROMPT_ACCEPTED_JSON_ENV = "GJC_SESSION_PROMPT_ACCEPTED_JSON";
|
|
12
|
+
const GJC_SESSION_WORKTREE_BASELINE_DIRTY_ENV = "GJC_SESSION_WORKTREE_BASELINE_DIRTY";
|
|
11
13
|
|
|
12
14
|
export type RuntimeState = "ready_for_input" | "running" | "needs_user_input" | "completed" | "errored";
|
|
13
15
|
|
|
14
16
|
type FinalResponseSource = "agent_end" | "launch_error";
|
|
15
17
|
const MAX_PUBLIC_ERROR_MESSAGE_LENGTH = 2000;
|
|
18
|
+
const HEARTBEAT_MS = 1000;
|
|
19
|
+
|
|
20
|
+
type LastPayloadCacheEntry = { mtimeMs: number; size: number; payload: Record<string, unknown> };
|
|
21
|
+
const lastPayloadByStateFile = new Map<string, LastPayloadCacheEntry>();
|
|
22
|
+
|
|
23
|
+
/** Test-only counters for runtime sidecar hot-path assertions. */
|
|
24
|
+
export const __sessionStateSidecarPerfCounters = {
|
|
25
|
+
persistFromEventCalls: 0,
|
|
26
|
+
reset(): void {
|
|
27
|
+
this.persistFromEventCalls = 0;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const lastWrittenPayloadByStateFile = new Map<string, Record<string, unknown>>();
|
|
16
32
|
|
|
17
33
|
interface RuntimeStateEvent {
|
|
18
34
|
type: string;
|
|
@@ -32,6 +48,7 @@ interface RuntimeStateSidecarPayload {
|
|
|
32
48
|
state?: unknown;
|
|
33
49
|
ready_for_input?: unknown;
|
|
34
50
|
cwd?: unknown;
|
|
51
|
+
workdir?: unknown;
|
|
35
52
|
session_file?: unknown;
|
|
36
53
|
final_response?: { source?: unknown };
|
|
37
54
|
}
|
|
@@ -53,6 +70,11 @@ function sameResolvedPath(left: string, right: string): boolean {
|
|
|
53
70
|
return path.resolve(left) === path.resolve(right);
|
|
54
71
|
}
|
|
55
72
|
|
|
73
|
+
function isCoordinatorOwnedStateFile(stateFile: string): boolean {
|
|
74
|
+
const coordinatorStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
|
|
75
|
+
return !!coordinatorStateFile && sameResolvedPath(coordinatorStateFile, stateFile);
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
export async function readTerminalRuntimeStateMarker(input: {
|
|
57
79
|
stateFile?: string | null;
|
|
58
80
|
sessionId?: string | null;
|
|
@@ -125,7 +147,7 @@ function finalResponseForEvent(event: RuntimeStateEvent): {
|
|
|
125
147
|
};
|
|
126
148
|
}
|
|
127
149
|
|
|
128
|
-
function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
|
|
150
|
+
export function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
|
|
129
151
|
if (event.type === "agent_start" || event.type === "turn_start") return "running";
|
|
130
152
|
if (event.type === "agent_end") {
|
|
131
153
|
const assistant = lastAssistant(event.messages);
|
|
@@ -135,6 +157,10 @@ function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
|
|
|
135
157
|
return null;
|
|
136
158
|
}
|
|
137
159
|
|
|
160
|
+
export function eventAffectsCoordinatorRuntimeState(event: RuntimeStateEvent): boolean {
|
|
161
|
+
return stateForEvent(event) !== null;
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
function readPreviousPayload(stateFile: string): Record<string, unknown> {
|
|
139
165
|
try {
|
|
140
166
|
return JSON.parse(fsSync.readFileSync(stateFile, "utf8")) as Record<string, unknown>;
|
|
@@ -143,10 +169,84 @@ function readPreviousPayload(stateFile: string): Record<string, unknown> {
|
|
|
143
169
|
}
|
|
144
170
|
}
|
|
145
171
|
|
|
146
|
-
function
|
|
172
|
+
async function readPreviousPayloadForEvent(stateFile: string): Promise<Record<string, unknown>> {
|
|
173
|
+
if (!isCoordinatorOwnedStateFile(stateFile)) {
|
|
174
|
+
const cachedWritten = lastWrittenPayloadByStateFile.get(stateFile);
|
|
175
|
+
if (cachedWritten) return cachedWritten;
|
|
176
|
+
}
|
|
177
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
|
178
|
+
try {
|
|
179
|
+
stat = await fs.stat(stateFile);
|
|
180
|
+
} catch {
|
|
181
|
+
lastPayloadByStateFile.delete(stateFile);
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
const cached = lastPayloadByStateFile.get(stateFile);
|
|
185
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.payload;
|
|
186
|
+
try {
|
|
187
|
+
const payload = JSON.parse(await Bun.file(stateFile).text()) as Record<string, unknown>;
|
|
188
|
+
lastPayloadByStateFile.set(stateFile, { mtimeMs: stat.mtimeMs, size: stat.size, payload });
|
|
189
|
+
return payload;
|
|
190
|
+
} catch {
|
|
191
|
+
lastPayloadByStateFile.delete(stateFile);
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function withoutUpdatedAt(payload: Record<string, unknown>): Record<string, unknown> {
|
|
197
|
+
const { updated_at: _updatedAt, ...rest } = payload;
|
|
198
|
+
return rest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function shouldSkipRuntimeStateWrite(
|
|
202
|
+
previous: Record<string, unknown>,
|
|
203
|
+
payload: Record<string, unknown>,
|
|
204
|
+
nowMs: number,
|
|
205
|
+
): boolean {
|
|
206
|
+
if (payload.state === "completed" || payload.state === "errored") return false;
|
|
207
|
+
if (previous.state !== payload.state) return false;
|
|
208
|
+
if (previous.state !== "running" || payload.state !== "running") return false;
|
|
209
|
+
if (JSON.stringify(withoutUpdatedAt(previous)) !== JSON.stringify(withoutUpdatedAt(payload))) return false;
|
|
210
|
+
const previousUpdatedAt = typeof previous.updated_at === "string" ? Date.parse(previous.updated_at) : NaN;
|
|
211
|
+
if (!Number.isFinite(previousUpdatedAt)) return false;
|
|
212
|
+
return nowMs - previousUpdatedAt < HEARTBEAT_MS;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rememberWrittenPayload(stateFile: string, payload: Record<string, unknown>): void {
|
|
216
|
+
lastWrittenPayloadByStateFile.set(stateFile, payload);
|
|
217
|
+
try {
|
|
218
|
+
const stat = fsSync.statSync(stateFile);
|
|
219
|
+
lastPayloadByStateFile.set(stateFile, { mtimeMs: stat.mtimeMs, size: stat.size, payload });
|
|
220
|
+
} catch {
|
|
221
|
+
lastPayloadByStateFile.delete(stateFile);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function shouldPreserveTerminalPayload(
|
|
225
|
+
previous: RuntimeStateSidecarPayload,
|
|
226
|
+
input: { sessionId: string; cwd: string; sessionFile?: string | null },
|
|
227
|
+
): boolean {
|
|
147
228
|
if (previous.state !== "completed" && previous.state !== "errored") return false;
|
|
148
229
|
const source = previous.final_response?.source;
|
|
149
|
-
|
|
230
|
+
if (source !== "agent_end" && source !== "launch_error") return false;
|
|
231
|
+
if (typeof previous.session_id === "string" && previous.session_id !== input.sessionId) return false;
|
|
232
|
+
if (typeof previous.cwd === "string" && !sameResolvedPath(previous.cwd, input.cwd)) return false;
|
|
233
|
+
if (typeof previous.workdir === "string" && !sameResolvedPath(previous.workdir, input.cwd)) return false;
|
|
234
|
+
if (
|
|
235
|
+
input.sessionFile &&
|
|
236
|
+
typeof previous.session_file === "string" &&
|
|
237
|
+
!sameResolvedPath(previous.session_file, input.sessionFile)
|
|
238
|
+
) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function cachedTerminalPayload(
|
|
245
|
+
stateFile: string,
|
|
246
|
+
input: { sessionId: string; cwd: string; sessionFile?: string | null },
|
|
247
|
+
): RuntimeStateSidecarPayload | null {
|
|
248
|
+
const cached = lastWrittenPayloadByStateFile.get(stateFile) as RuntimeStateSidecarPayload | undefined;
|
|
249
|
+
return cached && shouldPreserveTerminalPayload(cached, input) ? cached : null;
|
|
150
250
|
}
|
|
151
251
|
|
|
152
252
|
function runtimeStateFileForContext(context: RuntimeStateContext): string | null {
|
|
@@ -187,6 +287,56 @@ function basePayload(input: {
|
|
|
187
287
|
session_file: input.context.sessionFile ?? null,
|
|
188
288
|
};
|
|
189
289
|
}
|
|
290
|
+
function booleanFromUnknown(value: unknown): boolean | null {
|
|
291
|
+
if (typeof value === "boolean") return value;
|
|
292
|
+
if (typeof value === "string") {
|
|
293
|
+
const normalized = value.trim().toLowerCase();
|
|
294
|
+
if (normalized === "true") return true;
|
|
295
|
+
if (normalized === "false") return false;
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function promptAcceptedFromEnv(): boolean {
|
|
301
|
+
const promptAcceptedJson = process.env[GJC_SESSION_PROMPT_ACCEPTED_JSON_ENV]?.trim();
|
|
302
|
+
if (!promptAcceptedJson) return false;
|
|
303
|
+
try {
|
|
304
|
+
return fsSync.statSync(promptAcceptedJson).size > 0;
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function readJsonFileSync(file: string): Record<string, unknown> | null {
|
|
311
|
+
try {
|
|
312
|
+
return JSON.parse(fsSync.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function worktreeBaselineDirtyFromEnvOrMarker(): boolean | null {
|
|
319
|
+
const promptAcceptedJson = process.env[GJC_SESSION_PROMPT_ACCEPTED_JSON_ENV]?.trim();
|
|
320
|
+
if (promptAcceptedJson) {
|
|
321
|
+
const promptAccepted = readJsonFileSync(promptAcceptedJson);
|
|
322
|
+
const promptBaseline = booleanFromUnknown(promptAccepted?.worktreeBaselineDirty);
|
|
323
|
+
if (promptBaseline !== null) return promptBaseline;
|
|
324
|
+
}
|
|
325
|
+
const envValue = booleanFromUnknown(process.env[GJC_SESSION_WORKTREE_BASELINE_DIRTY_ENV]);
|
|
326
|
+
if (envValue !== null) return envValue;
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function observedRecoverableWorktreeChanges(cwd: string): boolean {
|
|
331
|
+
if (!cwd.trim()) return false;
|
|
332
|
+
try {
|
|
333
|
+
const proc = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
334
|
+
return proc.exitCode === 0 && proc.stdout.byteLength > 0;
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
190
340
|
function publicSafeErrorMessage(message: string): string {
|
|
191
341
|
const normalized = message.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim();
|
|
192
342
|
if (normalized.length <= MAX_PUBLIC_ERROR_MESSAGE_LENGTH) return normalized;
|
|
@@ -204,6 +354,7 @@ function numericProcessExitCode(defaultCode: number | null): number | null {
|
|
|
204
354
|
function postmortemExitDetails(
|
|
205
355
|
reason: postmortem.Reason,
|
|
206
356
|
previous: RuntimeStateSidecarPayload,
|
|
357
|
+
cwd: string,
|
|
207
358
|
): {
|
|
208
359
|
state: RuntimeState;
|
|
209
360
|
reason: string;
|
|
@@ -212,7 +363,15 @@ function postmortemExitDetails(
|
|
|
212
363
|
signal: string | null;
|
|
213
364
|
error?: { code: string; message: string; recoverable: true };
|
|
214
365
|
recovery?: { action: string; reason: string };
|
|
366
|
+
promptAccepted: boolean;
|
|
367
|
+
observedRecoverableWorktreeChanges: boolean;
|
|
368
|
+
worktreeBaselineDirty: boolean | null;
|
|
369
|
+
worktreeChangedSinceBaseline: boolean;
|
|
215
370
|
} {
|
|
371
|
+
const promptAccepted = promptAcceptedFromEnv();
|
|
372
|
+
const observedChanges = observedRecoverableWorktreeChanges(typeof previous.cwd === "string" ? previous.cwd : cwd);
|
|
373
|
+
const worktreeBaselineDirty = worktreeBaselineDirtyFromEnvOrMarker();
|
|
374
|
+
const worktreeChangedSinceBaseline = worktreeBaselineDirty === false && observedChanges;
|
|
216
375
|
if (reason === postmortem.Reason.EXIT || reason === postmortem.Reason.MANUAL) {
|
|
217
376
|
const exitCode = numericProcessExitCode(0) ?? 0;
|
|
218
377
|
const exitedBeforeTerminalState =
|
|
@@ -223,16 +382,25 @@ function postmortemExitDetails(
|
|
|
223
382
|
: reason === postmortem.Reason.EXIT
|
|
224
383
|
? "process_exit"
|
|
225
384
|
: "manual_cleanup";
|
|
385
|
+
let classifiedReason = exitReason;
|
|
386
|
+
if (exitedBeforeTerminalState) {
|
|
387
|
+
if (!promptAccepted) classifiedReason = "process_exit_before_prompt_acceptance";
|
|
388
|
+
else if (worktreeChangedSinceBaseline)
|
|
389
|
+
classifiedReason = "accepted_prompt_observed_recoverable_worktree_changes";
|
|
390
|
+
else if (observedChanges)
|
|
391
|
+
classifiedReason = "accepted_prompt_dirty_worktree_observed_without_new_change_proof";
|
|
392
|
+
else classifiedReason = "accepted_prompt_no_useful_output";
|
|
393
|
+
}
|
|
226
394
|
return {
|
|
227
395
|
state,
|
|
228
|
-
reason:
|
|
396
|
+
reason: classifiedReason,
|
|
229
397
|
exitKind: reason,
|
|
230
398
|
exitCode,
|
|
231
399
|
signal: null,
|
|
232
400
|
...(state === "errored"
|
|
233
401
|
? {
|
|
234
402
|
error: {
|
|
235
|
-
code:
|
|
403
|
+
code: classifiedReason,
|
|
236
404
|
message: publicSafeErrorMessage(
|
|
237
405
|
exitedBeforeTerminalState
|
|
238
406
|
? "GJC process exited before emitting terminal agent state"
|
|
@@ -248,6 +416,10 @@ function postmortemExitDetails(
|
|
|
248
416
|
},
|
|
249
417
|
}
|
|
250
418
|
: {}),
|
|
419
|
+
promptAccepted,
|
|
420
|
+
observedRecoverableWorktreeChanges: observedChanges,
|
|
421
|
+
worktreeBaselineDirty,
|
|
422
|
+
worktreeChangedSinceBaseline,
|
|
251
423
|
};
|
|
252
424
|
}
|
|
253
425
|
const signalByReason: Partial<Record<postmortem.Reason, string>> = {
|
|
@@ -263,29 +435,37 @@ function postmortemExitDetails(
|
|
|
263
435
|
signal: signalByReason[reason] ?? null,
|
|
264
436
|
error: { code: reason, message: errorMessageForPostmortem(reason), recoverable: true },
|
|
265
437
|
recovery: { action: "recover_or_resume_session", reason: "process cleanup ran before terminal agent state" },
|
|
438
|
+
promptAccepted,
|
|
439
|
+
observedRecoverableWorktreeChanges: observedChanges,
|
|
440
|
+
worktreeBaselineDirty,
|
|
441
|
+
worktreeChangedSinceBaseline,
|
|
266
442
|
};
|
|
267
443
|
}
|
|
268
444
|
|
|
269
445
|
function writeStateFileSync(stateFile: string, payload: Record<string, unknown>): void {
|
|
270
446
|
fsSync.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
271
|
-
fsSync.writeFileSync(stateFile, `${JSON.stringify(payload
|
|
447
|
+
fsSync.writeFileSync(stateFile, `${JSON.stringify(payload)}\n`);
|
|
448
|
+
rememberWrittenPayload(stateFile, payload);
|
|
272
449
|
}
|
|
273
450
|
|
|
274
451
|
async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
|
|
275
452
|
await fs.mkdir(path.dirname(stateFile), { recursive: true });
|
|
276
|
-
await Bun.write(stateFile, `${JSON.stringify(payload
|
|
453
|
+
await Bun.write(stateFile, `${JSON.stringify(payload)}\n`);
|
|
454
|
+
rememberWrittenPayload(stateFile, payload);
|
|
277
455
|
}
|
|
278
456
|
|
|
279
457
|
export async function persistCoordinatorRuntimeStateFromEvent(
|
|
280
458
|
event: RuntimeStateEvent,
|
|
281
459
|
context: RuntimeStateContext,
|
|
282
460
|
): Promise<void> {
|
|
461
|
+
__sessionStateSidecarPerfCounters.persistFromEventCalls += 1;
|
|
283
462
|
const stateFile = runtimeStateFileForContext(context);
|
|
284
463
|
if (!stateFile) return;
|
|
285
464
|
const state = stateForEvent(event);
|
|
286
465
|
if (!state) return;
|
|
287
|
-
const
|
|
288
|
-
const
|
|
466
|
+
const nowMs = Date.now();
|
|
467
|
+
const now = new Date(nowMs).toISOString();
|
|
468
|
+
const previous = await readPreviousPayloadForEvent(stateFile);
|
|
289
469
|
const finalResponse = finalResponseForEvent(event);
|
|
290
470
|
const sessionId = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim()
|
|
291
471
|
? process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || context.sessionId
|
|
@@ -313,7 +493,9 @@ export async function persistCoordinatorRuntimeStateFromEvent(
|
|
|
313
493
|
}
|
|
314
494
|
: {}),
|
|
315
495
|
};
|
|
496
|
+
if (state === "completed" || state === "errored") rememberWrittenPayload(stateFile, payload);
|
|
316
497
|
try {
|
|
498
|
+
if (shouldSkipRuntimeStateWrite(previous, payload, nowMs)) return;
|
|
317
499
|
await writeStateFile(stateFile, payload);
|
|
318
500
|
} catch (error) {
|
|
319
501
|
logger.warn("Failed to persist coordinator runtime state", { error: String(error), stateFile });
|
|
@@ -326,13 +508,22 @@ export function persistCoordinatorRuntimeStateFromPostmortem(
|
|
|
326
508
|
): void {
|
|
327
509
|
const stateFile = runtimeStateFileForContext(context);
|
|
328
510
|
if (!stateFile) return;
|
|
329
|
-
const previous = readPreviousPayload(stateFile);
|
|
330
|
-
if (shouldPreserveTerminalPayload(previous as RuntimeStateSidecarPayload)) return;
|
|
331
|
-
const now = new Date().toISOString();
|
|
332
|
-
const details = postmortemExitDetails(reason, previous as RuntimeStateSidecarPayload);
|
|
333
511
|
const sessionId = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim()
|
|
334
512
|
? process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || context.sessionId
|
|
335
513
|
: context.sessionId;
|
|
514
|
+
const preserveInput = { sessionId, cwd: context.cwd, sessionFile: context.sessionFile };
|
|
515
|
+
const cachedTerminal = cachedTerminalPayload(stateFile, preserveInput);
|
|
516
|
+
const previous: Record<string, unknown> = cachedTerminal
|
|
517
|
+
? (cachedTerminal as unknown as Record<string, unknown>)
|
|
518
|
+
: readPreviousPayload(stateFile);
|
|
519
|
+
if (cachedTerminal || shouldPreserveTerminalPayload(previous as RuntimeStateSidecarPayload, preserveInput)) return;
|
|
520
|
+
const previousForDetails: RuntimeStateSidecarPayload =
|
|
521
|
+
(previous as RuntimeStateSidecarPayload).state === "completed" ||
|
|
522
|
+
(previous as RuntimeStateSidecarPayload).state === "errored"
|
|
523
|
+
? { ...(previous as RuntimeStateSidecarPayload), state: "running" }
|
|
524
|
+
: (previous as RuntimeStateSidecarPayload);
|
|
525
|
+
const now = new Date().toISOString();
|
|
526
|
+
const details = postmortemExitDetails(reason, previousForDetails, context.cwd);
|
|
336
527
|
const payload = {
|
|
337
528
|
...basePayload({
|
|
338
529
|
context,
|
|
@@ -352,6 +543,10 @@ export function persistCoordinatorRuntimeStateFromPostmortem(
|
|
|
352
543
|
...(details.error ? { error: details.error } : {}),
|
|
353
544
|
...(details.recovery ? { recovery: details.recovery } : {}),
|
|
354
545
|
previous_runtime_state: typeof previous.state === "string" ? previous.state : null,
|
|
546
|
+
prompt_accepted: details.promptAccepted,
|
|
547
|
+
observed_recoverable_worktree_changes: details.observedRecoverableWorktreeChanges,
|
|
548
|
+
worktree_baseline_dirty: details.worktreeBaselineDirty,
|
|
549
|
+
worktree_changed_since_baseline: details.worktreeChangedSinceBaseline,
|
|
355
550
|
};
|
|
356
551
|
try {
|
|
357
552
|
writeStateFileSync(stateFile, payload);
|