@gajae-code/coding-agent 0.4.5 → 0.5.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 (185) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/types/async/job-manager.d.ts +26 -0
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/list-models.d.ts +6 -0
  5. package/dist/types/commands/gc.d.ts +26 -0
  6. package/dist/types/commands/harness.d.ts +3 -0
  7. package/dist/types/config/file-lock-gc.d.ts +5 -0
  8. package/dist/types/config/file-lock.d.ts +7 -0
  9. package/dist/types/config/model-profile-activation.d.ts +11 -2
  10. package/dist/types/config/model-profiles.d.ts +7 -0
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/model-resolver.d.ts +2 -0
  13. package/dist/types/config/models-config-schema.d.ts +30 -0
  14. package/dist/types/config/settings-schema.d.ts +4 -3
  15. package/dist/types/coordinator/contract.d.ts +1 -1
  16. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  25. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  26. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  27. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  28. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  29. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  30. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  31. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  32. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  33. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  34. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
  38. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  39. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  40. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  41. package/dist/types/harness-control-plane/owner.d.ts +8 -1
  42. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  43. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/harness-control-plane/types.d.ts +4 -0
  46. package/dist/types/hindsight/mental-models.d.ts +5 -5
  47. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  48. package/dist/types/modes/components/model-selector.d.ts +1 -12
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  51. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  52. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  53. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  54. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  56. package/dist/types/sdk.d.ts +5 -0
  57. package/dist/types/session/agent-session.d.ts +3 -1
  58. package/dist/types/session/blob-store.d.ts +59 -4
  59. package/dist/types/session/session-manager.d.ts +24 -6
  60. package/dist/types/session/streaming-output.d.ts +3 -2
  61. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/receipt.d.ts +1 -0
  64. package/dist/types/task/types.d.ts +7 -0
  65. package/dist/types/thinking-metadata.d.ts +16 -0
  66. package/dist/types/thinking.d.ts +3 -12
  67. package/dist/types/tools/ask.d.ts +15 -1
  68. package/dist/types/tools/index.d.ts +2 -0
  69. package/dist/types/tools/resolve.d.ts +0 -10
  70. package/dist/types/tools/subagent.d.ts +6 -0
  71. package/dist/types/utils/tool-choice.d.ts +14 -1
  72. package/package.json +7 -7
  73. package/src/async/job-manager.ts +52 -0
  74. package/src/cli/args.ts +3 -0
  75. package/src/cli/auth-broker-cli.ts +1 -0
  76. package/src/cli/list-models.ts +13 -1
  77. package/src/cli.ts +9 -4
  78. package/src/commands/gc.ts +22 -0
  79. package/src/commands/harness.ts +43 -5
  80. package/src/commands/launch.ts +2 -2
  81. package/src/commands/session.ts +3 -1
  82. package/src/config/file-lock-gc.ts +181 -0
  83. package/src/config/file-lock.ts +14 -0
  84. package/src/config/model-profile-activation.ts +15 -3
  85. package/src/config/model-profiles.ts +264 -56
  86. package/src/config/model-resolver.ts +9 -6
  87. package/src/config/models-config-schema.ts +1 -0
  88. package/src/config/settings-schema.ts +6 -3
  89. package/src/coordinator/contract.ts +1 -0
  90. package/src/coordinator-mcp/server.ts +513 -26
  91. package/src/cursor.ts +16 -2
  92. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  93. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  94. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  95. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  96. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  97. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  106. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  107. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  108. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  109. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  110. package/src/defaults/gjc-defaults.ts +7 -0
  111. package/src/defaults/gjc-grok-cli.ts +22 -0
  112. package/src/export/html/index.ts +13 -9
  113. package/src/extensibility/extensions/index.ts +1 -0
  114. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  115. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  116. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  117. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  118. package/src/gjc-runtime/gc-render.ts +70 -0
  119. package/src/gjc-runtime/gc-runtime.ts +403 -0
  120. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  121. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  122. package/src/gjc-runtime/state-renderer.ts +12 -3
  123. package/src/gjc-runtime/state-runtime.ts +46 -29
  124. package/src/gjc-runtime/team-gc.ts +49 -0
  125. package/src/gjc-runtime/team-runtime.ts +211 -8
  126. package/src/gjc-runtime/tmux-common.ts +29 -0
  127. package/src/gjc-runtime/tmux-gc.ts +176 -0
  128. package/src/gjc-runtime/tmux-sessions.ts +68 -12
  129. package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
  130. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  131. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  132. package/src/harness-control-plane/gc-adapter.ts +184 -0
  133. package/src/harness-control-plane/owner.ts +89 -27
  134. package/src/harness-control-plane/receipt-spool.ts +128 -0
  135. package/src/harness-control-plane/state-machine.ts +27 -6
  136. package/src/harness-control-plane/storage.ts +93 -0
  137. package/src/harness-control-plane/types.ts +4 -0
  138. package/src/hindsight/mental-models.ts +17 -16
  139. package/src/internal-urls/docs-index.generated.ts +14 -8
  140. package/src/main.ts +7 -2
  141. package/src/modes/components/assistant-message.ts +26 -14
  142. package/src/modes/components/diff.ts +97 -0
  143. package/src/modes/components/hook-selector.ts +19 -0
  144. package/src/modes/components/model-selector.ts +370 -181
  145. package/src/modes/components/status-line/segments.ts +1 -1
  146. package/src/modes/components/tool-execution.ts +30 -13
  147. package/src/modes/controllers/command-controller.ts +25 -6
  148. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  149. package/src/modes/controllers/selector-controller.ts +34 -42
  150. package/src/modes/rpc/rpc-client.ts +3 -2
  151. package/src/modes/rpc/rpc-mode.ts +187 -39
  152. package/src/modes/rpc/rpc-types.ts +5 -2
  153. package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
  154. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  156. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  157. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  158. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  159. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  160. package/src/sdk.ts +46 -5
  161. package/src/secrets/obfuscator.ts +102 -27
  162. package/src/session/agent-session.ts +179 -25
  163. package/src/session/blob-store.ts +148 -6
  164. package/src/session/session-manager.ts +311 -60
  165. package/src/session/streaming-output.ts +185 -122
  166. package/src/session/tool-choice-queue.ts +23 -0
  167. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  168. package/src/skill-state/workflow-hud.ts +106 -10
  169. package/src/slash-commands/builtin-registry.ts +3 -2
  170. package/src/task/executor.ts +78 -6
  171. package/src/task/receipt.ts +5 -0
  172. package/src/task/render.ts +21 -1
  173. package/src/task/types.ts +8 -0
  174. package/src/thinking-metadata.ts +51 -0
  175. package/src/thinking.ts +26 -46
  176. package/src/tools/ask.ts +56 -1
  177. package/src/tools/bash.ts +1 -1
  178. package/src/tools/index.ts +2 -0
  179. package/src/tools/job.ts +3 -2
  180. package/src/tools/monitor.ts +36 -1
  181. package/src/tools/resolve.ts +93 -18
  182. package/src/tools/subagent-render.ts +9 -0
  183. package/src/tools/subagent.ts +26 -2
  184. package/src/utils/edit-mode.ts +1 -1
  185. package/src/utils/tool-choice.ts +45 -16
@@ -0,0 +1,184 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ type GcCollectResult,
5
+ type GcContext,
6
+ type GcError,
7
+ type GcPruneOutcome,
8
+ type GcRecord,
9
+ type GcStoreAdapter,
10
+ gcPidStatusLabel,
11
+ gcProbeToLeasePidStatus,
12
+ } from "../gjc-runtime/gc-runtime";
13
+ import { classifyLeaseStatus, readLease, reapDeadOwnerArtifacts } from "./session-lease";
14
+ import {
15
+ type HarnessRootRegistryForGc,
16
+ type HarnessRootRegistryListingForGc,
17
+ listHarnessRootRegistriesForGc,
18
+ removeHarnessRootRegistryFileForGc,
19
+ rewriteHarnessRootRegistryForGc,
20
+ sessionPaths,
21
+ } from "./storage";
22
+
23
+ function errorMessage(error: unknown): string {
24
+ return error instanceof Error ? error.message : String(error);
25
+ }
26
+
27
+ async function exists(file: string): Promise<boolean> {
28
+ try {
29
+ await fs.access(file);
30
+ return true;
31
+ } catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ function registryErrors(registries: HarnessRootRegistryListingForGc[]): GcError[] {
38
+ return registries
39
+ .filter(registry => registry.error)
40
+ .map(registry => ({
41
+ store: "registry_entries",
42
+ scope: registry.file,
43
+ message: registry.error ?? "registry_error",
44
+ }));
45
+ }
46
+
47
+ async function collectRegistries(ctx: GcContext): Promise<HarnessRootRegistryListingForGc[]> {
48
+ return listHarnessRootRegistriesForGc(ctx.env);
49
+ }
50
+
51
+ export const harnessLeasesGcAdapter: GcStoreAdapter = {
52
+ store: "harness_leases",
53
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
54
+ const records: GcRecord[] = [];
55
+ const errors: GcError[] = [];
56
+ const registries = await collectRegistries(ctx);
57
+ errors.push(...registryErrors(registries).map(error => ({ ...error, store: "harness_leases" as const })));
58
+
59
+ const roots = new Set<string>();
60
+ for (const registry of registries) {
61
+ if (registry.error) continue;
62
+ for (const entry of registry.roots) roots.add(path.resolve(entry.root));
63
+ }
64
+
65
+ for (const root of roots) {
66
+ const sessionsDir = path.join(root, "sessions");
67
+ let sessionEntries: string[];
68
+ try {
69
+ sessionEntries = await fs.readdir(sessionsDir);
70
+ } catch (error) {
71
+ const code = (error as NodeJS.ErrnoException).code;
72
+ if (code === "ENOENT") continue;
73
+ errors.push({ store: "harness_leases", scope: sessionsDir, message: errorMessage(error) });
74
+ continue;
75
+ }
76
+
77
+ for (const sessionId of sessionEntries) {
78
+ const sessionDir = sessionPaths(root, sessionId).dir;
79
+ try {
80
+ const stat = await fs.stat(sessionDir);
81
+ if (!stat.isDirectory()) continue;
82
+ const lease = await readLease(root, sessionId);
83
+ if (!lease) continue;
84
+ const status = classifyLeaseStatus(lease, { probe: gcProbeToLeasePidStatus(ctx.probe) });
85
+ const pidProbe = ctx.probe(lease.pid);
86
+ const pidStatus = gcPidStatusLabel(pidProbe);
87
+ const removable = status === "dead" && pidProbe.status === "dead";
88
+ records.push({
89
+ store: "harness_leases",
90
+ id: sessionId,
91
+ root,
92
+ path: sessionPaths(root, sessionId).lease,
93
+ pid: lease.pid,
94
+ pid_status: pidStatus,
95
+ status,
96
+ stale: status === "dead",
97
+ removable,
98
+ action: "none",
99
+ reason: removable
100
+ ? `lease owner pid ${lease.pid} is dead`
101
+ : `lease owner pid ${lease.pid} is ${pidStatus}; keeping`,
102
+ });
103
+ } catch (error) {
104
+ errors.push({ store: "harness_leases", scope: sessionDir, message: errorMessage(error) });
105
+ }
106
+ }
107
+ }
108
+
109
+ return { records, errors };
110
+ },
111
+ async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
112
+ if (!record.root) return { removed: false, skipped: "missing_root" };
113
+ const lease = await readLease(record.root, record.id);
114
+ if (!lease) return { removed: false, skipped: "lease_not_dead_or_missing" };
115
+ const status = classifyLeaseStatus(lease, { probe: gcProbeToLeasePidStatus(ctx.probe) });
116
+ if (status !== "dead") return { removed: false, skipped: "lease_not_dead_or_missing" };
117
+ const removed = await reapDeadOwnerArtifacts(record.root, record.id, lease.ownerId, lease.leaseEpoch, {
118
+ probe: gcProbeToLeasePidStatus(ctx.probe),
119
+ });
120
+ return removed ? { removed: true } : { removed: false, skipped: "reaper_guard_rejected" };
121
+ },
122
+ };
123
+
124
+ async function splitRegistryRoots(registry: HarnessRootRegistryForGc): Promise<{
125
+ liveRoots: HarnessRootRegistryForGc["roots"];
126
+ danglingRoots: HarnessRootRegistryForGc["roots"];
127
+ }> {
128
+ const liveRoots: HarnessRootRegistryForGc["roots"] = [];
129
+ const danglingRoots: HarnessRootRegistryForGc["roots"] = [];
130
+ for (const entry of registry.roots) {
131
+ const sessionDir = sessionPaths(entry.root, registry.sessionId).dir;
132
+ if (await exists(sessionDir)) liveRoots.push(entry);
133
+ else danglingRoots.push(entry);
134
+ }
135
+ return { liveRoots, danglingRoots };
136
+ }
137
+
138
+ export const registryEntriesGcAdapter: GcStoreAdapter = {
139
+ store: "registry_entries",
140
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
141
+ const records: GcRecord[] = [];
142
+ const errors: GcError[] = [];
143
+ const registries = await collectRegistries(ctx);
144
+ errors.push(...registryErrors(registries));
145
+
146
+ for (const registry of registries) {
147
+ if (registry.error) continue;
148
+ try {
149
+ const { liveRoots, danglingRoots } = await splitRegistryRoots(registry);
150
+ if (danglingRoots.length === 0) continue;
151
+ records.push({
152
+ store: "registry_entries",
153
+ id: registry.sessionId,
154
+ path: registry.file,
155
+ pid_status: "none",
156
+ status: "dangling",
157
+ stale: true,
158
+ removable: true,
159
+ action: "none",
160
+ reason: `dangling roots: ${danglingRoots.map(entry => entry.root).join(", ")}`,
161
+ detail: `${danglingRoots.length} dangling, ${liveRoots.length} live`,
162
+ });
163
+ } catch (error) {
164
+ errors.push({ store: "registry_entries", scope: registry.file, message: errorMessage(error) });
165
+ }
166
+ }
167
+
168
+ return { records, errors };
169
+ },
170
+ async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
171
+ if (!record.path) return { removed: false, skipped: "missing_registry_path" };
172
+ const registries = await collectRegistries(ctx);
173
+ const registry = registries.find(entry => entry.file === record.path);
174
+ if (!registry || registry.error) return { removed: false, skipped: "registry_not_readable" };
175
+ const { liveRoots, danglingRoots } = await splitRegistryRoots(registry);
176
+ if (danglingRoots.length === 0) return { removed: false, skipped: "no_dangling_roots" };
177
+ if (liveRoots.length === 0) {
178
+ await removeHarnessRootRegistryFileForGc(record.path);
179
+ } else {
180
+ await rewriteHarnessRootRegistryForGc(record.path, { sessionId: registry.sessionId, roots: liveRoots });
181
+ }
182
+ return { removed: true };
183
+ },
184
+ };
@@ -20,6 +20,7 @@ import { ControlServer, type EndpointRequest } from "./control-endpoint";
20
20
  import { defaultFinalizeChecks, type FinalizeChecks, runFinalize, type ValidationCommandSpec } from "./finalize";
21
21
  import { type OperateResult, operate } from "./operate";
22
22
  import { preserveDirtyWorktree } from "./preserve";
23
+ import { RECEIPT_SPOOL_DIR_ENV, withReceiptSpoolDir } from "./receipt-spool";
23
24
  import {
24
25
  buildReceipt,
25
26
  type ReceiptSubject,
@@ -28,8 +29,7 @@ import {
28
29
  type VanishEvidence,
29
30
  validateReceipt,
30
31
  } from "./receipts";
31
- import type { HarnessRpc } from "./rpc-adapter";
32
- import { singleFlightAccept } from "./rpc-adapter";
32
+ import { type HarnessRpc, type RpcStateSnapshot, singleFlightAccept } from "./rpc-adapter";
33
33
  import {
34
34
  acquireLease,
35
35
  canWriteEvents,
@@ -39,7 +39,7 @@ import {
39
39
  releaseLease,
40
40
  type SessionLease,
41
41
  } from "./session-lease";
42
- import { buildStateView, nextAllowedActions } from "./state-machine";
42
+ import { buildStateView, nextAllowedActions, submitUnavailableReason } from "./state-machine";
43
43
  import {
44
44
  appendEvent,
45
45
  controlSocketPath,
@@ -200,11 +200,6 @@ export class RuntimeOwner {
200
200
  }
201
201
 
202
202
  async #emitMapped(mapped: NonNullable<ReturnType<typeof observeRpcOutboundFrame>>): Promise<void> {
203
- await this.#emit(
204
- mapped.severity,
205
- mapped.kind,
206
- mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
207
- );
208
203
  if (mapped.kind === "rpc_agent_completed") {
209
204
  const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
210
205
  if (
@@ -218,6 +213,11 @@ export class RuntimeOwner {
218
213
  await writeSessionState(this.#opts.root, state);
219
214
  }
220
215
  }
216
+ await this.#emit(
217
+ mapped.severity,
218
+ mapped.kind,
219
+ mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
220
+ );
221
221
  }
222
222
 
223
223
  #aggregateSignals(events: EventEnvelope[]): string[] {
@@ -233,6 +233,20 @@ export class RuntimeOwner {
233
233
  return out;
234
234
  }
235
235
 
236
+ #eventSubmitGateReason(kind: string, evidence: Record<string, unknown>): string | null {
237
+ const reason = typeof evidence.reason === "string" ? evidence.reason : null;
238
+ const signal = typeof evidence.signal === "string" ? evidence.signal : null;
239
+ const rpcActive =
240
+ kind === "prompt_accepted" ||
241
+ reason === "pre-state-not-idle" ||
242
+ kind.startsWith("rpc_") ||
243
+ signal === "prompt-accepted" ||
244
+ signal === "streaming" ||
245
+ signal === "tool-call" ||
246
+ signal === "test-running";
247
+ return rpcActive ? "rpc-not-idle" : null;
248
+ }
249
+
236
250
  async #emit(severity: Severity, kind: string, evidence: Record<string, unknown>): Promise<void> {
237
251
  const lease = await readLease(this.#opts.root, this.#opts.sessionId);
238
252
  // Single-writer guard: only emit while we still hold a live lease.
@@ -247,6 +261,7 @@ export class RuntimeOwner {
247
261
  ownerLive: true,
248
262
  blockers: [],
249
263
  };
264
+ const submitGateReason = this.#eventSubmitGateReason(kind, evidence);
250
265
  const envelope: EventEnvelope = {
251
266
  eventId: randomUUID(),
252
267
  cursor: ++this.#cursor,
@@ -255,21 +270,41 @@ export class RuntimeOwner {
255
270
  kind,
256
271
  state: view,
257
272
  evidence,
258
- nextAllowedActions: nextAllowedActions(view.lifecycle, true),
273
+ nextAllowedActions: nextAllowedActions(view.lifecycle, true, { submitUnavailableReason: submitGateReason }),
259
274
  writer: { ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch },
260
275
  };
261
276
  await appendEvent(this.#opts.root, this.#opts.sessionId, envelope);
262
277
  }
263
278
 
264
- #response(state: SessionState, evidence: Record<string, unknown>, ok = true): PrimitiveResponse {
279
+ #response(
280
+ state: SessionState,
281
+ evidence: Record<string, unknown>,
282
+ ok = true,
283
+ submitGateReason: string | null = null,
284
+ ): PrimitiveResponse {
265
285
  return {
266
286
  ok,
267
287
  state: buildStateView(state, true),
268
288
  evidence,
269
- nextAllowedActions: nextAllowedActions(state.lifecycle, true),
289
+ nextAllowedActions: nextAllowedActions(state.lifecycle, true, { submitUnavailableReason: submitGateReason }),
270
290
  };
271
291
  }
272
292
 
293
+ #submitGateReason(state: SessionState, rpcState: RpcStateSnapshot | null): string | null {
294
+ const rpcReason = rpcState
295
+ ? rpcState.isStreaming || rpcState.steeringQueueDepth > 0 || rpcState.followupQueueDepth > 0
296
+ ? "rpc-not-idle"
297
+ : null
298
+ : "rpc-not-live";
299
+ return submitUnavailableReason(state.lifecycle, true, rpcReason);
300
+ }
301
+
302
+ async #withReceiptSpoolFromInput<T>(input: Record<string, unknown>, fn: () => Promise<T>): Promise<T> {
303
+ const requested = input[RECEIPT_SPOOL_DIR_ENV];
304
+ if (typeof requested === "string" && requested.trim()) return withReceiptSpoolDir(requested, fn);
305
+ return fn();
306
+ }
307
+
273
308
  async #handle(req: EndpointRequest): Promise<unknown> {
274
309
  switch (req.verb) {
275
310
  case "ping":
@@ -281,13 +316,13 @@ export class RuntimeOwner {
281
316
  case "retire":
282
317
  return this.#retire();
283
318
  case "finalize":
284
- return this.#finalize(req.input);
319
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#finalize(req.input));
285
320
  case "recover":
286
- return this.#recover();
321
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#recover());
287
322
  case "validate":
288
- return this.#validate();
323
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#validate());
289
324
  case "operate":
290
- return this.#operate(req.input);
325
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#operate(req.input));
291
326
  default:
292
327
  return { ok: false, error: `owner_unsupported_verb:${req.verb}` };
293
328
  }
@@ -297,8 +332,10 @@ export class RuntimeOwner {
297
332
  const state = await this.#loadState();
298
333
  const workspace = state.handle.workspace;
299
334
  let streaming = false;
335
+ let rpcState: RpcStateSnapshot | null = null;
300
336
  try {
301
- streaming = (await this.#opts.rpc.getState()).isStreaming;
337
+ rpcState = await this.#opts.rpc.getState();
338
+ streaming = rpcState.isStreaming;
302
339
  } catch {
303
340
  streaming = false;
304
341
  }
@@ -328,12 +365,7 @@ export class RuntimeOwner {
328
365
  gitDelta = "unknown";
329
366
  }
330
367
  }
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);
368
+ const rpcLive = this.#opts.rpc.isLive ? this.#opts.rpc.isLive() : rpcState !== null;
337
369
  const rpcLastFrameAt = this.#opts.rpc.lastFrameAt ? this.#opts.rpc.lastFrameAt() : null;
338
370
  // Sticky semantic signals come from the persisted owner event log -> survive polling gaps.
339
371
  const recent = (await readEvents(this.#opts.root, this.#opts.sessionId, 0)).slice(-200);
@@ -343,6 +375,7 @@ export class RuntimeOwner {
343
375
  (t): t is string => typeof t === "string",
344
376
  );
345
377
  const lastActivityAt = stamps.length > 0 ? (stamps.sort().at(-1) ?? state.updatedAt) : state.updatedAt;
378
+ const submitGateReason = this.#submitGateReason(state, rpcState);
346
379
  return {
347
380
  lifecycle: state.lifecycle,
348
381
  ownerLive: true,
@@ -354,6 +387,8 @@ export class RuntimeOwner {
354
387
  risk: deleted ? "deleted-worktree" : "normal",
355
388
  rpcLive,
356
389
  rpcLastFrameAt,
390
+ readyForSubmit: submitGateReason === null,
391
+ submitUnavailableReason: submitGateReason,
357
392
  };
358
393
  }
359
394
 
@@ -543,10 +578,21 @@ export class RuntimeOwner {
543
578
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
544
579
  const state = await this.#loadState();
545
580
  if (!prompt) {
546
- return this.#response(state, { accepted: false, reason: "empty-prompt" }, false);
581
+ return this.#response(
582
+ state,
583
+ { accepted: false, submitted: false, reason: "empty-prompt" },
584
+ false,
585
+ "empty-prompt",
586
+ );
547
587
  }
548
- if (state.lifecycle === "blocked") {
549
- return this.#response(state, { accepted: false, reason: "lifecycle-blocked" }, false);
588
+ const lifecycleGate = submitUnavailableReason(state.lifecycle, true);
589
+ if (lifecycleGate) {
590
+ return this.#response(
591
+ state,
592
+ { accepted: false, submitted: false, reason: lifecycleGate },
593
+ false,
594
+ lifecycleGate,
595
+ );
550
596
  }
551
597
  const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
552
598
  if (result.accepted) {
@@ -560,11 +606,12 @@ export class RuntimeOwner {
560
606
  } else {
561
607
  await this.#emit("warn", "prompt_not_accepted", { reason: result.reason });
562
608
  }
609
+ const submitGateReason = result.accepted ? null : result.reason === "pre-state-not-idle" ? "rpc-not-idle" : null;
563
610
  return this.#response(
564
611
  state,
565
612
  {
566
613
  accepted: result.accepted,
567
- submitted: true,
614
+ submitted: result.commandId !== null,
568
615
  reason: result.reason,
569
616
  commandId: result.commandId,
570
617
  preSubmitCursor: result.preSubmitCursor,
@@ -572,12 +619,16 @@ export class RuntimeOwner {
572
619
  acceptanceEvidence: result.preSubmitState,
573
620
  },
574
621
  result.accepted,
622
+ submitGateReason,
575
623
  );
576
624
  }
577
625
 
578
626
  async #observe(): Promise<PrimitiveResponse> {
579
627
  const state = await this.#loadState();
580
- return this.#response(state, { observation: await this.#observeGit(), ownerRouted: true });
628
+ const observation = await this.#observeGit();
629
+ const submitGateReason =
630
+ typeof observation.submitUnavailableReason === "string" ? observation.submitUnavailableReason : null;
631
+ return this.#response(state, { observation, ownerRouted: true }, true, submitGateReason);
581
632
  }
582
633
 
583
634
  async #retire(): Promise<PrimitiveResponse> {
@@ -619,3 +670,14 @@ export async function resolveOwner(root: string, sessionId: string): Promise<Res
619
670
  const live = status === "live" || status === "expiredAlive" || status === "epermAlive";
620
671
  return { live, socketPath: lease.endpoint?.path ?? null, lease };
621
672
  }
673
+
674
+ /**
675
+ * Owner liveness for verbs that do not route to the owner (e.g. `classify`): a routable owner
676
+ * has a live lease and a socket endpoint. This is the same lease/socket probe `observe` uses to
677
+ * decide routing, so non-routing verbs derive `ownerLive` consistently instead of assuming the
678
+ * owner is gone (which would misclassify a live owner as vanished/restart-clean).
679
+ */
680
+ export async function resolveOwnerLive(root: string, sessionId: string): Promise<boolean> {
681
+ const owner = await resolveOwner(root, sessionId);
682
+ return owner.live && owner.socketPath !== null;
683
+ }
@@ -0,0 +1,128 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { withFileLock } from "../config/file-lock";
5
+ import type { ReceiptEnvelope } from "./receipts";
6
+
7
+ export const RECEIPT_SPOOL_DIR_ENV = "GJC_RECEIPT_SPOOL_DIR";
8
+ export const RECEIPT_SPOOL_FILENAME = "spool.jsonl";
9
+ export const RECEIPT_SPOOL_CURSOR_WIDTH = 12;
10
+
11
+ export interface ReceiptSpoolRecord {
12
+ cursor: string;
13
+ envelope: ReceiptEnvelope<unknown>;
14
+ }
15
+
16
+ export interface ReceiptSpoolAppendResult {
17
+ cursor: string;
18
+ path: string;
19
+ }
20
+
21
+ const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
22
+ const spoolQueues = new Map<string, Promise<void>>();
23
+ const noop = (): void => undefined;
24
+ export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
25
+ const trimmed = spoolDir.trim();
26
+ if (!trimmed) throw new Error("receipt_spool_dir_empty");
27
+ const resolved = path.resolve(trimmed);
28
+ return receiptSpoolDirStorage.run(resolved, fn);
29
+ }
30
+
31
+ export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
32
+ const active = receiptSpoolDirStorage.getStore();
33
+ if (active !== undefined) return active;
34
+ const raw = env[RECEIPT_SPOOL_DIR_ENV]?.trim();
35
+ return raw ? path.resolve(raw) : undefined;
36
+ }
37
+
38
+ export function receiptSpoolPath(spoolDir: string): string {
39
+ return path.join(path.resolve(spoolDir), RECEIPT_SPOOL_FILENAME);
40
+ }
41
+
42
+ function parseCursor(value: unknown): bigint | undefined {
43
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return undefined;
44
+ try {
45
+ return BigInt(value);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ export function formatReceiptSpoolCursor(cursor: bigint): string {
52
+ const raw = cursor.toString();
53
+ return raw.length >= RECEIPT_SPOOL_CURSOR_WIDTH ? raw : raw.padStart(RECEIPT_SPOOL_CURSOR_WIDTH, "0");
54
+ }
55
+
56
+ export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
57
+ const spoolFile = receiptSpoolPath(spoolDir);
58
+ let raw: string;
59
+ try {
60
+ raw = await fs.readFile(spoolFile, "utf8");
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0n;
63
+ throw error;
64
+ }
65
+
66
+ let highest = 0n;
67
+ for (const line of raw.split("\n")) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed) continue;
70
+ try {
71
+ const parsed = JSON.parse(trimmed) as { cursor?: unknown };
72
+ const cursor = parseCursor(parsed.cursor);
73
+ if (cursor !== undefined && cursor > highest) highest = cursor;
74
+ } catch {
75
+ // A crash may leave a torn tail; consumers skip malformed lines and so do we.
76
+ }
77
+ }
78
+ return highest;
79
+ }
80
+
81
+ async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
82
+ const previous = spoolQueues.get(spoolFile) ?? Promise.resolve();
83
+ const running = previous.catch(noop).then(task);
84
+ const normalized = running.then(noop, noop);
85
+ spoolQueues.set(spoolFile, normalized);
86
+ normalized
87
+ .finally(() => {
88
+ if (spoolQueues.get(spoolFile) === normalized) spoolQueues.delete(spoolFile);
89
+ })
90
+ .catch(noop);
91
+ return running;
92
+ }
93
+
94
+ export async function appendReceiptToSpool(
95
+ spoolDir: string,
96
+ envelope: ReceiptEnvelope<unknown>,
97
+ ): Promise<ReceiptSpoolAppendResult> {
98
+ const resolvedDir = path.resolve(spoolDir);
99
+ const spoolFile = receiptSpoolPath(resolvedDir);
100
+ return enqueueSpoolAppend(spoolFile, async () => {
101
+ await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
102
+ return withFileLock(
103
+ spoolFile,
104
+ async () => {
105
+ const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
106
+ const record: ReceiptSpoolRecord = { cursor, envelope };
107
+ const handle = await fs.open(spoolFile, "a", 0o600);
108
+ try {
109
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
110
+ await handle.sync();
111
+ } finally {
112
+ await handle.close();
113
+ }
114
+ return { cursor, path: spoolFile };
115
+ },
116
+ { staleMs: 30_000, retries: 100, retryDelayMs: 25 },
117
+ );
118
+ });
119
+ }
120
+
121
+ export async function appendReceiptToConfiguredSpool(
122
+ envelope: ReceiptEnvelope<unknown>,
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): Promise<ReceiptSpoolAppendResult | undefined> {
125
+ const spoolDir = resolveReceiptSpoolDir(env);
126
+ if (!spoolDir) return undefined;
127
+ return appendReceiptToSpool(spoolDir, envelope);
128
+ }
@@ -8,6 +8,7 @@
8
8
  import type { HarnessLifecycle, NextAllowedAction, PrimitiveResponse, SessionState, SessionStateView } from "./types";
9
9
 
10
10
  const TERMINAL_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["completed", "retired"]);
11
+ const SUBMIT_READY_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["started", "observing"]);
11
12
 
12
13
  const TRANSITIONS: Record<HarnessLifecycle, readonly HarnessLifecycle[]> = {
13
14
  new: ["started", "blocked", "retired"],
@@ -37,11 +38,32 @@ export function assertTransition(from: HarnessLifecycle, to: HarnessLifecycle):
37
38
  }
38
39
  }
39
40
 
41
+ export interface NextAllowedActionsOptions {
42
+ /** Additional live-owner/RPC readiness gate for submit, e.g. rpc-not-idle. */
43
+ submitUnavailableReason?: string | null;
44
+ }
45
+
46
+ export function submitUnavailableReason(
47
+ lifecycle: HarnessLifecycle,
48
+ ownerLive: boolean,
49
+ gateReason: string | null = null,
50
+ ): string | null {
51
+ if (isTerminal(lifecycle)) return `lifecycle-terminal:${lifecycle}`;
52
+ if (lifecycle === "blocked") return "lifecycle-blocked";
53
+ if (!SUBMIT_READY_LIFECYCLES.has(lifecycle)) return `lifecycle-not-idle:${lifecycle}`;
54
+ if (!ownerLive) return "owner-not-live";
55
+ return gateReason;
56
+ }
57
+
40
58
  /**
41
59
  * Derive the permitted next actions for a session given its lifecycle and whether
42
60
  * a live owner currently holds the lease.
43
61
  */
44
- export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boolean): NextAllowedAction[] {
62
+ export function nextAllowedActions(
63
+ lifecycle: HarnessLifecycle,
64
+ ownerLive: boolean,
65
+ options: NextAllowedActionsOptions = {},
66
+ ): NextAllowedAction[] {
45
67
  const terminal = isTerminal(lifecycle);
46
68
  const actions: NextAllowedAction[] = [];
47
69
  const add = (verb: NextAllowedAction["verb"], available: boolean, reason?: string): void => {
@@ -57,11 +79,10 @@ export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boole
57
79
  // `start` creates a new session; never re-applicable to an existing record.
58
80
  add("start", false, "session-already-exists");
59
81
 
60
- // `submit` is owner-routed: it requires a live owner and a non-blocked, non-terminal lifecycle.
61
- if (terminal) add("submit", false, `lifecycle-terminal:${lifecycle}`);
62
- else if (lifecycle === "blocked") add("submit", false, "lifecycle-blocked");
63
- else if (!ownerLive) add("submit", false, "owner-not-live");
64
- else add("submit", true);
82
+ // `submit` is owner-routed: it requires a live owner, a submit-ready lifecycle,
83
+ // and (for owner-observed responses) an idle/routable RPC backend.
84
+ const submitReason = submitUnavailableReason(lifecycle, ownerLive, options.submitUnavailableReason ?? null);
85
+ add("submit", submitReason === null, submitReason ?? undefined);
65
86
 
66
87
  // `recover` handles a dead/failed owner, so it is available without a live owner.
67
88
  add("recover", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);