@gajae-code/coding-agent 0.2.5 → 0.3.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 (234) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/async/job-manager.d.ts +91 -2
  3. package/dist/types/cli/args.d.ts +1 -1
  4. package/dist/types/commands/deep-interview.d.ts +3 -0
  5. package/dist/types/commands/harness.d.ts +37 -0
  6. package/dist/types/config/keybindings.d.ts +5 -0
  7. package/dist/types/config/settings-schema.d.ts +10 -4
  8. package/dist/types/config/settings.d.ts +2 -0
  9. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  10. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  11. package/dist/types/deep-interview/render-middleware.d.ts +6 -0
  12. package/dist/types/eval/py/executor.d.ts +2 -0
  13. package/dist/types/eval/py/kernel.d.ts +2 -0
  14. package/dist/types/exec/bash-executor.d.ts +10 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  17. package/dist/types/extensibility/shared-events.d.ts +1 -0
  18. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  19. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  20. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  21. package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
  22. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  23. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  25. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  26. package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  28. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  29. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  30. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  31. package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
  32. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  33. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  34. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  35. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  36. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  37. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  38. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  39. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  40. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  41. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  42. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  43. package/dist/types/harness-control-plane/types.d.ts +162 -0
  44. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  45. package/dist/types/hooks/skill-state.d.ts +23 -29
  46. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  47. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  48. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  49. package/dist/types/internal-urls/types.d.ts +4 -0
  50. package/dist/types/lsp/index.d.ts +10 -10
  51. package/dist/types/modes/bridge/auth.d.ts +12 -0
  52. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  53. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  54. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  55. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  56. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  57. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  58. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  59. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  60. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  61. package/dist/types/modes/components/status-line.d.ts +2 -0
  62. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  63. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  64. package/dist/types/modes/index.d.ts +1 -0
  65. package/dist/types/modes/interactive-mode.d.ts +2 -0
  66. package/dist/types/modes/jobs-observer.d.ts +57 -0
  67. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  68. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  69. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  70. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  71. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  72. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  73. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  74. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  75. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  76. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  77. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  78. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  79. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  80. package/dist/types/modes/types.d.ts +2 -0
  81. package/dist/types/sdk.d.ts +4 -0
  82. package/dist/types/session/agent-session.d.ts +19 -1
  83. package/dist/types/skill-state/active-state.d.ts +2 -0
  84. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  85. package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
  86. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  87. package/dist/types/task/executor.d.ts +3 -0
  88. package/dist/types/task/id.d.ts +7 -0
  89. package/dist/types/task/index.d.ts +5 -0
  90. package/dist/types/task/receipt.d.ts +85 -0
  91. package/dist/types/task/spawn-gate.d.ts +38 -0
  92. package/dist/types/task/types.d.ts +198 -14
  93. package/dist/types/tools/cron.d.ts +6 -0
  94. package/dist/types/tools/index.d.ts +2 -0
  95. package/dist/types/tools/path-utils.d.ts +1 -0
  96. package/dist/types/tools/subagent.d.ts +26 -1
  97. package/package.json +7 -7
  98. package/scripts/build-binary.ts +7 -0
  99. package/src/async/job-manager.ts +334 -6
  100. package/src/cli/args.ts +9 -2
  101. package/src/cli/auth-broker-cli.ts +1 -0
  102. package/src/cli/config-cli.ts +10 -2
  103. package/src/cli.ts +2 -0
  104. package/src/commands/deep-interview.ts +1 -0
  105. package/src/commands/harness.ts +862 -0
  106. package/src/commands/launch.ts +2 -2
  107. package/src/commands/state.ts +2 -1
  108. package/src/commands/team.ts +54 -39
  109. package/src/config/keybindings.ts +6 -0
  110. package/src/config/settings-schema.ts +13 -3
  111. package/src/config/settings.ts +5 -0
  112. package/src/dap/client.ts +17 -3
  113. package/src/debug/crash-diagnostics.ts +223 -0
  114. package/src/debug/runtime-gauges.ts +20 -0
  115. package/src/deep-interview/render-middleware.ts +372 -0
  116. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  117. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  118. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  119. package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
  120. package/src/eval/py/executor.ts +21 -1
  121. package/src/eval/py/kernel.ts +15 -0
  122. package/src/exec/bash-executor.ts +41 -0
  123. package/src/extensibility/custom-tools/types.ts +1 -0
  124. package/src/extensibility/extensions/types.ts +6 -0
  125. package/src/extensibility/shared-events.ts +1 -0
  126. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  127. package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
  128. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  129. package/src/gjc-runtime/ralplan-runtime.ts +235 -43
  130. package/src/gjc-runtime/state-graph.ts +86 -0
  131. package/src/gjc-runtime/state-migrations.ts +179 -0
  132. package/src/gjc-runtime/state-renderer.ts +345 -0
  133. package/src/gjc-runtime/state-runtime.ts +1155 -46
  134. package/src/gjc-runtime/state-schema.ts +192 -0
  135. package/src/gjc-runtime/state-validation.ts +49 -0
  136. package/src/gjc-runtime/state-writer.ts +749 -0
  137. package/src/gjc-runtime/team-runtime.ts +1255 -189
  138. package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
  139. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  140. package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
  141. package/src/gjc-runtime/workflow-manifest.ts +427 -0
  142. package/src/harness-control-plane/classifier.ts +128 -0
  143. package/src/harness-control-plane/control-endpoint.ts +148 -0
  144. package/src/harness-control-plane/finalize.ts +222 -0
  145. package/src/harness-control-plane/frame-mapper.ts +286 -0
  146. package/src/harness-control-plane/operate.ts +225 -0
  147. package/src/harness-control-plane/owner.ts +600 -0
  148. package/src/harness-control-plane/preserve.ts +102 -0
  149. package/src/harness-control-plane/receipts.ts +216 -0
  150. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  151. package/src/harness-control-plane/seams.ts +39 -0
  152. package/src/harness-control-plane/session-lease.ts +388 -0
  153. package/src/harness-control-plane/state-machine.ts +98 -0
  154. package/src/harness-control-plane/storage.ts +257 -0
  155. package/src/harness-control-plane/types.ts +214 -0
  156. package/src/hooks/skill-keywords.ts +4 -2
  157. package/src/hooks/skill-state.ts +197 -64
  158. package/src/internal-urls/agent-protocol.ts +68 -21
  159. package/src/internal-urls/artifact-protocol.ts +12 -17
  160. package/src/internal-urls/docs-index.generated.ts +3 -2
  161. package/src/internal-urls/registry-helpers.ts +19 -16
  162. package/src/internal-urls/types.ts +4 -0
  163. package/src/lsp/client.ts +18 -2
  164. package/src/main.ts +21 -5
  165. package/src/modes/bridge/auth.ts +41 -0
  166. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  167. package/src/modes/bridge/bridge-mode.ts +520 -0
  168. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  169. package/src/modes/bridge/event-stream.ts +70 -0
  170. package/src/modes/components/assistant-message.ts +5 -1
  171. package/src/modes/components/custom-editor.ts +101 -0
  172. package/src/modes/components/hook-selector.ts +133 -20
  173. package/src/modes/components/jobs-overlay-model.ts +109 -0
  174. package/src/modes/components/jobs-overlay.ts +172 -0
  175. package/src/modes/components/status-line/presets.ts +7 -5
  176. package/src/modes/components/status-line/segments.ts +25 -0
  177. package/src/modes/components/status-line/types.ts +2 -0
  178. package/src/modes/components/status-line.ts +9 -1
  179. package/src/modes/controllers/event-controller.ts +71 -6
  180. package/src/modes/controllers/extension-ui-controller.ts +43 -1
  181. package/src/modes/controllers/input-controller.ts +105 -9
  182. package/src/modes/controllers/selector-controller.ts +31 -1
  183. package/src/modes/index.ts +1 -0
  184. package/src/modes/interactive-mode.ts +28 -0
  185. package/src/modes/jobs-observer.ts +204 -0
  186. package/src/modes/rpc/host-tools.ts +1 -186
  187. package/src/modes/rpc/host-uris.ts +1 -235
  188. package/src/modes/rpc/rpc-client.ts +25 -10
  189. package/src/modes/rpc/rpc-mode.ts +12 -381
  190. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  191. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  192. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  193. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  194. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  195. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  196. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  197. package/src/modes/shared/agent-wire/responses.ts +17 -0
  198. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  199. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  200. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  201. package/src/modes/types.ts +2 -0
  202. package/src/prompts/agents/executor.md +13 -0
  203. package/src/prompts/tools/subagent.md +39 -4
  204. package/src/prompts/tools/task-summary.md +3 -9
  205. package/src/prompts/tools/task.md +5 -1
  206. package/src/sdk.ts +8 -0
  207. package/src/session/agent-session.ts +445 -71
  208. package/src/session/session-manager.ts +13 -1
  209. package/src/skill-state/active-state.ts +58 -65
  210. package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
  211. package/src/skill-state/initial-phase.ts +2 -0
  212. package/src/skill-state/workflow-state-contract.ts +33 -4
  213. package/src/skill-state/workflow-state-version.ts +3 -0
  214. package/src/slash-commands/builtin-registry.ts +8 -0
  215. package/src/task/executor.ts +79 -13
  216. package/src/task/id.ts +33 -0
  217. package/src/task/index.ts +376 -74
  218. package/src/task/output-manager.ts +5 -4
  219. package/src/task/receipt.ts +297 -0
  220. package/src/task/render.ts +54 -134
  221. package/src/task/spawn-gate.ts +132 -0
  222. package/src/task/types.ts +104 -10
  223. package/src/tools/ask.ts +88 -27
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash.ts +1 -1
  227. package/src/tools/cron.ts +48 -0
  228. package/src/tools/find.ts +4 -1
  229. package/src/tools/index.ts +2 -0
  230. package/src/tools/path-utils.ts +3 -2
  231. package/src/tools/read.ts +1 -0
  232. package/src/tools/search.ts +1 -0
  233. package/src/tools/skill.ts +6 -1
  234. package/src/tools/subagent.ts +423 -79
@@ -0,0 +1,600 @@
1
+ /**
2
+ * RuntimeOwner — the detached per-session process that makes live control honest.
3
+ *
4
+ * Responsibilities:
5
+ * - hold the {@link SessionLease} (single writer),
6
+ * - own the {@link HarnessRpc} subprocess (injected; real `GajaeCodeRpc` in prod, fake in tests),
7
+ * - serve owner-routed primitives over the {@link ControlServer} endpoint,
8
+ * - be the SOLE writer of the severity event stream,
9
+ * - heartbeat the lease.
10
+ *
11
+ * Stateless `gjc harness` CLI calls reach the owner via {@link resolveOwner} + the endpoint.
12
+ */
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { randomBytes, randomUUID } from "node:crypto";
16
+ import { existsSync } from "node:fs";
17
+ import { classifyRecovery } from "./classifier";
18
+ import { ControlServer, type EndpointRequest } from "./control-endpoint";
19
+ import { defaultFinalizeChecks, type FinalizeChecks, runFinalize, type ValidationCommandSpec } from "./finalize";
20
+ import { mapRpcFrame } from "./frame-mapper";
21
+ import { type OperateResult, operate } from "./operate";
22
+ import { preserveDirtyWorktree } from "./preserve";
23
+ import {
24
+ buildReceipt,
25
+ type ReceiptSubject,
26
+ requiresVanishBeforeAction,
27
+ type ValidationEvidence,
28
+ type VanishEvidence,
29
+ validateReceipt,
30
+ } from "./receipts";
31
+ import type { HarnessRpc } from "./rpc-adapter";
32
+ import { singleFlightAccept } from "./rpc-adapter";
33
+ import {
34
+ acquireLease,
35
+ canWriteEvents,
36
+ classifyLeaseStatus,
37
+ heartbeat,
38
+ readLease,
39
+ releaseLease,
40
+ type SessionLease,
41
+ } from "./session-lease";
42
+ import { buildStateView, nextAllowedActions } from "./state-machine";
43
+ import {
44
+ appendEvent,
45
+ controlSocketPath,
46
+ readEvents,
47
+ readSessionState,
48
+ sessionPaths,
49
+ writeReceiptImmutable,
50
+ writeSessionState,
51
+ } from "./storage";
52
+ import type { EventEnvelope, GitDelta, Observation, PrimitiveResponse, SessionState, Severity } from "./types";
53
+ import { DEFAULT_RETRY_BUDGET, OBSERVED_SIGNALS } from "./types";
54
+
55
+ function isStartupLivenessBlocker(blocker: string): boolean {
56
+ return blocker === "detached-owner-not-live";
57
+ }
58
+
59
+ function isOwnerVanishedBlocker(blocker: string): boolean {
60
+ return blocker.startsWith("owner-vanished:");
61
+ }
62
+
63
+ function reconcileLiveOwnerState(state: SessionState): { state: SessionState; reconciled: boolean } {
64
+ const blockers = state.blockers.filter(blocker => !isStartupLivenessBlocker(blocker));
65
+ const hadLivenessBlocker = blockers.length !== state.blockers.length;
66
+ const lifecycle =
67
+ hadLivenessBlocker && state.lifecycle === "blocked" && blockers.length === 0 ? "observing" : state.lifecycle;
68
+ if (!hadLivenessBlocker && lifecycle === state.lifecycle) return { state, reconciled: false };
69
+ return {
70
+ state: {
71
+ ...state,
72
+ lifecycle,
73
+ blockers,
74
+ updatedAt: new Date().toISOString(),
75
+ },
76
+ reconciled: true,
77
+ };
78
+ }
79
+
80
+ export interface OwnerOptions {
81
+ root: string;
82
+ sessionId: string;
83
+ rpc: HarnessRpc;
84
+ ownerId?: string;
85
+ ttlMs?: number;
86
+ heartbeatMs?: number;
87
+ acceptanceTimeoutMs?: number;
88
+ clock?: () => number;
89
+ finalizeChecks?: FinalizeChecks;
90
+ validationCommands?: ValidationCommandSpec[];
91
+ }
92
+
93
+ export interface OwnerStartInfo {
94
+ ownerId: string;
95
+ socketPath: string;
96
+ leaseEpoch: number;
97
+ }
98
+
99
+ const DEFAULT_TTL_MS = 30_000;
100
+ const DEFAULT_HEARTBEAT_MS = 10_000;
101
+ const DEFAULT_ACCEPT_TIMEOUT_MS = 60_000;
102
+
103
+ export class RuntimeOwner {
104
+ readonly ownerId: string;
105
+ #opts: Required<Omit<OwnerOptions, "clock" | "finalizeChecks" | "validationCommands">> & { clock?: () => number };
106
+ #server: ControlServer;
107
+ #cursor = 0;
108
+ #leaseEpoch = 0;
109
+ #heartbeatTimer: ReturnType<typeof setInterval> | null = null;
110
+ #socketPath: string;
111
+ #finalizeChecks?: FinalizeChecks;
112
+ #validationCommands?: ValidationCommandSpec[];
113
+ #unsubscribeFrames: (() => void) | null = null;
114
+ #framePump: Promise<void> = Promise.resolve();
115
+ #coalesced = new Map<string, true>();
116
+
117
+ constructor(opts: OwnerOptions) {
118
+ this.ownerId = opts.ownerId ?? `owner-${randomUUID()}`;
119
+ this.#socketPath = controlSocketPath(opts.root, opts.sessionId);
120
+ this.#opts = {
121
+ root: opts.root,
122
+ sessionId: opts.sessionId,
123
+ rpc: opts.rpc,
124
+ ownerId: this.ownerId,
125
+ ttlMs: opts.ttlMs ?? DEFAULT_TTL_MS,
126
+ heartbeatMs: opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
127
+ acceptanceTimeoutMs: opts.acceptanceTimeoutMs ?? DEFAULT_ACCEPT_TIMEOUT_MS,
128
+ clock: opts.clock,
129
+ };
130
+ this.#finalizeChecks = opts.finalizeChecks;
131
+ this.#validationCommands = opts.validationCommands;
132
+ this.#server = new ControlServer(this.#socketPath, req => this.#handle(req));
133
+ }
134
+
135
+ async start(): Promise<OwnerStartInfo> {
136
+ const { root, sessionId } = this.#opts;
137
+ const eventsPath = sessionPaths(root, sessionId).events;
138
+ const existing = await readEvents(root, sessionId, 0);
139
+ this.#cursor = existing.reduce((max, e) => Math.max(max, e.cursor), 0);
140
+ const { lease } = await acquireLease(root, sessionId, {
141
+ ownerId: this.ownerId,
142
+ pid: process.pid,
143
+ endpoint: { kind: "unix-socket", path: this.#socketPath },
144
+ eventsPath,
145
+ ttlMs: this.#opts.ttlMs,
146
+ clock: this.#opts.clock,
147
+ });
148
+ this.#leaseEpoch = lease.leaseEpoch;
149
+ await this.#server.listen();
150
+ await this.#emit("info", "owner_started", { ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch });
151
+ if (this.#opts.rpc.onEventFrame) {
152
+ this.#unsubscribeFrames = this.#opts.rpc.onEventFrame(frame => this.#handleFrame(frame));
153
+ }
154
+ this.#heartbeatTimer = setInterval(() => {
155
+ void heartbeat(root, sessionId, this.ownerId, this.#opts.ttlMs, this.#opts.clock).catch(err => {
156
+ // Self-stop if a legitimate dead-owner takeover revoked our lease.
157
+ if (err instanceof Error && err.message.includes("not_lease_holder")) void this.stop();
158
+ });
159
+ }, this.#opts.heartbeatMs);
160
+ this.#heartbeatTimer.unref?.();
161
+ return { ownerId: this.ownerId, socketPath: this.#socketPath, leaseEpoch: this.#leaseEpoch };
162
+ }
163
+
164
+ async #loadState(): Promise<SessionState> {
165
+ const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
166
+ if (!state) throw new Error(`session_not_found:${this.#opts.sessionId}`);
167
+ const reconciled = reconcileLiveOwnerState(state);
168
+ if (reconciled.reconciled) {
169
+ await writeSessionState(this.#opts.root, reconciled.state);
170
+ return reconciled.state;
171
+ }
172
+ return state;
173
+ }
174
+
175
+ /** Map an RPC frame and route it: semantic/signal-bearing -> serial emit; high-frequency progress -> coalesce. */
176
+ #handleFrame(frame: Record<string, unknown>): void {
177
+ const mapped = mapRpcFrame(frame);
178
+ if (!mapped) return;
179
+ if (mapped.semantic || (mapped.signal && !mapped.coalesceKey)) {
180
+ this.#framePump = this.#framePump
181
+ .then(() => this.#flushCoalesced())
182
+ .then(() => this.#emitMapped(mapped))
183
+ .catch(() => {});
184
+ } else if (mapped.coalesceKey) {
185
+ // Coalesce progress-noise by key; never enqueues a per-frame emit, so a message_update
186
+ // storm cannot starve semantic frames. Bound memory.
187
+ this.#coalesced.set(mapped.coalesceKey, true);
188
+ if (this.#coalesced.size > 256) {
189
+ const oldest = this.#coalesced.keys().next().value;
190
+ if (oldest !== undefined) this.#coalesced.delete(oldest);
191
+ }
192
+ }
193
+ }
194
+
195
+ async #flushCoalesced(): Promise<void> {
196
+ if (this.#coalesced.size === 0) return;
197
+ const coalescedFrames = this.#coalesced.size;
198
+ this.#coalesced.clear();
199
+ await this.#emit("info", "rpc_activity", { coalescedFrames });
200
+ }
201
+
202
+ async #emitMapped(mapped: NonNullable<ReturnType<typeof mapRpcFrame>>): Promise<void> {
203
+ await this.#emit(
204
+ mapped.severity,
205
+ mapped.kind,
206
+ mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
207
+ );
208
+ if (mapped.kind === "rpc_agent_completed") {
209
+ const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
210
+ if (
211
+ state &&
212
+ state.lifecycle !== "completed" &&
213
+ state.lifecycle !== "retired" &&
214
+ state.lifecycle !== "finalizing"
215
+ ) {
216
+ state.lifecycle = "finalizing";
217
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
218
+ await writeSessionState(this.#opts.root, state);
219
+ }
220
+ }
221
+ }
222
+
223
+ #aggregateSignals(events: EventEnvelope[]): string[] {
224
+ const out: string[] = [];
225
+ const vocab = OBSERVED_SIGNALS as readonly string[];
226
+ const add = (s: unknown): void => {
227
+ if (typeof s === "string" && vocab.includes(s) && !out.includes(s)) out.push(s);
228
+ };
229
+ for (const e of events) {
230
+ add((e.evidence as { signal?: unknown } | undefined)?.signal);
231
+ if (e.kind === "prompt_accepted") add("prompt-accepted");
232
+ }
233
+ return out;
234
+ }
235
+
236
+ async #emit(severity: Severity, kind: string, evidence: Record<string, unknown>): Promise<void> {
237
+ const lease = await readLease(this.#opts.root, this.#opts.sessionId);
238
+ // Single-writer guard: only emit while we still hold a live lease.
239
+ if (!lease || !canWriteEvents(lease, this.ownerId, this.#opts.clock)) return;
240
+ const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
241
+ const view = state
242
+ ? buildStateView(state, true)
243
+ : {
244
+ sessionId: this.#opts.sessionId,
245
+ lifecycle: "started" as const,
246
+ harness: "gajae-code" as const,
247
+ ownerLive: true,
248
+ blockers: [],
249
+ };
250
+ const envelope: EventEnvelope = {
251
+ eventId: randomUUID(),
252
+ cursor: ++this.#cursor,
253
+ createdAt: new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString(),
254
+ severity,
255
+ kind,
256
+ state: view,
257
+ evidence,
258
+ nextAllowedActions: nextAllowedActions(view.lifecycle, true),
259
+ writer: { ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch },
260
+ };
261
+ await appendEvent(this.#opts.root, this.#opts.sessionId, envelope);
262
+ }
263
+
264
+ #response(state: SessionState, evidence: Record<string, unknown>, ok = true): PrimitiveResponse {
265
+ return {
266
+ ok,
267
+ state: buildStateView(state, true),
268
+ evidence,
269
+ nextAllowedActions: nextAllowedActions(state.lifecycle, true),
270
+ };
271
+ }
272
+
273
+ async #handle(req: EndpointRequest): Promise<unknown> {
274
+ switch (req.verb) {
275
+ case "ping":
276
+ return { ok: true, ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch };
277
+ case "submit":
278
+ return this.#submit(req.input);
279
+ case "observe":
280
+ return this.#observe();
281
+ case "retire":
282
+ return this.#retire();
283
+ case "finalize":
284
+ return this.#finalize(req.input);
285
+ case "recover":
286
+ return this.#recover();
287
+ case "validate":
288
+ return this.#validate();
289
+ case "operate":
290
+ return this.#operate(req.input);
291
+ default:
292
+ return { ok: false, error: `owner_unsupported_verb:${req.verb}` };
293
+ }
294
+ }
295
+
296
+ async #observeGit(): Promise<Observation> {
297
+ const state = await this.#loadState();
298
+ const workspace = state.handle.workspace;
299
+ let streaming = false;
300
+ try {
301
+ streaming = (await this.#opts.rpc.getState()).isStreaming;
302
+ } catch {
303
+ streaming = false;
304
+ }
305
+ let gitDelta: GitDelta = "unknown";
306
+ let branch = state.handle.branch;
307
+ let deleted = false;
308
+ if (!existsSync(workspace)) {
309
+ deleted = true;
310
+ } else {
311
+ try {
312
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
313
+ cwd: workspace,
314
+ encoding: "utf8",
315
+ stdio: ["ignore", "pipe", "ignore"],
316
+ }).trim();
317
+ } catch {
318
+ // keep prior branch
319
+ }
320
+ try {
321
+ const porcelain = execFileSync("git", ["status", "--porcelain"], {
322
+ cwd: workspace,
323
+ encoding: "utf8",
324
+ stdio: ["ignore", "pipe", "ignore"],
325
+ });
326
+ gitDelta = porcelain.trim().length > 0 ? "dirty" : "clean";
327
+ } catch {
328
+ gitDelta = "unknown";
329
+ }
330
+ }
331
+ const rpcLive = this.#opts.rpc.isLive
332
+ ? this.#opts.rpc.isLive()
333
+ : await this.#opts.rpc
334
+ .getState()
335
+ .then(() => true)
336
+ .catch(() => false);
337
+ const rpcLastFrameAt = this.#opts.rpc.lastFrameAt ? this.#opts.rpc.lastFrameAt() : null;
338
+ // Sticky semantic signals come from the persisted owner event log -> survive polling gaps.
339
+ const recent = (await readEvents(this.#opts.root, this.#opts.sessionId, 0)).slice(-200);
340
+ const observedSignals = this.#aggregateSignals(recent).slice(0, 7);
341
+ observedSignals.push(streaming ? "streaming" : "idle");
342
+ const stamps = [state.updatedAt, rpcLastFrameAt, recent.at(-1)?.createdAt].filter(
343
+ (t): t is string => typeof t === "string",
344
+ );
345
+ const lastActivityAt = stamps.length > 0 ? (stamps.sort().at(-1) ?? state.updatedAt) : state.updatedAt;
346
+ return {
347
+ lifecycle: state.lifecycle,
348
+ ownerLive: true,
349
+ cwd: workspace,
350
+ branch,
351
+ gitDelta,
352
+ lastActivityAt,
353
+ observedSignals,
354
+ risk: deleted ? "deleted-worktree" : "normal",
355
+ rpcLive,
356
+ rpcLastFrameAt,
357
+ };
358
+ }
359
+
360
+ async #validate(): Promise<PrimitiveResponse> {
361
+ const state = await this.#loadState();
362
+ const checks = this.#finalizeChecks ?? defaultFinalizeChecks(state.handle.workspace);
363
+ const commit = await checks.resolveCommit();
364
+ const subject: ReceiptSubject = {
365
+ workspace: state.handle.workspace,
366
+ branch: state.handle.branch,
367
+ head: commit,
368
+ commit,
369
+ };
370
+ const validation: { name: string; valid: boolean; exitStatus: number }[] = [];
371
+ for (const spec of this.#validationCommands ?? []) {
372
+ const run = await checks.runValidation(spec);
373
+ const evidence: ValidationEvidence = {
374
+ command: spec.name,
375
+ exactCommand: run.exactCommand,
376
+ cwd: run.cwd,
377
+ exitStatus: run.exitStatus,
378
+ pass: run.pass,
379
+ commitUnderTest: commit,
380
+ };
381
+ const receipt = buildReceipt<ValidationEvidence>({
382
+ receiptId: `val-${Date.now()}-${randomBytes(4).toString("hex")}`,
383
+ sessionId: this.#opts.sessionId,
384
+ family: "validation",
385
+ source: "owner",
386
+ subject,
387
+ evidence,
388
+ valid: run.pass,
389
+ });
390
+ await writeReceiptImmutable(this.#opts.root, this.#opts.sessionId, "validation", receipt.receiptId, receipt);
391
+ validation.push({ name: spec.name, valid: validateReceipt(receipt).valid, exitStatus: run.exitStatus });
392
+ }
393
+ state.lifecycle = "validating";
394
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
395
+ await writeSessionState(this.#opts.root, state);
396
+ await this.#emit("info", "validated", { count: validation.length });
397
+ return this.#response(state, { validation });
398
+ }
399
+
400
+ async #recover(): Promise<PrimitiveResponse> {
401
+ const obs = await this.#observeGit();
402
+ const state = await this.#loadState();
403
+ const recoveringPriorVanish = state.blockers.some(isOwnerVanishedBlocker);
404
+ const recoveryObservation: Observation = recoveringPriorVanish
405
+ ? { ...obs, ownerLive: false, risk: obs.gitDelta === "dirty" ? "vanished-dirty" : obs.risk }
406
+ : obs;
407
+ const decision = classifyRecovery({ observation: recoveryObservation, retryBudget: { ...DEFAULT_RETRY_BUDGET } });
408
+ let vanishReceiptId: string | null = null;
409
+ if (requiresVanishBeforeAction(decision.classification)) {
410
+ const dirty = recoveryObservation.gitDelta === "dirty" || recoveryObservation.gitDelta === "unknown";
411
+ const p = dirty ? preserveDirtyWorktree(recoveryObservation.cwd) : null;
412
+ const evidence: VanishEvidence = {
413
+ classification: decision.classification,
414
+ gitDelta: recoveryObservation.gitDelta,
415
+ gitStatusPorcelain: p
416
+ ? `tracked:${p.trackedDiffSha256};untracked:${p.untrackedManifest.length}`
417
+ : recoveryObservation.observedSignals.join(","),
418
+ untrackedManifest: p?.untrackedManifest ?? [],
419
+ preservation: p?.stashRef ? "stash" : "snapshot",
420
+ stashRef: p?.stashRef ?? null,
421
+ snapshotComplete: p?.snapshotComplete ?? true,
422
+ forbiddenActions: dirty ? ["restart-clean", "delete", "reset"] : [],
423
+ };
424
+ const receipt = buildReceipt<VanishEvidence>({
425
+ receiptId: `vanish-${Date.now()}-${randomBytes(4).toString("hex")}`,
426
+ sessionId: this.#opts.sessionId,
427
+ family: "vanish",
428
+ source: "owner",
429
+ subject: {
430
+ workspace: recoveryObservation.cwd,
431
+ branch: recoveryObservation.branch,
432
+ head: null,
433
+ commit: null,
434
+ },
435
+ evidence,
436
+ });
437
+ await writeReceiptImmutable(this.#opts.root, this.#opts.sessionId, "vanish", receipt.receiptId, receipt);
438
+ vanishReceiptId = receipt.receiptId;
439
+ }
440
+ if (vanishReceiptId) {
441
+ state.blockers = state.blockers.filter(blocker => !isOwnerVanishedBlocker(blocker));
442
+ state.lifecycle = state.blockers.length === 0 ? "observing" : state.lifecycle;
443
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
444
+ await writeSessionState(this.#opts.root, state);
445
+ }
446
+ await this.#emit(decision.severity, "recover_classified", { classification: decision.classification });
447
+ return this.#response(state, { decision, observation: recoveryObservation, vanishReceiptId });
448
+ }
449
+
450
+ async #operate(input: Record<string, unknown>): Promise<PrimitiveResponse> {
451
+ const goal = typeof input.goal === "string" ? input.goal : "";
452
+ let state = await this.#loadState();
453
+ if (!goal) return this.#response(state, { error: "empty-goal" }, false);
454
+ const emitOperateEvent = async (
455
+ severity: Severity,
456
+ kind: string,
457
+ evidence: Record<string, unknown>,
458
+ ): Promise<void> => {
459
+ if (kind === "operate_blocked" || kind === "operate_finalized") {
460
+ const terminalState = await this.#loadState();
461
+ terminalState.lifecycle =
462
+ kind === "operate_finalized" && evidence.completed === true ? "completed" : "blocked";
463
+ terminalState.blockers = Array.isArray(evidence.blockers)
464
+ ? evidence.blockers.filter((blocker): blocker is string => typeof blocker === "string")
465
+ : terminalState.lifecycle === "completed"
466
+ ? []
467
+ : terminalState.blockers;
468
+ terminalState.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
469
+ await writeSessionState(this.#opts.root, terminalState);
470
+ }
471
+ await this.#emit(severity, kind, evidence);
472
+ };
473
+ const result: OperateResult = await operate(goal, {
474
+ root: this.#opts.root,
475
+ sessionId: this.#opts.sessionId,
476
+ workspace: state.handle.workspace,
477
+ branch: state.handle.branch ?? "",
478
+ rpc: this.#opts.rpc,
479
+ observe: () => this.#observeGit(),
480
+ finalizeChecks: this.#finalizeChecks ?? defaultFinalizeChecks(state.handle.workspace),
481
+ validationCommands: this.#validationCommands,
482
+ maxIterations: typeof input.maxIterations === "number" ? input.maxIterations : 5,
483
+ emit: emitOperateEvent,
484
+ });
485
+ // Persist the loop's terminal lifecycle/blockers so the response state is not stale.
486
+ state = await this.#loadState();
487
+ state.lifecycle = result.lifecycle;
488
+ state.blockers = result.blockers;
489
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
490
+ await writeSessionState(this.#opts.root, state);
491
+ return this.#response(state, { operate: result }, result.completed);
492
+ }
493
+
494
+ async #finalize(input: Record<string, unknown>): Promise<PrimitiveResponse> {
495
+ const state = await this.#loadState();
496
+ const workspace = state.handle.workspace;
497
+ const checks = this.#finalizeChecks ?? defaultFinalizeChecks(workspace);
498
+ const fin = await runFinalize({
499
+ root: this.#opts.root,
500
+ sessionId: this.#opts.sessionId,
501
+ workspace,
502
+ branch: state.handle.branch ?? "",
503
+ requireTests: input.requireTests !== false,
504
+ requireCommit: input.requireCommit !== false,
505
+ requirePr: input.requirePr !== false,
506
+ validationCommands: this.#validationCommands,
507
+ checks,
508
+ clock: this.#opts.clock,
509
+ });
510
+ state.lifecycle = fin.completed ? "completed" : "blocked";
511
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
512
+ if (!fin.completed) state.blockers = fin.blockers;
513
+ await writeSessionState(this.#opts.root, state);
514
+ await this.#emit(fin.completed ? "info" : "critical", "finalized", {
515
+ completed: fin.completed,
516
+ blockers: fin.blockers,
517
+ });
518
+ return this.#response(state, { finalize: fin }, fin.completed);
519
+ }
520
+
521
+ async #submit(input: Record<string, unknown>): Promise<PrimitiveResponse> {
522
+ const prompt = typeof input.prompt === "string" ? input.prompt : "";
523
+ const state = await this.#loadState();
524
+ if (!prompt) {
525
+ return this.#response(state, { accepted: false, reason: "empty-prompt" }, false);
526
+ }
527
+ if (state.lifecycle === "blocked") {
528
+ return this.#response(state, { accepted: false, reason: "lifecycle-blocked" }, false);
529
+ }
530
+ const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
531
+ if (result.accepted) {
532
+ state.lifecycle = "observing";
533
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
534
+ await writeSessionState(this.#opts.root, state);
535
+ await this.#emit("info", "prompt_accepted", {
536
+ reason: result.reason,
537
+ agentStartCursor: result.agentStartCursor,
538
+ });
539
+ } else {
540
+ await this.#emit("warn", "prompt_not_accepted", { reason: result.reason });
541
+ }
542
+ return this.#response(
543
+ state,
544
+ {
545
+ accepted: result.accepted,
546
+ submitted: true,
547
+ reason: result.reason,
548
+ commandId: result.commandId,
549
+ preSubmitCursor: result.preSubmitCursor,
550
+ agentStartCursor: result.agentStartCursor,
551
+ acceptanceEvidence: result.preSubmitState,
552
+ },
553
+ result.accepted,
554
+ );
555
+ }
556
+
557
+ async #observe(): Promise<PrimitiveResponse> {
558
+ const state = await this.#loadState();
559
+ return this.#response(state, { observation: await this.#observeGit(), ownerRouted: true });
560
+ }
561
+
562
+ async #retire(): Promise<PrimitiveResponse> {
563
+ const state = await this.#loadState();
564
+ state.lifecycle = "retired";
565
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
566
+ await writeSessionState(this.#opts.root, state);
567
+ await this.#emit("info", "owner_retired", {});
568
+ queueMicrotask(() => void this.stop());
569
+ return this.#response(state, { retired: true });
570
+ }
571
+
572
+ async stop(): Promise<void> {
573
+ this.#unsubscribeFrames?.();
574
+ this.#unsubscribeFrames = null;
575
+ await this.#framePump.catch(() => {});
576
+ if (this.#heartbeatTimer) {
577
+ clearInterval(this.#heartbeatTimer);
578
+ this.#heartbeatTimer = null;
579
+ }
580
+ await this.#server.close().catch(() => {});
581
+ await this.#opts.rpc.close().catch(() => {});
582
+ await releaseLease(this.#opts.root, this.#opts.sessionId, this.ownerId).catch(() => {});
583
+ }
584
+ }
585
+
586
+ export interface ResolvedOwner {
587
+ live: boolean;
588
+ socketPath: string | null;
589
+ lease: SessionLease | null;
590
+ }
591
+
592
+ /** Determine whether a live owner currently holds the session (for CLI routing). */
593
+ export async function resolveOwner(root: string, sessionId: string): Promise<ResolvedOwner> {
594
+ const lease = await readLease(root, sessionId);
595
+ if (!lease) return { live: false, socketPath: null, lease: null };
596
+ const status = classifyLeaseStatus(lease);
597
+ // Owner process alive (live / lease-expired-but-alive / EPERM-alive) => endpoint reachable => routable.
598
+ const live = status === "live" || status === "expiredAlive" || status === "epermAlive";
599
+ return { live, socketPath: lease.endpoint?.path ?? null, lease };
600
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Real dirty-worktree preservation (architect blocker B2).
3
+ *
4
+ * Before any destructive recovery on a dirty/unknown worktree, capture REAL evidence and a
5
+ * recoverable snapshot WITHOUT mutating the working tree:
6
+ * - the tracked diff (`git diff HEAD`) + its sha256,
7
+ * - an untracked-file manifest (path/size/sha256),
8
+ * - a `git stash create` commit object stored in the stash list (`git stash store`),
9
+ * which snapshots tracked+staged changes without touching the worktree.
10
+ *
11
+ * This is what backs a valid `vanish` receipt so the data-loss invariant is enforced in
12
+ * practice, not just structurally.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { readFileSync } from "node:fs";
17
+ import * as path from "node:path";
18
+ import type { GitDelta } from "./types";
19
+
20
+ export interface UntrackedEntry {
21
+ path: string;
22
+ size: number;
23
+ sha256: string;
24
+ }
25
+
26
+ export interface PreserveResult {
27
+ gitDelta: GitDelta;
28
+ trackedDiff: string;
29
+ trackedDiffSha256: string;
30
+ untrackedManifest: UntrackedEntry[];
31
+ stashRef: string | null;
32
+ snapshotComplete: boolean;
33
+ }
34
+
35
+ function git(workspace: string, args: string[]): string {
36
+ return execFileSync("git", args, { cwd: workspace, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
37
+ }
38
+
39
+ function sha256(input: string | Buffer): string {
40
+ return createHash("sha256").update(input).digest("hex");
41
+ }
42
+
43
+ /**
44
+ * Capture + snapshot a (possibly dirty) worktree without mutating it. Safe to call on a clean
45
+ * tree (returns empty evidence). Never deletes, resets, or cleans.
46
+ */
47
+ export function preserveDirtyWorktree(workspace: string): PreserveResult {
48
+ let trackedDiff = "";
49
+ try {
50
+ trackedDiff = git(workspace, ["diff", "HEAD"]);
51
+ } catch {
52
+ trackedDiff = "";
53
+ }
54
+
55
+ let untracked: string[] = [];
56
+ try {
57
+ untracked = git(workspace, ["ls-files", "--others", "--exclude-standard"])
58
+ .split("\n")
59
+ .map(s => s.trim())
60
+ .filter(Boolean);
61
+ } catch {
62
+ untracked = [];
63
+ }
64
+
65
+ const untrackedManifest: UntrackedEntry[] = [];
66
+ for (const rel of untracked) {
67
+ try {
68
+ const buf = readFileSync(path.join(workspace, rel));
69
+ untrackedManifest.push({ path: rel, size: buf.length, sha256: sha256(buf) });
70
+ } catch {
71
+ // unreadable entry — record path with a marker rather than dropping it
72
+ untrackedManifest.push({ path: rel, size: -1, sha256: "unreadable" });
73
+ }
74
+ }
75
+
76
+ // `git stash create` builds a stash commit WITHOUT modifying the working tree; store it so
77
+ // it survives in the stash list as a recoverable ref. No-op (empty oid) on a clean tree.
78
+ let stashRef: string | null = null;
79
+ try {
80
+ const oid = git(workspace, ["stash", "create", "harness-vanish-snapshot"]).trim();
81
+ if (oid) {
82
+ git(workspace, ["stash", "store", "-m", "harness-vanish-snapshot", oid]);
83
+ stashRef = oid;
84
+ }
85
+ } catch {
86
+ stashRef = null;
87
+ }
88
+
89
+ const dirty = trackedDiff.trim().length > 0 || untrackedManifest.length > 0;
90
+ // snapshotComplete iff every dirty component is actually captured: tracked changes need a
91
+ // stash ref, untracked entries need readable hashes.
92
+ const trackedCaptured = trackedDiff.trim().length === 0 || stashRef !== null;
93
+ const untrackedCaptured = untrackedManifest.every(e => e.sha256 !== "unreadable");
94
+ return {
95
+ gitDelta: dirty ? "dirty" : "clean",
96
+ trackedDiff,
97
+ trackedDiffSha256: sha256(trackedDiff),
98
+ untrackedManifest,
99
+ stashRef,
100
+ snapshotComplete: trackedCaptured && untrackedCaptured,
101
+ };
102
+ }