@gajae-code/coding-agent 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/types/async/job-manager.d.ts +84 -2
  3. package/dist/types/commands/harness.d.ts +37 -0
  4. package/dist/types/config/settings-schema.d.ts +6 -0
  5. package/dist/types/config/settings.d.ts +2 -0
  6. package/dist/types/deep-interview/render-middleware.d.ts +5 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -0
  10. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  11. package/dist/types/gjc-runtime/state-migrations.d.ts +24 -0
  12. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  13. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  14. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +137 -0
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  17. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  18. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  19. package/dist/types/harness-control-plane/control-endpoint.d.ts +30 -0
  20. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  21. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  22. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  23. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  24. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  25. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  26. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  27. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  28. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  29. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  30. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  31. package/dist/types/harness-control-plane/types.d.ts +162 -0
  32. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  33. package/dist/types/hooks/skill-state.d.ts +2 -29
  34. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  35. package/dist/types/modes/interactive-mode.d.ts +1 -0
  36. package/dist/types/modes/types.d.ts +1 -0
  37. package/dist/types/sdk.d.ts +2 -0
  38. package/dist/types/session/agent-session.d.ts +8 -0
  39. package/dist/types/skill-state/active-state.d.ts +2 -0
  40. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  41. package/dist/types/skill-state/workflow-state-contract.d.ts +24 -0
  42. package/dist/types/task/executor.d.ts +3 -0
  43. package/dist/types/task/types.d.ts +55 -3
  44. package/dist/types/tools/subagent.d.ts +11 -1
  45. package/package.json +7 -7
  46. package/src/async/job-manager.ts +298 -6
  47. package/src/cli/auth-broker-cli.ts +1 -0
  48. package/src/cli/config-cli.ts +10 -2
  49. package/src/cli.ts +2 -0
  50. package/src/commands/harness.ts +592 -0
  51. package/src/commands/team.ts +36 -39
  52. package/src/config/settings-schema.ts +7 -0
  53. package/src/config/settings.ts +5 -0
  54. package/src/deep-interview/render-middleware.ts +366 -0
  55. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  56. package/src/defaults/gjc/skills/ultragoal/SKILL.md +78 -11
  57. package/src/extensibility/custom-tools/types.ts +1 -0
  58. package/src/extensibility/extensions/types.ts +6 -0
  59. package/src/extensibility/shared-events.ts +1 -0
  60. package/src/gjc-runtime/deep-interview-runtime.ts +40 -21
  61. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  62. package/src/gjc-runtime/ralplan-runtime.ts +25 -10
  63. package/src/gjc-runtime/state-graph.ts +86 -0
  64. package/src/gjc-runtime/state-migrations.ts +132 -0
  65. package/src/gjc-runtime/state-renderer.ts +345 -0
  66. package/src/gjc-runtime/state-runtime.ts +733 -21
  67. package/src/gjc-runtime/state-validation.ts +49 -0
  68. package/src/gjc-runtime/state-writer.ts +718 -0
  69. package/src/gjc-runtime/team-runtime.ts +1083 -89
  70. package/src/gjc-runtime/ultragoal-runtime.ts +348 -19
  71. package/src/gjc-runtime/workflow-manifest.generated.json +1497 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +425 -0
  73. package/src/harness-control-plane/classifier.ts +128 -0
  74. package/src/harness-control-plane/control-endpoint.ts +137 -0
  75. package/src/harness-control-plane/finalize.ts +222 -0
  76. package/src/harness-control-plane/frame-mapper.ts +286 -0
  77. package/src/harness-control-plane/operate.ts +225 -0
  78. package/src/harness-control-plane/owner.ts +553 -0
  79. package/src/harness-control-plane/preserve.ts +102 -0
  80. package/src/harness-control-plane/receipts.ts +216 -0
  81. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  82. package/src/harness-control-plane/seams.ts +39 -0
  83. package/src/harness-control-plane/session-lease.ts +388 -0
  84. package/src/harness-control-plane/state-machine.ts +97 -0
  85. package/src/harness-control-plane/storage.ts +257 -0
  86. package/src/harness-control-plane/types.ts +214 -0
  87. package/src/hooks/skill-keywords.ts +4 -2
  88. package/src/hooks/skill-state.ts +24 -41
  89. package/src/internal-urls/docs-index.generated.ts +1 -1
  90. package/src/modes/components/assistant-message.ts +5 -1
  91. package/src/modes/components/hook-selector.ts +72 -2
  92. package/src/modes/controllers/event-controller.ts +71 -6
  93. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  94. package/src/modes/controllers/input-controller.ts +9 -1
  95. package/src/modes/controllers/selector-controller.ts +2 -1
  96. package/src/modes/interactive-mode.ts +1 -0
  97. package/src/modes/types.ts +1 -0
  98. package/src/prompts/agents/executor.md +13 -0
  99. package/src/prompts/tools/subagent.md +33 -3
  100. package/src/sdk.ts +4 -0
  101. package/src/session/agent-session.ts +231 -33
  102. package/src/session/session-manager.ts +13 -1
  103. package/src/skill-state/active-state.ts +58 -65
  104. package/src/skill-state/deep-interview-mutation-guard.ts +91 -13
  105. package/src/skill-state/initial-phase.ts +2 -0
  106. package/src/skill-state/workflow-state-contract.ts +26 -0
  107. package/src/task/executor.ts +50 -8
  108. package/src/task/index.ts +120 -8
  109. package/src/task/render.ts +6 -3
  110. package/src/task/types.ts +56 -3
  111. package/src/tools/ask.ts +28 -7
  112. package/src/tools/subagent.ts +255 -64
@@ -0,0 +1,592 @@
1
+ /**
2
+ * `gjc harness <verb>` — AI-native stateless JSON CLI for the coding-harness
3
+ * operations control plane (v1, gajae-code adapter).
4
+ *
5
+ * Every verb emits the universal contract `{ ok, state, evidence, nextAllowedActions }`.
6
+ * Foundation milestone (M1/M2) implements: start, observe, classify, events, retire,
7
+ * and the spec-required `owner-not-live` blocking for submit. Owner-runtime verbs
8
+ * (recover/validate/finalize/operate) return an honest `pending-<milestone>` contract
9
+ * until the RuntimeOwner (M3+) lands.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { Args, Command, Flags } from "@gajae-code/utils/cli";
14
+ import { resolveGjcTmuxCommand, sanitizeTmuxToken } from "../gjc-runtime/tmux-common";
15
+ import { classifyRecovery } from "../harness-control-plane/classifier";
16
+ import { callEndpoint, EndpointUnreachableError } from "../harness-control-plane/control-endpoint";
17
+ import { RuntimeOwner, resolveOwner } from "../harness-control-plane/owner";
18
+ import { GajaeCodeRpc } from "../harness-control-plane/rpc-adapter";
19
+ import { buildResponse, buildStateView } from "../harness-control-plane/state-machine";
20
+ import {
21
+ generateSessionId,
22
+ readEvents,
23
+ readSessionState,
24
+ resolveHarnessRoot,
25
+ sessionPaths,
26
+ writeSessionState,
27
+ } from "../harness-control-plane/storage";
28
+ import {
29
+ DEFAULT_RETRY_BUDGET,
30
+ type GitDelta,
31
+ type Harness as HarnessKind,
32
+ type Observation,
33
+ type RetryBudget,
34
+ SESSION_SCHEMA_VERSION,
35
+ type SessionHandle,
36
+ type SessionState,
37
+ } from "../harness-control-plane/types";
38
+
39
+ function writeJson(value: unknown): void {
40
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
41
+ }
42
+
43
+ function nowIso(): string {
44
+ return new Date().toISOString();
45
+ }
46
+
47
+ function parseInput(raw: string | undefined): Record<string, unknown> {
48
+ if (!raw?.trim()) return {};
49
+ const parsed = JSON.parse(raw) as unknown;
50
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
51
+ throw new Error("input_must_be_json_object");
52
+ }
53
+ return parsed as Record<string, unknown>;
54
+ }
55
+
56
+ function gitDeltaFor(workspace: string): { gitDelta: GitDelta; branch: string | null; deleted: boolean } {
57
+ if (!existsSync(workspace)) return { gitDelta: "unknown", branch: null, deleted: true };
58
+ let branch: string | null = null;
59
+ try {
60
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
61
+ cwd: workspace,
62
+ encoding: "utf8",
63
+ stdio: ["ignore", "pipe", "ignore"],
64
+ }).trim();
65
+ } catch {
66
+ branch = null;
67
+ }
68
+ try {
69
+ const porcelain = execFileSync("git", ["status", "--porcelain"], {
70
+ cwd: workspace,
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ });
74
+ return { gitDelta: porcelain.trim().length > 0 ? "dirty" : "clean", branch, deleted: false };
75
+ } catch {
76
+ return { gitDelta: "unknown", branch, deleted: false };
77
+ }
78
+ }
79
+
80
+ /** Owner liveness — always false in the foundation build (RuntimeOwner is M3). */
81
+ function ownerLiveFor(_state: SessionState): boolean {
82
+ return false;
83
+ }
84
+
85
+ function buildObservation(state: SessionState, ownerLive: boolean): Observation {
86
+ const workspace = state.handle.workspace;
87
+ const { gitDelta, branch, deleted } = gitDeltaFor(workspace);
88
+ return {
89
+ lifecycle: state.lifecycle,
90
+ ownerLive,
91
+ cwd: workspace,
92
+ branch: branch ?? state.handle.branch,
93
+ gitDelta,
94
+ lastActivityAt: state.updatedAt,
95
+ observedSignals: ["SessionStart"],
96
+ risk: deleted ? "deleted-worktree" : "normal",
97
+ };
98
+ }
99
+
100
+ function resolveRetryBudget(input: Record<string, unknown>): RetryBudget {
101
+ const supplied = input.retryBudget;
102
+ if (supplied && typeof supplied === "object" && !Array.isArray(supplied)) {
103
+ return { ...DEFAULT_RETRY_BUDGET, ...(supplied as Partial<RetryBudget>) };
104
+ }
105
+ return { ...DEFAULT_RETRY_BUDGET };
106
+ }
107
+
108
+ interface OwnerSpawnResult {
109
+ live: boolean;
110
+ runtime: "tmux" | "detached" | "manual";
111
+ tmuxSessionName: string | null;
112
+ fallbackReason: string | null;
113
+ blockerReason: string | null;
114
+ }
115
+
116
+ function shellQuote(value: string): string {
117
+ return `'${value.replaceAll("'", "'\\''")}'`;
118
+ }
119
+
120
+ function deterministicHarnessTmuxSessionName(sessionId: string): string {
121
+ return `gajae_code_harness_${sanitizeTmuxToken(sessionId)}`;
122
+ }
123
+
124
+ async function loadState(root: string, sessionId: string): Promise<SessionState> {
125
+ const state = await readSessionState(root, sessionId);
126
+ if (!state) throw new Error(`session_not_found:${sessionId}`);
127
+ return state;
128
+ }
129
+
130
+ function requireSessionId(input: Record<string, unknown>, flagSession: string | undefined): string {
131
+ const id = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
132
+ if (!id) throw new Error("missing_session_id");
133
+ return id;
134
+ }
135
+
136
+ export default class Harness extends Command {
137
+ static description = "Operate coding harnesses (v1: gajae-code) as a session/evidence/recovery/PR control plane";
138
+ static strict = false;
139
+
140
+ static args = {
141
+ verb: Args.string({
142
+ description: "start|submit|observe|classify|recover|validate|finalize|retire|events|monitor|operate",
143
+ required: true,
144
+ }),
145
+ };
146
+
147
+ static flags = {
148
+ input: Flags.string({ description: "JSON object input for the verb", default: "" }),
149
+ session: Flags.string({ char: "s", description: "Session id (re-grab a session)" }),
150
+ cursor: Flags.string({ description: "Event cursor for events --follow (exclusive)", default: "0" }),
151
+ follow: Flags.boolean({ description: "Tail the owner-written event log", default: false }),
152
+ json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: true }),
153
+ };
154
+
155
+ static examples = [
156
+ `gjc harness start --input '{"harness":"gajae-code","workspace":".","branch":"feat/x"}'`,
157
+ "gjc harness observe --session <id>",
158
+ `gjc harness classify --input '{"observation":{"ownerLive":false,"gitDelta":"dirty","risk":"vanished-dirty"}}'`,
159
+ "gjc harness events --session <id> --follow",
160
+ ];
161
+
162
+ async run(): Promise<void> {
163
+ const { args, flags } = await this.parse(Harness);
164
+ const verb = String(args.verb);
165
+ const root = resolveHarnessRoot();
166
+ try {
167
+ const input = parseInput(flags.input);
168
+ switch (verb) {
169
+ case "start":
170
+ return await this.#start(root, input);
171
+ case "observe":
172
+ return await this.#observe(root, input, flags.session);
173
+ case "classify":
174
+ return await this.#classify(root, input, flags.session);
175
+ case "submit":
176
+ return await this.#submit(root, input, flags.session);
177
+ case "events":
178
+ case "monitor":
179
+ return await this.#events(root, input, flags.session, Number(flags.cursor) || 0);
180
+ case "retire":
181
+ return await this.#retire(root, input, flags.session);
182
+ case "finalize":
183
+ return await this.#finalizeVerb(root, input, flags.session);
184
+ case "__owner":
185
+ return await this.#runOwner(root, input, flags.session);
186
+ case "recover":
187
+ case "validate":
188
+ case "operate":
189
+ return await this.#ownerVerbOrPending(root, verb, input, flags.session);
190
+ default:
191
+ throw new Error(`unknown_harness_verb:${verb}`);
192
+ }
193
+ } catch (error) {
194
+ writeJson({ ok: false, error: error instanceof Error ? error.message : String(error), verb });
195
+ process.exitCode = 1;
196
+ }
197
+ }
198
+
199
+ async #finalizeVerb(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
200
+ const sessionId = requireSessionId(input, flagSession);
201
+ if (await this.#tryOwnerRoute(root, sessionId, "finalize", { ...input, sessionId })) return;
202
+ // finalize is owner-routed; without a live owner, report owner-not-live (start the owner first).
203
+ const state = await loadState(root, sessionId);
204
+ writeJson(buildResponse(state, false, { completed: false, reason: "owner-not-live" }, false));
205
+ process.exitCode = 1;
206
+ }
207
+
208
+ /** Route an owner-backed verb to the live owner; fall back to a pending response when none. */
209
+ async #ownerVerbOrPending(
210
+ root: string,
211
+ verb: string,
212
+ input: Record<string, unknown>,
213
+ flagSession: string | undefined,
214
+ ): Promise<void> {
215
+ const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
216
+ if (sessionId && (await this.#tryOwnerRoute(root, sessionId, verb, { ...input, sessionId }))) return;
217
+ return this.#pending(root, verb, input, flagSession);
218
+ }
219
+
220
+ /** Detached owner daemon (spawned by `start --detach`). Runs until retired or signalled. */
221
+ async #runOwner(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
222
+ const sessionId = requireSessionId(input, flagSession);
223
+ const sessionDir = sessionPaths(root, sessionId).gjcSessionDir;
224
+ // Optional rpc command override (tests / non-default hosts); defaults to `gjc --mode rpc`.
225
+ const override = process.env.GJC_HARNESS_RPC_COMMAND;
226
+ const command = override ? (JSON.parse(override) as string[]) : undefined;
227
+ const rpc = new GajaeCodeRpc({ sessionDir, command });
228
+ const owner = new RuntimeOwner({ root, sessionId, rpc });
229
+ const info = await owner.start();
230
+ writeJson({ ok: true, owner: info });
231
+ await new Promise<void>(resolve => {
232
+ const stop = (): void => {
233
+ clearInterval(timer);
234
+ resolve();
235
+ };
236
+ const timer = setInterval(async () => {
237
+ const resolved = await resolveOwner(root, sessionId);
238
+ if (!resolved.live) stop();
239
+ }, 500);
240
+ timer.unref?.();
241
+ process.on("SIGTERM", stop);
242
+ process.on("SIGINT", stop);
243
+ });
244
+ await owner.stop();
245
+ process.exit(0);
246
+ }
247
+
248
+ #buildOwnerCommand(sessionId: string): string[] {
249
+ const argv1 = process.argv[1];
250
+ return argv1
251
+ ? [process.execPath, argv1, "harness", "__owner", "--session", sessionId]
252
+ : [process.execPath, "harness", "__owner", "--session", sessionId];
253
+ }
254
+
255
+ async #waitForOwner(root: string, sessionId: string): Promise<boolean> {
256
+ for (let i = 0; i < 100; i++) {
257
+ const owner = await resolveOwner(root, sessionId);
258
+ if (owner.live && owner.socketPath) {
259
+ try {
260
+ await callEndpoint(owner.socketPath, { verb: "observe", input: { sessionId } }, 250);
261
+ return true;
262
+ } catch (error) {
263
+ if (!(error instanceof EndpointUnreachableError)) throw error;
264
+ }
265
+ }
266
+ await new Promise(r => setTimeout(r, 50));
267
+ }
268
+ return false;
269
+ }
270
+
271
+ #startTmuxResidentOwner(
272
+ root: string,
273
+ sessionId: string,
274
+ cwd: string,
275
+ ): { started: boolean; sessionName: string; reason: string | null } {
276
+ const tmuxCommand = resolveGjcTmuxCommand();
277
+ if (Bun.which(tmuxCommand) === null) {
278
+ return {
279
+ started: false,
280
+ sessionName: deterministicHarnessTmuxSessionName(sessionId),
281
+ reason: "tmux-unavailable",
282
+ };
283
+ }
284
+ const sessionName = deterministicHarnessTmuxSessionName(sessionId);
285
+ const envAssignments = [`GJC_HARNESS_STATE_ROOT=${shellQuote(root)}`];
286
+ if (process.env.GJC_HARNESS_RPC_COMMAND) {
287
+ envAssignments.push(`GJC_HARNESS_RPC_COMMAND=${shellQuote(process.env.GJC_HARNESS_RPC_COMMAND)}`);
288
+ }
289
+ const ownerCommand = this.#buildOwnerCommand(sessionId).map(shellQuote).join(" ");
290
+ const shellCommand = `exec env ${envAssignments.join(" ")} ${ownerCommand}`;
291
+ const created = Bun.spawnSync([tmuxCommand, "new-session", "-d", "-s", sessionName, "-c", cwd, shellCommand], {
292
+ stdout: "pipe",
293
+ stderr: "pipe",
294
+ env: process.env,
295
+ });
296
+ if (created.exitCode === 0) return { started: true, sessionName, reason: null };
297
+ const stderr = created.stderr.toString().trim();
298
+ return { started: false, sessionName, reason: stderr || "tmux-start-failed" };
299
+ }
300
+
301
+ /** Spawn the owner daemon. Prefer a tmux-resident owner, then explicitly fall back to detached. */
302
+ async #spawnDetachedOwner(root: string, sessionId: string, cwd: string): Promise<OwnerSpawnResult> {
303
+ const tmux = this.#startTmuxResidentOwner(root, sessionId, cwd);
304
+ if (tmux.started && (await this.#waitForOwner(root, sessionId))) {
305
+ return {
306
+ live: true,
307
+ runtime: "tmux",
308
+ tmuxSessionName: tmux.sessionName,
309
+ fallbackReason: null,
310
+ blockerReason: null,
311
+ };
312
+ }
313
+ const fallbackReason = tmux.started
314
+ ? "tmux new-session exited 0 but owner endpoint did not become routable"
315
+ : tmux.reason;
316
+ const cmd = this.#buildOwnerCommand(sessionId);
317
+ const child = Bun.spawn(cmd, {
318
+ cwd,
319
+ env: { ...process.env, GJC_HARNESS_STATE_ROOT: root },
320
+ stdout: "ignore",
321
+ stderr: "ignore",
322
+ stdin: "ignore",
323
+ });
324
+ child.unref();
325
+ const live = await this.#waitForOwner(root, sessionId);
326
+ return {
327
+ live,
328
+ runtime: "detached",
329
+ tmuxSessionName: null,
330
+ fallbackReason,
331
+ blockerReason: live ? null : "detached-owner-not-live",
332
+ };
333
+ }
334
+
335
+ async #start(root: string, input: Record<string, unknown>): Promise<void> {
336
+ const harness = (typeof input.harness === "string" ? input.harness : "gajae-code") as HarnessKind;
337
+ if (harness !== "gajae-code") {
338
+ writeJson({
339
+ ok: false,
340
+ error: `harness_unsupported_in_v1:${harness}`,
341
+ evidence: { seam: true, supported: ["gajae-code"] },
342
+ });
343
+ process.exitCode = 1;
344
+ return;
345
+ }
346
+ const workspace = typeof input.workspace === "string" ? input.workspace : process.cwd();
347
+ const sessionId = typeof input.sessionId === "string" ? input.sessionId : generateSessionId();
348
+ const eventsPath = `${root}/sessions/${sessionId}/events.jsonl`;
349
+ const leasePath = `${root}/sessions/${sessionId}/lease.json`;
350
+ const startedAt = nowIso();
351
+ const handle: SessionHandle = {
352
+ sessionId,
353
+ harness,
354
+ repo: typeof input.repo === "string" ? input.repo : null,
355
+ workspace,
356
+ branch: typeof input.branch === "string" ? input.branch : null,
357
+ base: typeof input.base === "string" ? input.base : null,
358
+ issueOrPr: typeof input.issueOrPr === "string" ? input.issueOrPr : null,
359
+ processHandle: { kind: "runtime-owner", ownerId: null, pid: null },
360
+ rpcHandle: { kind: "rpc-subprocess", pid: null, sessionDir: `${root}/sessions/${sessionId}/gjc-session` },
361
+ ownerHandle: { leasePath, endpoint: null, heartbeatAt: null },
362
+ routerHandle: { kind: "default-in-owner", policy: "default-fallback", eventsPath },
363
+ viewportHandle: { kind: "event-monitor", tmuxSessionName: null, viewOnly: true },
364
+ startedAt,
365
+ updatedAt: startedAt,
366
+ };
367
+ const state: SessionState = {
368
+ schemaVersion: SESSION_SCHEMA_VERSION,
369
+ sessionId,
370
+ lifecycle: "started",
371
+ harness,
372
+ handle,
373
+ retries: {},
374
+ blockers: [],
375
+ createdAt: startedAt,
376
+ updatedAt: startedAt,
377
+ };
378
+ await writeSessionState(root, state);
379
+ let ownerLive = false;
380
+ let ownerRuntime: OwnerSpawnResult["runtime"] = "manual";
381
+ let ownerFallbackReason: string | null = null;
382
+ let ownerBlockerReason: string | null = null;
383
+ if (input.detach === true) {
384
+ const ownerSpawn = await this.#spawnDetachedOwner(root, sessionId, workspace);
385
+ ownerLive = ownerSpawn.live;
386
+ ownerRuntime = ownerSpawn.runtime;
387
+ ownerFallbackReason = ownerSpawn.fallbackReason;
388
+ ownerBlockerReason = ownerSpawn.blockerReason;
389
+ handle.viewportHandle = {
390
+ kind: "event-monitor",
391
+ tmuxSessionName: ownerSpawn.tmuxSessionName,
392
+ viewOnly: true,
393
+ };
394
+ if (ownerLive) {
395
+ const resolved = await resolveOwner(root, sessionId);
396
+ handle.processHandle = {
397
+ kind: "runtime-owner",
398
+ ownerId: resolved.lease?.ownerId ?? null,
399
+ pid: resolved.lease?.pid ?? null,
400
+ };
401
+ handle.ownerHandle = {
402
+ leasePath,
403
+ endpoint: resolved.socketPath,
404
+ heartbeatAt: resolved.lease?.heartbeatAt ?? null,
405
+ };
406
+ state.handle = handle;
407
+ await writeSessionState(root, state);
408
+ }
409
+ }
410
+ if (ownerBlockerReason) {
411
+ state.lifecycle = "blocked";
412
+ state.blockers = [...state.blockers, ownerBlockerReason];
413
+ state.handle = handle;
414
+ state.updatedAt = nowIso();
415
+ await writeSessionState(root, state);
416
+ }
417
+ writeJson(
418
+ buildResponse(
419
+ state,
420
+ ownerLive,
421
+ {
422
+ handle,
423
+ ownerRuntime,
424
+ ...(ownerFallbackReason ? { ownerFallbackReason } : {}),
425
+ ...(ownerBlockerReason ? { reason: ownerBlockerReason } : {}),
426
+ },
427
+ !ownerBlockerReason,
428
+ ),
429
+ );
430
+ if (ownerBlockerReason) process.exitCode = 1;
431
+ }
432
+
433
+ /** Returns true if a live owner handled the verb (response already printed). */
434
+ async #tryOwnerRoute(
435
+ root: string,
436
+ sessionId: string,
437
+ verb: string,
438
+ input: Record<string, unknown>,
439
+ ): Promise<boolean> {
440
+ const owner = await resolveOwner(root, sessionId);
441
+ if (!owner.live || !owner.socketPath) return false;
442
+ try {
443
+ const res = (await callEndpoint(owner.socketPath, { verb, input })) as { ok?: boolean };
444
+ writeJson(res);
445
+ if (res?.ok === false) process.exitCode = 1;
446
+ return true;
447
+ } catch (error) {
448
+ if (error instanceof EndpointUnreachableError) return false;
449
+ throw error;
450
+ }
451
+ }
452
+
453
+ async #observe(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
454
+ const sessionId = requireSessionId(input, flagSession);
455
+ if (await this.#tryOwnerRoute(root, sessionId, "observe", { ...input, sessionId })) return;
456
+ const state = await loadState(root, sessionId);
457
+ const ownerLive = ownerLiveFor(state);
458
+ const observation = buildObservation(state, ownerLive);
459
+ writeJson(buildResponse(state, ownerLive, { observation, readOnly: !ownerLive }));
460
+ }
461
+
462
+ async #classify(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
463
+ const budget = resolveRetryBudget(input);
464
+ let observation = input.observation as Partial<Observation> | undefined;
465
+ let stateView: SessionState | null = null;
466
+ const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
467
+ if (sessionId) {
468
+ stateView = await loadState(root, sessionId);
469
+ if (!observation) observation = buildObservation(stateView, ownerLiveFor(stateView));
470
+ }
471
+ if (!observation) throw new Error("classify_requires_observation_or_session");
472
+ const full: Observation = {
473
+ lifecycle: observation.lifecycle ?? "observing",
474
+ ownerLive: observation.ownerLive ?? false,
475
+ cwd: observation.cwd ?? ".",
476
+ branch: observation.branch ?? null,
477
+ gitDelta: observation.gitDelta ?? "unknown",
478
+ lastActivityAt: observation.lastActivityAt ?? null,
479
+ observedSignals: observation.observedSignals ?? [],
480
+ risk: observation.risk ?? "normal",
481
+ };
482
+ const decision = classifyRecovery({ observation: full, retryBudget: budget });
483
+ if (stateView) {
484
+ writeJson(buildResponse(stateView, ownerLiveFor(stateView), { decision, observation: full }));
485
+ return;
486
+ }
487
+ // Pure classify without a session: synthesize a minimal state view.
488
+ writeJson({
489
+ ok: true,
490
+ state: {
491
+ sessionId: "(none)",
492
+ lifecycle: full.lifecycle,
493
+ harness: "gajae-code",
494
+ ownerLive: full.ownerLive,
495
+ blockers: [],
496
+ },
497
+ evidence: { decision, observation: full },
498
+ nextAllowedActions: [],
499
+ });
500
+ }
501
+
502
+ async #submit(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
503
+ const sessionId = requireSessionId(input, flagSession);
504
+ if (await this.#tryOwnerRoute(root, sessionId, "submit", { ...input, sessionId })) return;
505
+ const state = await loadState(root, sessionId);
506
+ // No live owner: submission is blocked (never echoed-as-accepted).
507
+ writeJson(buildResponse(state, false, { accepted: false, submitted: false, reason: "owner-not-live" }, false));
508
+ process.exitCode = 1;
509
+ }
510
+
511
+ async #events(
512
+ root: string,
513
+ input: Record<string, unknown>,
514
+ flagSession: string | undefined,
515
+ cursor: number,
516
+ ): Promise<void> {
517
+ const sessionId = requireSessionId(input, flagSession);
518
+ const state = await loadState(root, sessionId);
519
+ const events = await readEvents(root, sessionId, cursor);
520
+ const nextCursor = events.length > 0 ? events[events.length - 1].cursor : cursor;
521
+ writeJson(
522
+ buildResponse(state, ownerLiveFor(state), {
523
+ events,
524
+ cursor: nextCursor,
525
+ note: "tail-only; live producer (owner) lands in M3/M5",
526
+ }),
527
+ );
528
+ }
529
+
530
+ async #retire(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
531
+ const sessionId = requireSessionId(input, flagSession);
532
+ if (await this.#tryOwnerRoute(root, sessionId, "retire", { ...input, sessionId })) return;
533
+ const state = await loadState(root, sessionId);
534
+ const observation = buildObservation(state, ownerLiveFor(state));
535
+ if (observation.gitDelta === "dirty" || observation.gitDelta === "unknown") {
536
+ writeJson(
537
+ buildResponse(
538
+ state,
539
+ false,
540
+ {
541
+ retired: false,
542
+ reason: `retire-blocked:${observation.gitDelta}-delta`,
543
+ gitDelta: observation.gitDelta,
544
+ },
545
+ false,
546
+ ),
547
+ );
548
+ process.exitCode = 1;
549
+ return;
550
+ }
551
+ state.lifecycle = "retired";
552
+ state.updatedAt = nowIso();
553
+ await writeSessionState(root, state);
554
+ writeJson(buildResponse(state, false, { retired: true }));
555
+ }
556
+
557
+ async #pending(
558
+ root: string,
559
+ verb: string,
560
+ input: Record<string, unknown>,
561
+ flagSession: string | undefined,
562
+ ): Promise<void> {
563
+ const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
564
+ const milestone = verb === "recover" ? "M7" : verb === "validate" || verb === "finalize" ? "M8" : "M9";
565
+ if (sessionId) {
566
+ const state = await loadState(root, sessionId);
567
+ writeJson(buildResponse(state, ownerLiveFor(state), { pending: true, milestone, verb }, false));
568
+ process.exitCode = 1;
569
+ return;
570
+ }
571
+ writeJson({
572
+ ok: false,
573
+ state: buildStateView(
574
+ {
575
+ schemaVersion: SESSION_SCHEMA_VERSION,
576
+ sessionId: "(none)",
577
+ lifecycle: "new",
578
+ harness: "gajae-code",
579
+ handle: {} as SessionHandle,
580
+ retries: {},
581
+ blockers: [],
582
+ createdAt: nowIso(),
583
+ updatedAt: nowIso(),
584
+ },
585
+ false,
586
+ ),
587
+ evidence: { pending: true, milestone, verb },
588
+ nextAllowedActions: [],
589
+ });
590
+ process.exitCode = 1;
591
+ }
592
+ }