@gajae-code/coding-agent 0.11.4 → 0.11.5

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 (146) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +20 -0
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +88 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/task/executor.d.ts +9 -0
  50. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  51. package/dist/types/tools/ask.d.ts +48 -0
  52. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  53. package/dist/types/tools/index.d.ts +4 -0
  54. package/package.json +9 -9
  55. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  56. package/src/cli/notify-cli.ts +40 -18
  57. package/src/commands/sdk.ts +7 -2
  58. package/src/config/settings-schema.ts +17 -0
  59. package/src/daemon/runtime.ts +2 -2
  60. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  61. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  62. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  64. package/src/eval/py/index.ts +2 -2
  65. package/src/export/html/template.generated.ts +1 -1
  66. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  67. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  68. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  69. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  70. package/src/gjc-runtime/launch-tmux.ts +61 -6
  71. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  72. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  73. package/src/gjc-runtime/state-runtime.ts +38 -12
  74. package/src/gjc-runtime/team-runtime.ts +1 -1
  75. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  76. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  77. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  78. package/src/harness-control-plane/owner.ts +59 -3
  79. package/src/hindsight/client.ts +65 -2
  80. package/src/internal-urls/agent-protocol.ts +76 -5
  81. package/src/internal-urls/docs-index.generated.ts +7 -6
  82. package/src/internal-urls/local-protocol.ts +416 -18
  83. package/src/lsp/index.ts +4 -88
  84. package/src/main.ts +37 -13
  85. package/src/modes/acp/acp-agent.ts +8 -0
  86. package/src/modes/components/ansi-display-validator.ts +54 -0
  87. package/src/modes/components/notifications-settings-editor.ts +26 -2
  88. package/src/modes/components/session-observer-overlay.ts +40 -24
  89. package/src/modes/components/settings-selector.ts +3 -1
  90. package/src/modes/components/tool-transcript-format.ts +321 -0
  91. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  92. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  93. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  94. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  95. package/src/modes/controllers/selector-controller.ts +70 -8
  96. package/src/modes/interactive-mode.ts +1 -0
  97. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  98. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  99. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  100. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  101. package/src/plan-mode/approved-plan.ts +3 -1
  102. package/src/prompts/agents/architect.md +1 -0
  103. package/src/prompts/agents/critic.md +1 -0
  104. package/src/prompts/system/custom-system-prompt.md +1 -1
  105. package/src/prompts/system/system-prompt.md +2 -1
  106. package/src/prompts/tools/lsp.md +3 -3
  107. package/src/runtime-mcp/manager.ts +16 -1
  108. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  109. package/src/sdk/broker/session-index.ts +122 -64
  110. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  111. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  112. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  113. package/src/sdk/bus/config-commands.ts +22 -0
  114. package/src/sdk/bus/config.ts +37 -0
  115. package/src/sdk/bus/index.ts +384 -92
  116. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  117. package/src/sdk/bus/notification-service.ts +284 -62
  118. package/src/sdk/bus/recent-activity.ts +14 -5
  119. package/src/sdk/bus/session-control.ts +150 -44
  120. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  121. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  122. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  123. package/src/sdk/bus/telegram-daemon.ts +1788 -267
  124. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  125. package/src/sdk/session.ts +155 -27
  126. package/src/secrets/index.ts +53 -10
  127. package/src/secrets/obfuscator.ts +21 -18
  128. package/src/secrets/regex.ts +2 -0
  129. package/src/session/agent-session.ts +495 -253
  130. package/src/session/artifacts.ts +188 -23
  131. package/src/session/context-estimation.ts +7 -2
  132. package/src/session/internal/managed-session-scope.ts +200 -38
  133. package/src/session/internal/managed-session-storage.ts +1152 -46
  134. package/src/session/session-manager.ts +1055 -148
  135. package/src/session/session-storage.ts +109 -11
  136. package/src/session/streaming-output.ts +6 -0
  137. package/src/task/executor.ts +71 -26
  138. package/src/task/index.ts +63 -20
  139. package/src/tool-discovery/tool-index.ts +18 -0
  140. package/src/tools/ask.ts +129 -32
  141. package/src/tools/bash-skill-urls.ts +14 -3
  142. package/src/tools/bash.ts +2 -2
  143. package/src/tools/index.ts +4 -0
  144. package/src/tools/plan-mode-guard.ts +1 -0
  145. package/src/tools/sqlite-reader.ts +3 -2
  146. package/src/tools/subagent.ts +2 -0
@@ -11,6 +11,7 @@ import * as fsSync from "node:fs";
11
11
  import * as fs from "node:fs/promises";
12
12
 
13
13
  import * as path from "node:path";
14
+ import { openRecoveryFsRoot } from "@gajae-code/natives";
14
15
  import { isCompiledBinary } from "@gajae-code/utils/env";
15
16
  import { parseLinuxProcStartTime } from "./linux-proc";
16
17
 
@@ -1046,6 +1047,122 @@ function sameOwnerGenerationBaseline(left: OwnerGenerationBaseline, right: Owner
1046
1047
  );
1047
1048
  }
1048
1049
 
1050
+ export interface ManagedOwnerPredecessorEvidence {
1051
+ generation: string;
1052
+ sessionId: string;
1053
+ runId: string;
1054
+ incarnation: string;
1055
+ predecessorToken: string;
1056
+ }
1057
+
1058
+ function exactManagedOwnerJson(authority: ReturnType<typeof openRecoveryFsRoot>, name: string): unknown {
1059
+ const first = authority.read(name, 64 * 1024);
1060
+ if (!first.ok || !first.data) throw new Error("managed_owner_replacement_evidence_unavailable");
1061
+ const second = authority.read(name, 64 * 1024);
1062
+ if (
1063
+ !second.ok ||
1064
+ !second.data ||
1065
+ Buffer.compare(Buffer.from(first.data), Buffer.from(second.data)) !== 0 ||
1066
+ JSON.stringify(first.identity) !== JSON.stringify(second.identity)
1067
+ )
1068
+ throw new Error("managed_owner_replacement_evidence_changed");
1069
+ const content = Buffer.from(first.data).toString("utf8");
1070
+ if (!content.endsWith("\n") || content.indexOf("\n") !== content.length - 1)
1071
+ throw new Error("managed_owner_replacement_evidence_untrusted");
1072
+ return JSON.parse(content);
1073
+ }
1074
+
1075
+ /** Resolve one SIGABRT predecessor from the retained exact prior-generation root. */
1076
+ export function resolveManagedOwnerPredecessorSync(
1077
+ stateDir: string,
1078
+ sessionId: string,
1079
+ baseline: OwnerGenerationBaseline,
1080
+ ): ManagedOwnerPredecessorEvidence | undefined {
1081
+ if (baseline.state === "absent") return undefined;
1082
+ const root = lifecyclePaths(stateDir, sessionId, baseline.generation).root;
1083
+ let entries: string[];
1084
+ try {
1085
+ entries = fsSync.readdirSync(root);
1086
+ } catch {
1087
+ throw new Error("managed_owner_replacement_evidence_unavailable");
1088
+ }
1089
+ const bindings = new Set<string>();
1090
+ const receipts = new Set<string>();
1091
+ for (const entry of entries) {
1092
+ const binding = /^child-([^/]+)\.binding\.json$/.exec(entry);
1093
+ const receipt = /^sigabrt-([^/]+)\.receipt\.json$/.exec(entry);
1094
+ if (entry.startsWith("child-") && !binding) throw new Error("managed_owner_replacement_evidence_untrusted");
1095
+ if (entry.startsWith("sigabrt-") && !receipt) throw new Error("managed_owner_replacement_evidence_untrusted");
1096
+ if (binding) bindings.add(binding[1]!);
1097
+ if (receipt) receipts.add(receipt[1]!);
1098
+ }
1099
+ if (bindings.size === 0 && receipts.size === 0) return undefined;
1100
+ const tokens = [...bindings].filter(token => receipts.has(token));
1101
+ if (tokens.length !== 1 || receipts.size !== 1) throw new Error("managed_owner_replacement_evidence_ambiguous");
1102
+ const predecessorToken = tokens[0]!;
1103
+ if (!/^[A-Za-z0-9._-]+$/.test(predecessorToken)) throw new Error("managed_owner_replacement_evidence_untrusted");
1104
+ const authority = openRecoveryFsRoot(root);
1105
+ try {
1106
+ const binding = exactManagedOwnerJson(authority, `child-${predecessorToken}.binding.json`) as Record<
1107
+ string,
1108
+ unknown
1109
+ >;
1110
+ const receipt = exactManagedOwnerJson(authority, `sigabrt-${predecessorToken}.receipt.json`) as Record<
1111
+ string,
1112
+ unknown
1113
+ >;
1114
+ const command = binding.command;
1115
+ const runId = binding.run_id;
1116
+ const incarnation = binding.endpoint_incarnation;
1117
+ const commandDigest =
1118
+ Array.isArray(command) && command.length > 0 && command.every(value => typeof value === "string" && value)
1119
+ ? crypto.createHash("sha256").update(JSON.stringify(command)).digest("hex")
1120
+ : "";
1121
+ const valid =
1122
+ binding.schema_version === 2 &&
1123
+ binding.generation === baseline.generation &&
1124
+ binding.session_id === sessionId &&
1125
+ typeof runId === "string" &&
1126
+ runId.length > 0 &&
1127
+ typeof incarnation === "string" &&
1128
+ incarnation.length > 0 &&
1129
+ binding.child_token === predecessorToken &&
1130
+ binding.command_sha256 === commandDigest &&
1131
+ typeof binding.supervisor_pid === "number" &&
1132
+ Number.isSafeInteger(binding.supervisor_pid) &&
1133
+ binding.supervisor_pid > 0 &&
1134
+ typeof binding.supervisor_start_time === "string" &&
1135
+ typeof binding.created_at === "string" &&
1136
+ receipt.schema_version === 2 &&
1137
+ receipt.generation === binding.generation &&
1138
+ receipt.session_id === binding.session_id &&
1139
+ receipt.run_id === runId &&
1140
+ receipt.endpoint_incarnation === incarnation &&
1141
+ receipt.child_token === binding.child_token &&
1142
+ receipt.command_sha256 === binding.command_sha256 &&
1143
+ receipt.supervisor_pid === binding.supervisor_pid &&
1144
+ receipt.supervisor_start_time === binding.supervisor_start_time &&
1145
+ typeof receipt.child_pid === "number" &&
1146
+ Number.isSafeInteger(receipt.child_pid) &&
1147
+ receipt.child_pid > 0 &&
1148
+ typeof receipt.child_start_time === "string" &&
1149
+ typeof receipt.received_at === "string" &&
1150
+ (receipt.exit_code === null || Number.isSafeInteger(receipt.exit_code)) &&
1151
+ receipt.signal === "SIGABRT" &&
1152
+ receipt.signal_number === 6;
1153
+ if (!valid) throw new Error("managed_owner_replacement_evidence_untrusted");
1154
+ return {
1155
+ generation: baseline.generation,
1156
+ sessionId,
1157
+ runId,
1158
+ incarnation,
1159
+ predecessorToken,
1160
+ };
1161
+ } finally {
1162
+ authority.close();
1163
+ }
1164
+ }
1165
+
1049
1166
  export async function captureOwnerGenerationBaseline(
1050
1167
  stateDir: string,
1051
1168
  sessionId: string,
@@ -0,0 +1,328 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { openRecoveryFsRoot } from "@gajae-code/natives";
5
+ import type { ManagedOwnerSigabrtReceipt } from "./managed-owner-supervisor";
6
+ import { sessionStateDir, sessionUltragoalDir } from "./session-layout";
7
+ import { appendJsonlIdempotent, writeJsonAtomic } from "./state-writer";
8
+
9
+ /** Immutable identity supplied by the owner-loss monitor and coordinator admission. */
10
+ export interface UltragoalRecoveryBinding {
11
+ sessionId: string;
12
+ endpointIncarnation: string;
13
+ ownerGeneration: string;
14
+ cwd: string;
15
+ }
16
+
17
+ export interface UltragoalOwnerLossReceipt {
18
+ schema_version: 1;
19
+ session_id: string;
20
+ generation: string;
21
+ classification: "unexpected_owner_loss";
22
+ result: "signal" | "exit" | "unknown_terminal";
23
+ observed_at: string;
24
+ }
25
+
26
+ export type AuthoritativeOwnerLossReceipt = UltragoalOwnerLossReceipt | ManagedOwnerSigabrtReceipt;
27
+
28
+ export interface UltragoalRecoverySnapshot {
29
+ /** B0 is captured once before recovery writes. P is protected and never rewritten. */
30
+ b0: { planSha256: string; ledgerSha256: string; capturedAt: string };
31
+ protectedPaths: string[];
32
+ /** Exact sanctioned deltas, never inferred from a worktree scan. */
33
+ sanctionedDeltas: string[];
34
+ /** Durable baseline-absent artifacts. */
35
+ absentArtifacts: string[];
36
+ /** Transient paths deliberately excluded from provenance. */
37
+ transientHistory: string[];
38
+ }
39
+
40
+ export type UltragoalRecoveryDisposition = "resume" | "handoff";
41
+ export interface UltragoalRecoveryDecision {
42
+ disposition: UltragoalRecoveryDisposition;
43
+ reason: string;
44
+ snapshot?: UltragoalRecoverySnapshot;
45
+ terminal?: { yieldId: string; result: Record<string, unknown> };
46
+ }
47
+
48
+ interface TranscriptYield {
49
+ type: "yield";
50
+ id: string;
51
+ parentId: string | null;
52
+ result: Record<string, unknown>;
53
+ }
54
+
55
+ function nonEmpty(value: unknown): value is string {
56
+ return typeof value === "string" && value.trim().length > 0;
57
+ }
58
+
59
+ function isRecord(value: unknown): value is Record<string, unknown> {
60
+ return value !== null && typeof value === "object" && !Array.isArray(value);
61
+ }
62
+
63
+ function exactChild(
64
+ entry: Record<string, unknown>,
65
+ parentId: string | null,
66
+ ): entry is Record<string, unknown> & { id: string } {
67
+ return entry.parentId === parentId && nonEmpty(entry.id);
68
+ }
69
+
70
+ function digest(bytes: Uint8Array): string {
71
+ return createHash("sha256").update(bytes).digest("hex");
72
+ }
73
+
74
+ function safeRelativePath(root: string, candidate: string): string | null {
75
+ const relative = path.relative(root, candidate);
76
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
77
+ return relative;
78
+ }
79
+
80
+ async function bytesOrAbsent(filePath: string): Promise<Uint8Array | null> {
81
+ try {
82
+ return await fs.readFile(filePath);
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Parse the complete transcript; partial rows, alternate terminal spellings, and
91
+ * duplicate/conflicting yields are intentionally invalid authority.
92
+ */
93
+ export function parseStrictTerminalTranscript(content: string): TranscriptYield | null {
94
+ if (!content?.endsWith("\n")) return null;
95
+ const entries: Array<Record<string, unknown> & { id: string }> = [];
96
+ let parentId: string | null = null;
97
+ for (const line of content.slice(0, -1).split("\n")) {
98
+ if (!line) return null;
99
+ let parsed: unknown;
100
+ try {
101
+ parsed = JSON.parse(line);
102
+ } catch {
103
+ return null;
104
+ }
105
+ if (!isRecord(parsed) || !nonEmpty(parsed.type) || !exactChild(parsed, parentId)) return null;
106
+ if (
107
+ parsed.type !== "yield" &&
108
+ parsed.type !== "toolResult" &&
109
+ parsed.type !== "tool_call" &&
110
+ parsed.type !== "message" &&
111
+ parsed.type !== "mode_change"
112
+ )
113
+ return null;
114
+ entries.push(parsed);
115
+ parentId = parsed.id;
116
+ }
117
+ if (entries.length < 2) return null;
118
+ if (entries.filter(entry => entry.type === "yield").length !== 1) return null;
119
+ const yieldEntry = entries[entries.length - 2]!;
120
+ const resultEntry = entries[entries.length - 1]!;
121
+ if (yieldEntry.type !== "yield" || !isRecord(yieldEntry.result) || resultEntry.type !== "toolResult") return null;
122
+ if (resultEntry.toolCallId !== yieldEntry.id || !Array.isArray(resultEntry.content)) return null;
123
+ return {
124
+ type: "yield",
125
+ id: yieldEntry.id,
126
+ parentId: yieldEntry.parentId as string | null,
127
+ result: yieldEntry.result,
128
+ };
129
+ }
130
+
131
+ /** Read through a retained root descriptor; pathname resolution never authorizes recovery input. */
132
+ async function readRecoveryFile(root: string, candidate: string): Promise<string | null> {
133
+ if (process.platform !== "linux") return null;
134
+ const relative = safeRelativePath(path.resolve(root), path.resolve(candidate));
135
+ if (!relative) return null;
136
+ try {
137
+ const authority = openRecoveryFsRoot(root);
138
+ try {
139
+ const result = authority.read(relative, 4 * 1024 * 1024);
140
+ return result.ok && result.data ? new TextDecoder().decode(result.data) : null;
141
+ } finally {
142
+ authority.close();
143
+ }
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /** Validate a path through a retained native root descriptor; path strings alone are never authority. */
150
+ export async function validateRecoveryPath(root: string, candidate: string): Promise<string | null> {
151
+ return (await readRecoveryFile(root, candidate)) === null ? null : candidate;
152
+ }
153
+
154
+ export function validateOwnerLossBinding(
155
+ binding: UltragoalRecoveryBinding,
156
+ receipt: unknown,
157
+ ): receipt is AuthoritativeOwnerLossReceipt {
158
+ if (!isRecord(receipt) || receipt.session_id !== binding.sessionId || receipt.generation !== binding.ownerGeneration)
159
+ return false;
160
+ if (receipt.schema_version === 2)
161
+ return (
162
+ receipt.signal === "SIGABRT" &&
163
+ receipt.signal_number === 6 &&
164
+ nonEmpty(receipt.run_id) &&
165
+ receipt.endpoint_incarnation === binding.endpointIncarnation &&
166
+ nonEmpty(receipt.child_token) &&
167
+ nonEmpty(receipt.command_sha256) &&
168
+ typeof receipt.supervisor_pid === "number" &&
169
+ Number.isSafeInteger(receipt.supervisor_pid) &&
170
+ receipt.supervisor_pid > 0 &&
171
+ typeof receipt.child_pid === "number" &&
172
+ Number.isSafeInteger(receipt.child_pid) &&
173
+ receipt.child_pid > 0 &&
174
+ nonEmpty(receipt.supervisor_start_time) &&
175
+ nonEmpty(receipt.child_start_time) &&
176
+ (receipt.exit_code === null || Number.isSafeInteger(receipt.exit_code)) &&
177
+ nonEmpty(receipt.received_at) &&
178
+ Number.isFinite(Date.parse(receipt.received_at))
179
+ );
180
+ return false;
181
+ }
182
+
183
+ /** Admission is valid only for this immutable coordinator endpoint incarnation. */
184
+ export function validateRecoveryAdmission(binding: UltragoalRecoveryBinding, admission: unknown): boolean {
185
+ if (!isRecord(admission)) return false;
186
+ return (
187
+ admission.session_id === binding.sessionId &&
188
+ admission.endpoint_incarnation === binding.endpointIncarnation &&
189
+ admission.owner_generation === binding.ownerGeneration &&
190
+ admission.admitted === true
191
+ );
192
+ }
193
+
194
+ /** Raw plan and ledger evidence must be complete before their B0 hashes are trusted. */
195
+ export function validateRawUltragoalEvidence(plan: Uint8Array, ledger: Uint8Array): boolean {
196
+ try {
197
+ const parsedPlan: unknown = JSON.parse(new TextDecoder().decode(plan));
198
+ if (!isRecord(parsedPlan) || !Array.isArray(parsedPlan.goals)) return false;
199
+ const content = new TextDecoder().decode(ledger);
200
+ if (!content.endsWith("\n")) return false;
201
+ for (const line of content.slice(0, -1).split("\n")) {
202
+ if (!line || !isRecord(JSON.parse(line))) return false;
203
+ }
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /** Build a B0 snapshot without consulting unrelated paths or mutable sidecars. */
211
+ export async function captureUltragoalRecoverySnapshot(input: {
212
+ cwd: string;
213
+ sessionId: string;
214
+ protectedPaths: readonly string[];
215
+ sanctionedDeltas: readonly string[];
216
+ absentArtifacts: readonly string[];
217
+ transientHistory: readonly string[];
218
+ }): Promise<UltragoalRecoverySnapshot | null> {
219
+ const root = sessionUltragoalDir(input.cwd, input.sessionId);
220
+ const [plan, ledger] = await Promise.all([
221
+ bytesOrAbsent(path.join(root, "goals.json")),
222
+ bytesOrAbsent(path.join(root, "ledger.jsonl")),
223
+ ]);
224
+ if (!plan || !ledger || !validateRawUltragoalEvidence(plan, ledger)) return null;
225
+ const allPaths = [
226
+ ...input.protectedPaths,
227
+ ...input.sanctionedDeltas,
228
+ ...input.absentArtifacts,
229
+ ...input.transientHistory,
230
+ ];
231
+ if (allPaths.some(value => !nonEmpty(value) || path.isAbsolute(value) || value.split(/[\\/]/).includes("..")))
232
+ return null;
233
+ const sets = [input.protectedPaths, input.sanctionedDeltas, input.absentArtifacts, input.transientHistory].map(
234
+ values => new Set(values),
235
+ );
236
+ if (
237
+ sets.some((set, index) =>
238
+ sets.some((other, otherIndex) => otherIndex > index && [...set].some(value => other.has(value))),
239
+ )
240
+ )
241
+ return null;
242
+ return {
243
+ b0: { planSha256: digest(plan), ledgerSha256: digest(ledger), capturedAt: new Date().toISOString() },
244
+ protectedPaths: [...input.protectedPaths],
245
+ sanctionedDeltas: [...input.sanctionedDeltas],
246
+ absentArtifacts: [...input.absentArtifacts],
247
+ transientHistory: [...input.transientHistory],
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Decide only from direct owner evidence plus a complete terminal transcript.
253
+ * All ambiguity maps to durable handoff; callers never guess a resume.
254
+ */
255
+ export async function planUltragoalOwnerLossRecovery(input: {
256
+ binding: UltragoalRecoveryBinding;
257
+ receipt: unknown;
258
+ admission: unknown;
259
+
260
+ transcriptPath: string;
261
+ protectedPaths?: readonly string[];
262
+ sanctionedDeltas?: readonly string[];
263
+ absentArtifacts?: readonly string[];
264
+ transientHistory?: readonly string[];
265
+ }): Promise<UltragoalRecoveryDecision> {
266
+ if (!validateOwnerLossBinding(input.binding, input.receipt))
267
+ return { disposition: "handoff", reason: "owner_receipt_untrusted" };
268
+ const content = await readRecoveryFile(input.binding.cwd, input.transcriptPath);
269
+ if (!validateRecoveryAdmission(input.binding, input.admission))
270
+ return { disposition: "handoff", reason: "coordinator_admission_untrusted" };
271
+ if (content === null) return { disposition: "handoff", reason: "transcript_path_untrusted" };
272
+ const terminal = parseStrictTerminalTranscript(content);
273
+ if (!terminal) return { disposition: "handoff", reason: "terminal_transcript_missing_or_invalid" };
274
+ if (terminal.result.status !== "success") return { disposition: "handoff", reason: "terminal_aborted_errored" };
275
+ const snapshot = await captureUltragoalRecoverySnapshot({
276
+ cwd: input.binding.cwd,
277
+ sessionId: input.binding.sessionId,
278
+ protectedPaths: input.protectedPaths ?? ["goals.json", "ledger.jsonl"],
279
+ sanctionedDeltas: input.sanctionedDeltas ?? [],
280
+ absentArtifacts: input.absentArtifacts ?? [],
281
+ transientHistory: input.transientHistory ?? [],
282
+ });
283
+ if (!snapshot) return { disposition: "handoff", reason: "baseline_or_path_invariant_failed" };
284
+ return {
285
+ disposition: "resume",
286
+ reason: "terminal_transcript_authoritative",
287
+ snapshot,
288
+ terminal: { yieldId: terminal.id, result: terminal.result },
289
+ };
290
+ }
291
+
292
+ /** Persist a monotonic replay journal and fail-closed handoff through sanctioned state writers. */
293
+ export async function persistUltragoalRecoveryDecision(input: {
294
+ cwd: string;
295
+ sessionId: string;
296
+ binding: UltragoalRecoveryBinding;
297
+ decision: UltragoalRecoveryDecision;
298
+ }): Promise<{ handoffPath: string; journalPath: string }> {
299
+ const stateDir = sessionStateDir(input.cwd, input.sessionId);
300
+ const handoffPath = path.join(stateDir, "ultragoal-owner-loss-recovery.json");
301
+ const journalPath = path.join(stateDir, "ultragoal-owner-loss-recovery.jsonl");
302
+ const record = {
303
+ schema_version: 1,
304
+ binding: input.binding,
305
+ disposition: input.decision.disposition,
306
+ reason: input.decision.reason,
307
+ snapshot: input.decision.snapshot ?? null,
308
+ terminal: input.decision.terminal ?? null,
309
+ created_at: new Date().toISOString(),
310
+ };
311
+ await writeJsonAtomic(handoffPath, record, {
312
+ cwd: input.cwd,
313
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", sessionId: input.sessionId },
314
+ });
315
+ await appendJsonlIdempotent(
316
+ journalPath,
317
+ { ...record, event: "owner_loss_recovery_decision" },
318
+ {
319
+ cwd: input.cwd,
320
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", sessionId: input.sessionId },
321
+ key: entry => {
322
+ if (!isRecord(entry) || entry.event !== "owner_loss_recovery_decision") return undefined;
323
+ return `${entry.binding === undefined ? "" : JSON.stringify(entry.binding)}:${String(entry.disposition)}:${String(entry.reason)}`;
324
+ },
325
+ },
326
+ );
327
+ return { handoffPath, journalPath };
328
+ }
@@ -33,6 +33,20 @@ import {
33
33
  writeGuardedJsonAtomic,
34
34
  } from "./state-writer";
35
35
 
36
+ export {
37
+ captureUltragoalRecoverySnapshot,
38
+ parseStrictTerminalTranscript,
39
+ persistUltragoalRecoveryDecision,
40
+ planUltragoalOwnerLossRecovery,
41
+ type UltragoalOwnerLossReceipt,
42
+ type UltragoalRecoveryBinding,
43
+ type UltragoalRecoveryDecision,
44
+ type UltragoalRecoverySnapshot,
45
+ validateOwnerLossBinding,
46
+ validateRawUltragoalEvidence,
47
+ validateRecoveryAdmission,
48
+ validateRecoveryPath,
49
+ } from "./ultragoal-owner-loss-recovery";
36
50
  export type UltragoalGjcGoalMode = "aggregate" | "per-story";
37
51
  export type UltragoalGoalStatus =
38
52
  | "pending"
@@ -115,6 +115,8 @@ export interface OwnerOptions {
115
115
  controlServerFactory?: (socketPath: string, handler: EndpointHandler) => ControlServer;
116
116
  /** Test seam for deterministic lease-release teardown failures. */
117
117
  leaseRelease?: typeof releaseLease;
118
+ /** Test seam for deterministic lease-heartbeat behavior (e.g. renewal barriers). */
119
+ leaseHeartbeat?: typeof heartbeat;
118
120
  /** Test seams for frame persistence failures. */
119
121
  framePersistence?: {
120
122
  appendEvent?: typeof appendEvent;
@@ -150,6 +152,11 @@ export class RuntimeOwner {
150
152
  #leaseEpoch = 0;
151
153
  #leaseHeld = false;
152
154
  #heartbeatTimer: NodeJS.Timeout | null = null;
155
+ #heartbeatFenced = false;
156
+ // Every renewal the heartbeat interval starts is tracked here until it settles.
157
+ // Teardown drains the whole set so no already-started renewal can still be
158
+ // polling the lease mutation lock when releaseLease runs.
159
+ #heartbeatInFlight = new Set<Promise<unknown>>();
153
160
  #socketPath: string;
154
161
  #finalizeChecks?: FinalizeChecks;
155
162
  #validationCommands?: ValidationCommandSpec[];
@@ -179,6 +186,7 @@ export class RuntimeOwner {
179
186
  cleanupRetryMs: opts.cleanupRetryMs ?? DEFAULT_CLEANUP_RETRY_MS,
180
187
  cleanupRetryLimit: opts.cleanupRetryLimit ?? Number.POSITIVE_INFINITY,
181
188
  leaseRelease: opts.leaseRelease ?? releaseLease,
189
+ leaseHeartbeat: opts.leaseHeartbeat ?? heartbeat,
182
190
  };
183
191
  this.#finalizeChecks = opts.finalizeChecks;
184
192
  this.#validationCommands = opts.validationCommands;
@@ -230,9 +238,23 @@ export class RuntimeOwner {
230
238
  this.#leaseHeld = true;
231
239
  this.#leaseEpoch = lease.leaseEpoch;
232
240
  this.#heartbeatTimer = setInterval(() => {
233
- void heartbeat(root, sessionId, this.ownerId, this.#opts.ttlMs, this.#opts.clock).catch(err => {
234
- // Self-stop if a legitimate dead-owner takeover revoked our lease.
235
- if (err instanceof Error && err.message.includes("not_lease_holder")) void this.stop().catch(() => {});
241
+ // Once teardown fences the heartbeat we must never start a renewal again:
242
+ // a renewal here would contend the lease mutation lock with the release
243
+ // path and can starve it (lease_lock_timeout).
244
+ if (this.#heartbeatFenced) return;
245
+ const renewal = this.#opts
246
+ .leaseHeartbeat(root, sessionId, this.ownerId, this.#opts.ttlMs, this.#opts.clock)
247
+ .catch(err => {
248
+ // Self-stop if a legitimate dead-owner takeover revoked our lease.
249
+ if (err instanceof Error && err.message.includes("not_lease_holder")) void this.stop().catch(() => {});
250
+ });
251
+ // Track every in-flight renewal so teardown can drain them ALL before
252
+ // releasing the lease. setInterval can start a new renewal before the
253
+ // previous one settles, and lock acquisition is non-FIFO, so awaiting only
254
+ // the latest would leave an older renewal able to contend releaseLease.
255
+ this.#heartbeatInFlight.add(renewal);
256
+ void renewal.finally(() => {
257
+ this.#heartbeatInFlight.delete(renewal);
236
258
  });
237
259
  }, this.#opts.heartbeatMs);
238
260
  this.#heartbeatTimer.unref?.();
@@ -876,6 +898,14 @@ export class RuntimeOwner {
876
898
  }
877
899
  if (!this.#serverClosed) throw new AggregateError(failures, "Runtime owner endpoint cleanup failed.");
878
900
 
901
+ // Transport and endpoint are verified closed, so we are committed to
902
+ // surrendering the lease. Stop, fence, and join the heartbeat first: an
903
+ // in-flight or scheduled renewal would otherwise contend the lease mutation
904
+ // lock with releaseLease and can starve it (lease_lock_timeout). Fencing
905
+ // before release preserves exact-owner fencing — a fenced owner never renews,
906
+ // so it can neither revive its own lease nor touch a successor's.
907
+ await this.#fenceHeartbeat();
908
+
879
909
  if (this.#leaseHeld) {
880
910
  try {
881
911
  await this.#opts.leaseRelease(this.#opts.root, this.#opts.sessionId, this.ownerId);
@@ -892,12 +922,38 @@ export class RuntimeOwner {
892
922
  }
893
923
  if (this.#leaseHeld) throw new AggregateError(failures, "Runtime owner lease cleanup failed.");
894
924
 
925
+ // Heartbeat was already fenced before the lease release above; this is a
926
+ // defensive no-op that also covers any path that skipped the release stage.
895
927
  if (this.#heartbeatTimer) {
896
928
  clearInterval(this.#heartbeatTimer);
897
929
  this.#heartbeatTimer = null;
898
930
  }
899
931
  if (failures.length > 0) throw new AggregateError(failures, "Runtime owner cleanup failed.");
900
932
  }
933
+
934
+ /**
935
+ * Permanently stop the lease heartbeat and drain every in-flight renewal.
936
+ *
937
+ * Fencing is terminal: `#heartbeatFenced` stays set so no scheduled interval
938
+ * callback can start a new renewal after teardown begins. The interval is
939
+ * cleared, then EVERY renewal already started (there may be more than one, since
940
+ * `setInterval` does not wait for the async `heartbeat()` of a prior tick) is
941
+ * awaited so each releases the lease mutation lock before the release path
942
+ * acquires it. This eliminates heartbeat/release self-contention on the
943
+ * non-FIFO lock (lease_lock_timeout) without weakening exact-owner fencing.
944
+ */
945
+ async #fenceHeartbeat(): Promise<void> {
946
+ this.#heartbeatFenced = true;
947
+ if (this.#heartbeatTimer) {
948
+ clearInterval(this.#heartbeatTimer);
949
+ this.#heartbeatTimer = null;
950
+ }
951
+ // Snapshot then drain: no new renewal can be added once fenced, so this
952
+ // settles every renewal that could still hold or be waiting on the lock.
953
+ const inFlight = [...this.#heartbeatInFlight];
954
+ this.#heartbeatInFlight.clear();
955
+ if (inFlight.length > 0) await Promise.allSettled(inFlight);
956
+ }
901
957
  }
902
958
 
903
959
  export interface ResolvedOwner {
@@ -12,6 +12,8 @@ import type { HindsightConfig } from "./config";
12
12
 
13
13
  const USER_AGENT = "oh-my-gajae-code";
14
14
  const DEFAULT_USER_AGENT = USER_AGENT;
15
+ const REQUEST_TIMEOUT_MS = 30_000;
16
+ const RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
15
17
 
16
18
  export type Budget = "low" | "mid" | "high" | string;
17
19
  export type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
@@ -210,6 +212,49 @@ interface RequestOptions {
210
212
  allow404?: boolean;
211
213
  }
212
214
 
215
+ class ResponseTooLargeError extends Error {}
216
+
217
+ async function readResponseText(response: Response): Promise<string> {
218
+ const contentLength = response.headers.get("content-length");
219
+ if (contentLength !== null) {
220
+ const declaredBytes = Number(contentLength);
221
+ if (Number.isFinite(declaredBytes) && declaredBytes > RESPONSE_MAX_BYTES) {
222
+ void response.body?.cancel().catch(() => undefined);
223
+ throw new ResponseTooLargeError();
224
+ }
225
+ }
226
+
227
+ const reader = response.body?.getReader();
228
+ if (!reader) return "";
229
+
230
+ const chunks: Uint8Array[] = [];
231
+ let totalBytes = 0;
232
+ try {
233
+ while (true) {
234
+ const { done, value } = await reader.read();
235
+ if (done) break;
236
+ if (!value || value.byteLength === 0) continue;
237
+
238
+ totalBytes += value.byteLength;
239
+ if (totalBytes > RESPONSE_MAX_BYTES) {
240
+ void reader.cancel().catch(() => undefined);
241
+ throw new ResponseTooLargeError();
242
+ }
243
+ chunks.push(value);
244
+ }
245
+ } finally {
246
+ reader.releaseLock();
247
+ }
248
+
249
+ const bytes = new Uint8Array(totalBytes);
250
+ let offset = 0;
251
+ for (const chunk of chunks) {
252
+ bytes.set(chunk, offset);
253
+ offset += chunk.byteLength;
254
+ }
255
+ return new TextDecoder().decode(bytes);
256
+ }
257
+
213
258
  export class HindsightApi {
214
259
  #baseUrl: string;
215
260
  #headers: Record<string, string>;
@@ -489,7 +534,8 @@ export class HindsightApi {
489
534
  if (qs) url += `?${qs}`;
490
535
  }
491
536
 
492
- const init: RequestInit = { method, headers: this.#headers };
537
+ const requestSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
538
+ const init: RequestInit = { method, headers: this.#headers, signal: requestSignal };
493
539
  if (opts?.body !== undefined) {
494
540
  init.body = JSON.stringify(pruneUndefined(opts.body));
495
541
  }
@@ -498,6 +544,9 @@ export class HindsightApi {
498
544
  try {
499
545
  response = await fetch(url, init);
500
546
  } catch (err) {
547
+ if (requestSignal.aborted) {
548
+ throw new HindsightError(`${operation} request failed: timed out`);
549
+ }
501
550
  throw new HindsightError(
502
551
  `${operation} request failed: ${err instanceof Error ? err.message : String(err)}`,
503
552
  undefined,
@@ -506,10 +555,24 @@ export class HindsightApi {
506
555
  }
507
556
 
508
557
  if (opts?.allow404 && response.status === 404) {
558
+ await response.body?.cancel().catch(() => undefined);
509
559
  return null as T;
510
560
  }
511
561
 
512
- const text = await response.text();
562
+ let text: string;
563
+ try {
564
+ text = await readResponseText(response);
565
+ } catch (err) {
566
+ const reason = requestSignal.aborted
567
+ ? "timed out"
568
+ : err instanceof ResponseTooLargeError
569
+ ? "response exceeded size limit"
570
+ : "response could not be read";
571
+ throw new HindsightError(`${operation} request failed: ${reason}`, response.status);
572
+ }
573
+ if (requestSignal.aborted) {
574
+ throw new HindsightError(`${operation} request failed: timed out`, response.status);
575
+ }
513
576
  const parsed = text ? safeJsonParse(text) : null;
514
577
 
515
578
  if (!response.ok) {