@gajae-code/coding-agent 0.8.2 → 0.9.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 (214) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/fixture-report.d.ts +20 -0
  5. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  6. package/dist/types/cli/nofile-limit.d.ts +11 -0
  7. package/dist/types/cli/update-cli.d.ts +22 -0
  8. package/dist/types/config/settings-schema.d.ts +119 -1
  9. package/dist/types/discovery/index.d.ts +1 -0
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/export/ttsr.d.ts +14 -3
  12. package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  15. package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
  16. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  17. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
  19. package/dist/types/goals/runtime.d.ts +1 -0
  20. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  22. package/dist/types/modes/components/status-line.d.ts +9 -0
  23. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  29. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  30. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  31. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  32. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  33. package/dist/types/modes/types.d.ts +3 -0
  34. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  36. package/dist/types/notifications/config-commands.d.ts +9 -0
  37. package/dist/types/notifications/config.d.ts +22 -0
  38. package/dist/types/notifications/index.d.ts +2 -0
  39. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  40. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  41. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  42. package/dist/types/notifications/rich-draft.d.ts +68 -0
  43. package/dist/types/notifications/rich-render.d.ts +90 -0
  44. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  45. package/dist/types/notifications/telegram-daemon.d.ts +30 -0
  46. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  47. package/dist/types/notifications/threaded-render.d.ts +14 -0
  48. package/dist/types/sdk.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +43 -1
  50. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  51. package/dist/types/session/messages.d.ts +4 -0
  52. package/dist/types/session/session-manager.d.ts +17 -1
  53. package/dist/types/skill-state/active-state.d.ts +18 -1
  54. package/dist/types/system-prompt.d.ts +18 -2
  55. package/dist/types/task/executor.d.ts +1 -0
  56. package/dist/types/task/fork-context-budget.d.ts +7 -0
  57. package/dist/types/task/token-log.d.ts +30 -0
  58. package/dist/types/task/token-log.test.d.ts +1 -0
  59. package/dist/types/tools/bisect.d.ts +81 -0
  60. package/dist/types/tools/index.d.ts +1 -0
  61. package/dist/types/tools/search.d.ts +2 -2
  62. package/dist/types/utils/file-mentions.d.ts +14 -0
  63. package/dist/types/utils/git.d.ts +32 -0
  64. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  65. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  66. package/package.json +8 -11
  67. package/src/append-only-mode.ts +58 -0
  68. package/src/autoresearch/prompt.md +1 -1
  69. package/src/autoresearch/tools/log-experiment.ts +1 -1
  70. package/src/capability/rule.ts +9 -0
  71. package/src/cli/config-cli.ts +27 -3
  72. package/src/cli/fixture-report.test.ts +230 -0
  73. package/src/cli/fixture-report.ts +171 -0
  74. package/src/cli/nofile-limit.ts +48 -0
  75. package/src/cli/notify-cli.ts +11 -9
  76. package/src/cli/update-cli.ts +85 -9
  77. package/src/cli.ts +26 -0
  78. package/src/commands/contribution-prep.ts +1 -1
  79. package/src/config/keybindings.ts +1 -1
  80. package/src/config/settings-schema.ts +76 -1
  81. package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
  82. package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
  83. package/src/defaults/gjc/skills/team/SKILL.md +16 -13
  84. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  85. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  86. package/src/defaults/gjc-defaults.ts +9 -0
  87. package/src/discovery/helpers.ts +10 -0
  88. package/src/discovery/index.ts +1 -0
  89. package/src/export/html/template.generated.ts +1 -1
  90. package/src/export/html/template.js +60 -2
  91. package/src/export/ttsr.ts +96 -18
  92. package/src/extensibility/extensions/runner.ts +181 -219
  93. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  94. package/src/gjc-runtime/deep-interview-state.ts +31 -2
  95. package/src/gjc-runtime/launch-tmux.ts +6 -0
  96. package/src/gjc-runtime/session-state-sidecar.ts +208 -13
  97. package/src/gjc-runtime/state-writer.ts +15 -0
  98. package/src/gjc-runtime/team-runtime.ts +175 -19
  99. package/src/gjc-runtime/tmux-common.ts +1 -0
  100. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  101. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  102. package/src/gjc-runtime/ultragoal-runtime.ts +1602 -143
  103. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  104. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  105. package/src/goals/runtime.ts +10 -2
  106. package/src/hindsight/backend.ts +1 -3
  107. package/src/hooks/native-skill-hook.ts +95 -4
  108. package/src/internal-urls/docs-index.generated.ts +18 -9
  109. package/src/lsp/client.ts +1 -0
  110. package/src/main.ts +43 -2
  111. package/src/memories/index.ts +12 -1
  112. package/src/modes/components/assistant-message.ts +241 -82
  113. package/src/modes/components/custom-editor.ts +1 -0
  114. package/src/modes/components/footer.ts +7 -2
  115. package/src/modes/components/status-line.ts +167 -54
  116. package/src/modes/components/thinking-selector.ts +10 -8
  117. package/src/modes/components/tool-execution.ts +44 -19
  118. package/src/modes/controllers/command-controller.ts +39 -2
  119. package/src/modes/controllers/event-controller.ts +77 -49
  120. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  121. package/src/modes/controllers/input-controller.ts +13 -18
  122. package/src/modes/controllers/selector-controller.ts +41 -0
  123. package/src/modes/interactive-mode.ts +14 -1
  124. package/src/modes/rpc/rpc-mode.ts +32 -12
  125. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  126. package/src/modes/rpc/rpc-types.ts +9 -0
  127. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  128. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  129. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  130. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  131. package/src/modes/types.ts +3 -0
  132. package/src/modes/utils/injected-user-submission.ts +94 -0
  133. package/src/modes/utils/ui-helpers.ts +17 -4
  134. package/src/notifications/config-commands.ts +20 -0
  135. package/src/notifications/config.ts +43 -4
  136. package/src/notifications/index.ts +105 -9
  137. package/src/notifications/lifecycle-commands.ts +37 -16
  138. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  139. package/src/notifications/reply-sent-store.ts +134 -0
  140. package/src/notifications/rich-draft.ts +107 -0
  141. package/src/notifications/rich-render.ts +142 -0
  142. package/src/notifications/telegram-daemon-cli.ts +54 -7
  143. package/src/notifications/telegram-daemon-control.ts +2 -2
  144. package/src/notifications/telegram-daemon.ts +390 -56
  145. package/src/notifications/telegram-reference.ts +17 -0
  146. package/src/notifications/threaded-render.ts +45 -2
  147. package/src/prompts/agents/architect.md +19 -10
  148. package/src/prompts/agents/critic.md +16 -18
  149. package/src/prompts/agents/executor.md +14 -11
  150. package/src/prompts/agents/planner.md +18 -13
  151. package/src/prompts/ci-green-request.md +3 -8
  152. package/src/prompts/goals/goal-continuation.md +0 -5
  153. package/src/prompts/goals/goal-mode-active.md +0 -5
  154. package/src/prompts/system/agent-creation-architect.md +2 -2
  155. package/src/prompts/system/plan-mode-active.md +1 -1
  156. package/src/prompts/system/plan-mode-subagent.md +3 -3
  157. package/src/prompts/system/project-prompt.md +0 -13
  158. package/src/prompts/system/subagent-system-prompt.md +3 -2
  159. package/src/prompts/system/system-prompt.md +26 -19
  160. package/src/prompts/system/volatile-project-context.md +10 -0
  161. package/src/prompts/system/web-search.md +4 -7
  162. package/src/prompts/tools/apply-patch.md +6 -0
  163. package/src/prompts/tools/ast-edit.md +1 -0
  164. package/src/prompts/tools/bash.md +1 -1
  165. package/src/prompts/tools/bisect.md +22 -0
  166. package/src/prompts/tools/browser.md +1 -1
  167. package/src/prompts/tools/cron.md +0 -2
  168. package/src/prompts/tools/eval.md +2 -2
  169. package/src/prompts/tools/find.md +1 -1
  170. package/src/prompts/tools/github.md +6 -6
  171. package/src/prompts/tools/goal.md +1 -3
  172. package/src/prompts/tools/image-gen.md +12 -5
  173. package/src/prompts/tools/irc.md +5 -4
  174. package/src/prompts/tools/job.md +2 -2
  175. package/src/prompts/tools/lsp.md +1 -1
  176. package/src/prompts/tools/monitor.md +2 -2
  177. package/src/prompts/tools/patch.md +2 -2
  178. package/src/prompts/tools/replace.md +2 -21
  179. package/src/prompts/tools/rewind.md +1 -1
  180. package/src/prompts/tools/search-tool-bm25.md +6 -11
  181. package/src/prompts/tools/search.md +1 -1
  182. package/src/prompts/tools/skill.md +3 -2
  183. package/src/prompts/tools/ssh.md +2 -1
  184. package/src/prompts/tools/subagent.md +2 -2
  185. package/src/prompts/tools/task.md +4 -5
  186. package/src/prompts/tools/todo-write.md +1 -1
  187. package/src/prompts/tools/vim.md +1 -0
  188. package/src/prompts/tools/web-search.md +2 -0
  189. package/src/prompts/tools/write.md +8 -2
  190. package/src/sdk.ts +68 -30
  191. package/src/session/agent-session.ts +734 -107
  192. package/src/session/file-mention-pruning.ts +64 -0
  193. package/src/session/messages.ts +4 -0
  194. package/src/session/session-manager.ts +59 -2
  195. package/src/skill-state/active-state.ts +141 -5
  196. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  197. package/src/slash-commands/builtin-registry.ts +143 -1
  198. package/src/system-prompt.ts +57 -16
  199. package/src/task/executor.ts +125 -20
  200. package/src/task/fork-context-advisory.ts +2 -7
  201. package/src/task/fork-context-budget.ts +9 -0
  202. package/src/task/index.ts +4 -3
  203. package/src/task/output-manager.ts +4 -2
  204. package/src/task/token-log.test.ts +188 -0
  205. package/src/task/token-log.ts +179 -0
  206. package/src/tools/bisect.ts +367 -0
  207. package/src/tools/index.ts +3 -0
  208. package/src/tools/output-meta.ts +116 -4
  209. package/src/tools/search.ts +8 -2
  210. package/src/tools/skill.ts +15 -0
  211. package/src/utils/file-mentions.ts +39 -7
  212. package/src/utils/git.ts +73 -0
  213. package/src/utils/pasted-image-path.ts +170 -0
  214. 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, validateSessionBundles, verifyEntryHashes } from "./session-validation";
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 effective = await loadEffectiveGjcPluginRegistry(input.cwd);
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
- preQuarantine,
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 loadEffectiveGjcPluginRegistry(input.cwd);
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 loadEffectiveGjcPluginRegistry(input.cwd);
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 loadEffectiveGjcPluginRegistry(input.cwd);
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 loadEffectiveGjcPluginRegistry(input.cwd);
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. Never deletes
172
- * unknown envelope or nested fields, and never mutates the input.
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 = [];
@@ -13,6 +13,7 @@ import {
13
13
  buildGjcTmuxSessionName,
14
14
  buildGjcTmuxSessionSlug,
15
15
  GJC_DEFAULT_TMUX_SESSION,
16
+ GJC_TMUX_ACTIVE_SESSION_ENV,
16
17
  GJC_TMUX_COMMAND_ENV,
17
18
  GJC_TMUX_MOUSE_ENV,
18
19
  GJC_TMUX_PROFILE_ENV,
@@ -688,6 +689,11 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
688
689
  extraEnv: {
689
690
  [GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
690
691
  [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
692
+ // Carry the GJC-managed session name into the child so `gjc team`
693
+ // can target the correct leader session by name. Under psmux on
694
+ // Windows the inherited TMUX_PANE can resolve to the wrong/default
695
+ // session, which would split/send workers into the wrong session.
696
+ [GJC_TMUX_ACTIVE_SESSION_ENV]: sessionName,
691
697
  },
692
698
  platform,
693
699
  },
@@ -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 shouldPreserveTerminalPayload(previous: RuntimeStateSidecarPayload): boolean {
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
- return source === "agent_end" || source === "launch_error";
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: exitReason,
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: exitReason,
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, null, 2)}\n`);
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, null, 2)}\n`);
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 now = new Date().toISOString();
288
- const previous = readPreviousPayload(stateFile);
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);