@gajae-code/coding-agent 0.5.0 → 0.5.2

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 (194) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +26 -0
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/list-models.d.ts +6 -0
  6. package/dist/types/cli/setup-cli.d.ts +8 -1
  7. package/dist/types/commands/gc.d.ts +26 -0
  8. package/dist/types/commands/setup.d.ts +7 -0
  9. package/dist/types/config/file-lock-gc.d.ts +5 -0
  10. package/dist/types/config/file-lock.d.ts +29 -0
  11. package/dist/types/config/model-registry.d.ts +4 -0
  12. package/dist/types/config/models-config-schema.d.ts +5 -0
  13. package/dist/types/config/settings-schema.d.ts +62 -0
  14. package/dist/types/coordinator/contract.d.ts +1 -1
  15. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  25. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  26. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  27. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  28. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  29. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  31. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  32. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  33. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  34. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -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/gjc-runtime/ultragoal-guard.d.ts +10 -0
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  42. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  43. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  46. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  48. package/dist/types/modes/interactive-mode.d.ts +1 -1
  49. package/dist/types/modes/rpc/rpc-mode.d.ts +72 -2
  50. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  51. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  52. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  53. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  54. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  55. package/dist/types/modes/theme/theme.d.ts +1 -0
  56. package/dist/types/modes/types.d.ts +1 -1
  57. package/dist/types/session/agent-session.d.ts +1 -1
  58. package/dist/types/session/blob-store.d.ts +39 -3
  59. package/dist/types/session/history-storage.d.ts +2 -2
  60. package/dist/types/session/session-manager.d.ts +10 -1
  61. package/dist/types/setup/credential-import.d.ts +79 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/executor.d.ts +1 -0
  64. package/dist/types/task/render.d.ts +1 -1
  65. package/dist/types/tools/ask.d.ts +15 -1
  66. package/dist/types/tools/subagent-render.d.ts +7 -1
  67. package/dist/types/tools/subagent.d.ts +27 -0
  68. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  69. package/dist/types/web/search/index.d.ts +4 -4
  70. package/dist/types/web/search/provider.d.ts +16 -20
  71. package/dist/types/web/search/providers/base.d.ts +2 -1
  72. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  73. package/dist/types/web/search/types.d.ts +14 -2
  74. package/package.json +7 -7
  75. package/scripts/build-binary.ts +7 -0
  76. package/src/async/job-manager.ts +52 -0
  77. package/src/cli/args.ts +5 -0
  78. package/src/cli/auth-broker-cli.ts +1 -0
  79. package/src/cli/fast-help.ts +2 -0
  80. package/src/cli/list-models.ts +13 -1
  81. package/src/cli/setup-cli.ts +138 -3
  82. package/src/cli.ts +1 -0
  83. package/src/commands/gc.ts +22 -0
  84. package/src/commands/harness.ts +7 -3
  85. package/src/commands/setup.ts +5 -1
  86. package/src/commands/ultragoal.ts +3 -1
  87. package/src/config/file-lock-gc.ts +193 -0
  88. package/src/config/file-lock.ts +66 -10
  89. package/src/config/model-profile-activation.ts +15 -3
  90. package/src/config/model-profiles.ts +39 -30
  91. package/src/config/model-registry.ts +21 -1
  92. package/src/config/models-config-schema.ts +1 -0
  93. package/src/config/settings-schema.ts +62 -0
  94. package/src/coordinator/contract.ts +1 -0
  95. package/src/coordinator-mcp/server.ts +459 -3
  96. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  97. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  106. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  107. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  108. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  109. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  110. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  111. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  112. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  113. package/src/defaults/gjc-defaults.ts +7 -0
  114. package/src/defaults/gjc-grok-cli.ts +22 -0
  115. package/src/extensibility/extensions/index.ts +1 -0
  116. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  117. package/src/gjc-runtime/deep-interview-recorder.ts +457 -0
  118. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  119. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  120. package/src/gjc-runtime/gc-render.ts +70 -0
  121. package/src/gjc-runtime/gc-runtime.ts +403 -0
  122. package/src/gjc-runtime/launch-tmux.ts +3 -4
  123. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  124. package/src/gjc-runtime/ralplan-runtime.ts +232 -19
  125. package/src/gjc-runtime/state-renderer.ts +12 -3
  126. package/src/gjc-runtime/state-runtime.ts +48 -30
  127. package/src/gjc-runtime/state-writer.ts +254 -7
  128. package/src/gjc-runtime/team-gc.ts +49 -0
  129. package/src/gjc-runtime/team-runtime.ts +179 -2
  130. package/src/gjc-runtime/tmux-common.ts +14 -0
  131. package/src/gjc-runtime/tmux-gc.ts +177 -0
  132. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  133. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  134. package/src/gjc-runtime/ultragoal-runtime.ts +1239 -31
  135. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  136. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  137. package/src/harness-control-plane/gc-adapter.ts +184 -0
  138. package/src/harness-control-plane/owner.ts +14 -2
  139. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  140. package/src/harness-control-plane/storage.ts +70 -0
  141. package/src/hooks/skill-state.ts +121 -2
  142. package/src/internal-urls/docs-index.generated.ts +22 -12
  143. package/src/lsp/defaults.json +1 -0
  144. package/src/main.ts +18 -3
  145. package/src/modes/acp/acp-agent.ts +4 -2
  146. package/src/modes/bridge/bridge-mode.ts +2 -1
  147. package/src/modes/components/history-search.ts +5 -2
  148. package/src/modes/components/hook-selector.ts +19 -0
  149. package/src/modes/components/model-selector.ts +51 -8
  150. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  151. package/src/modes/components/status-line/segments.ts +1 -1
  152. package/src/modes/controllers/command-controller.ts +25 -6
  153. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  154. package/src/modes/controllers/selector-controller.ts +81 -1
  155. package/src/modes/interactive-mode.ts +11 -1
  156. package/src/modes/rpc/rpc-mode.ts +266 -34
  157. package/src/modes/shared/agent-wire/command-dispatch.ts +281 -261
  158. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  159. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  160. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  161. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  162. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  163. package/src/modes/shared/agent-wire/unattended-session.ts +32 -2
  164. package/src/modes/theme/defaults/claude-code.json +100 -0
  165. package/src/modes/theme/defaults/codex.json +100 -0
  166. package/src/modes/theme/defaults/index.ts +6 -0
  167. package/src/modes/theme/defaults/opencode.json +102 -0
  168. package/src/modes/theme/theme.ts +2 -2
  169. package/src/modes/types.ts +1 -1
  170. package/src/prompts/agents/executor.md +5 -2
  171. package/src/sdk.ts +29 -4
  172. package/src/session/agent-session.ts +99 -19
  173. package/src/session/blob-store.ts +59 -3
  174. package/src/session/history-storage.ts +32 -11
  175. package/src/session/session-manager.ts +72 -20
  176. package/src/setup/credential-import.ts +429 -0
  177. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  178. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  179. package/src/skill-state/workflow-hud.ts +106 -10
  180. package/src/slash-commands/builtin-registry.ts +3 -2
  181. package/src/task/executor.ts +16 -1
  182. package/src/task/render.ts +18 -7
  183. package/src/tools/ask.ts +59 -2
  184. package/src/tools/cron.ts +1 -1
  185. package/src/tools/job.ts +3 -2
  186. package/src/tools/monitor.ts +36 -1
  187. package/src/tools/subagent-render.ts +128 -29
  188. package/src/tools/subagent.ts +173 -9
  189. package/src/tools/ultragoal-ask-guard.ts +39 -0
  190. package/src/web/search/index.ts +25 -25
  191. package/src/web/search/provider.ts +178 -87
  192. package/src/web/search/providers/base.ts +2 -1
  193. package/src/web/search/providers/openai-compatible.ts +151 -0
  194. package/src/web/search/types.ts +47 -22
@@ -10,8 +10,10 @@
10
10
  * - Events: AgentSessionEvent objects streamed as they occur
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
+
14
+ import * as fs from "node:fs/promises";
13
15
  import * as path from "node:path";
14
- import { $env, readLines, Snowflake } from "@gajae-code/utils";
16
+ import { $pickenv, logger, readLines, Snowflake } from "@gajae-code/utils";
15
17
  import type {
16
18
  ExtensionUIContext,
17
19
  ExtensionUIDialogOptions,
@@ -23,8 +25,9 @@ import { initializeExtensions } from "../runtime-init";
23
25
  import { dispatchRpcCommand } from "../shared/agent-wire/command-dispatch";
24
26
  import { AgentWireFrameSequencer, toAgentWireEventFrame } from "../shared/agent-wire/event-envelope";
25
27
  import { rpcError as error } from "../shared/agent-wire/responses";
28
+ import { registerRpcSession, unregisterRpcSession } from "../shared/agent-wire/session-registry";
26
29
  import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
27
- import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
30
+ import { modelSupportsTokenCostMetrics, UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
28
31
  import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
29
32
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
30
33
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
@@ -70,13 +73,91 @@ function parseValueDialogResponse(
70
73
  return undefined;
71
74
  }
72
75
 
73
- function shouldEmitRpcTitles(): boolean {
74
- const raw = $env.PI_RPC_EMIT_TITLE;
76
+ export function shouldEmitRpcTitlesForTest(): boolean {
77
+ const raw = $pickenv("GJC_RPC_EMIT_TITLE", "PI_RPC_EMIT_TITLE");
75
78
  if (!raw) return false;
76
79
  const normalized = raw.trim().toLowerCase();
77
80
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
78
81
  }
79
82
 
83
+ const shouldEmitRpcTitles = shouldEmitRpcTitlesForTest;
84
+
85
+ /**
86
+ * Cancellation commands bypass the ordered serial chain because they must
87
+ * interrupt in-flight work — they cannot wait behind the very command they are
88
+ * meant to abort.
89
+ */
90
+ export const RPC_CANCELLATION_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set<RpcCommand["type"]>([
91
+ "abort",
92
+ "abort_bash",
93
+ "abort_retry",
94
+ ]);
95
+
96
+ /**
97
+ * Safe read-only commands that bypass the ordered serial chain so they never
98
+ * head-of-line-block behind a long-running ordered command like
99
+ * `bash`/`compact`/`handoff`/`login` (#606, issue 13 — the partial fix only
100
+ * fast-laned cancellation).
101
+ *
102
+ * Every command listed here has a dispatch handler that is **fully synchronous
103
+ * and side-effect-free**: on the single-threaded event loop it runs to
104
+ * completion between the await points of any in-flight ordered command, reading
105
+ * live state without mutating it. Because such a read performs no causal write,
106
+ * jumping ahead of an earlier *queued* ordered command is observably harmless —
107
+ * there is no state change to reorder. Read payloads are additionally
108
+ * snapshotted inside the handler (e.g. `get_messages` returns a shallow copy of
109
+ * `session.messages`) so a fast-lane read can never serialize a half-mutated
110
+ * array that an ordered turn/compaction is rewriting in place.
111
+ *
112
+ * Deliberately excluded (kept ordered): every async/long command and every
113
+ * mutating command. In particular the control-flag setters (`set_thinking_level`,
114
+ * `cycle_thinking_level`, `set_steering_mode`, `set_follow_up_mode`,
115
+ * `set_interrupt_mode`, `set_auto_compaction`, `set_auto_retry`) stay ordered.
116
+ * Their handlers are synchronous, so fast-laning one ahead of an already-queued
117
+ * `prompt`/`bash` would apply the new mode *before* that earlier command runs —
118
+ * the earlier command would then observe the later setter's value, a
119
+ * causal-order (arrival-order) regression. Mutations therefore stay on the
120
+ * chain, and new command types default to ordered (fail-safe).
121
+ */
122
+ export const RPC_SAFE_READ_CONTROL_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set<RpcCommand["type"]>([
123
+ // Pure synchronous reads — snapshot live state at processing time, never mutate.
124
+ "get_state",
125
+ "get_session_stats",
126
+ "get_available_models",
127
+ "get_branch_messages",
128
+ "get_last_assistant_text",
129
+ "get_messages",
130
+ "get_login_providers",
131
+ ]);
132
+
133
+ /** True when a command may bypass the ordered serial chain and run immediately. */
134
+ export function isFastLaneRpcCommand(type: RpcCommand["type"]): boolean {
135
+ return RPC_CANCELLATION_COMMANDS.has(type) || RPC_SAFE_READ_CONTROL_COMMANDS.has(type);
136
+ }
137
+
138
+ /**
139
+ * Schedules inbound RPC commands: fast-lane commands run immediately while
140
+ * everything else runs through a serial chain so causal order is preserved. The
141
+ * read loop never blocks, which is what lets a fast-lane command reach a
142
+ * long-running ordered command instead of being head-of-line-blocked behind it.
143
+ */
144
+ export function createRpcCommandScheduler(
145
+ run: (command: RpcCommand) => Promise<void>,
146
+ track: (task: Promise<void>) => void,
147
+ ): { dispatch: (command: RpcCommand) => void } {
148
+ let orderedChain: Promise<void> = Promise.resolve();
149
+ return {
150
+ dispatch(command: RpcCommand): void {
151
+ if (isFastLaneRpcCommand(command.type)) {
152
+ track(run(command));
153
+ return;
154
+ }
155
+ orderedChain = orderedChain.then(() => run(command));
156
+ track(orderedChain);
157
+ },
158
+ };
159
+ }
160
+
80
161
  function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info" {
81
162
  if (event.includes("denied")) return "denied";
82
163
  if (event.includes("exceeded")) return "exceeded";
@@ -86,6 +167,43 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
86
167
  return "info";
87
168
  }
88
169
 
170
+ export class RpcListenRefusedError extends Error {
171
+ constructor(socketPath: string) {
172
+ super(
173
+ `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
174
+ "Stop it first or choose a different --listen path.",
175
+ );
176
+ this.name = "RpcListenRefusedError";
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Probe whether a unix-domain socket path has a live server accepting
182
+ * connections. Returns `true` when a connection succeeds (a previous owner is
183
+ * still alive), and returns `false` only for known missing/stale endpoints
184
+ * (ENOENT / ECONNREFUSED). Unexpected probe failures fail closed as "alive" so
185
+ * `--listen` startup refuses to unlink a path it could not safely classify.
186
+ */
187
+ export async function isUnixSocketAlive(socketPath: string): Promise<boolean> {
188
+ try {
189
+ const socket = await Bun.connect({
190
+ unix: socketPath,
191
+ socket: { data() {}, open() {}, error() {}, close() {} },
192
+ });
193
+ socket.end();
194
+ return true;
195
+ } catch (err) {
196
+ const code = err && typeof err === "object" ? (err as { code?: unknown }).code : undefined;
197
+ if (code === "ENOENT" || code === "ECONNREFUSED") return false;
198
+ logger.warn("RPC --listen socket probe failed closed", {
199
+ socketPath,
200
+ code: typeof code === "string" ? code : undefined,
201
+ error: err instanceof Error ? err.message : String(err),
202
+ });
203
+ return true;
204
+ }
205
+ }
206
+
89
207
  export function requestRpcEditor(
90
208
  pendingRequests: Map<string, PendingExtensionRequest>,
91
209
  output: RpcOutput,
@@ -156,6 +274,7 @@ export function requestRpcEditor(
156
274
  export async function runRpcMode(
157
275
  session: AgentSession,
158
276
  setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
277
+ options?: { listen?: string },
159
278
  ): Promise<never> {
160
279
  // Signal to RPC clients that the server is ready to accept commands
161
280
  // Suppress terminal notifications: they write \x07 (BEL) or OSC sequences directly to
@@ -164,10 +283,18 @@ export async function runRpcMode(
164
283
  // may write there.
165
284
  process.env.PI_NOTIFICATIONS = "off";
166
285
 
167
- process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`);
286
+ // Frames go to a swappable sink: stdout for stdio, the active client socket for a
287
+ // persistent --listen (UDS) server. Defaults to stdout, so the stdio path is unchanged.
288
+ let frameSink = (line: string): void => {
289
+ process.stdout.write(line);
290
+ };
168
291
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
169
- process.stdout.write(`${JSON.stringify(obj)}\n`);
292
+ frameSink(`${JSON.stringify(obj)}\n`);
170
293
  };
294
+ // stdio announces readiness immediately; the UDS server announces it per client connection.
295
+ if (!options?.listen) {
296
+ output({ type: "ready" });
297
+ }
171
298
  const emitRpcTitles = shouldEmitRpcTitles();
172
299
  const decodeError = (err: unknown): string => (err instanceof Error ? err.message : String(err));
173
300
 
@@ -216,6 +343,7 @@ export async function runRpcMode(
216
343
  emitFrame: gate => output(gate),
217
344
  store: gateStore,
218
345
  audit: recordAudit,
346
+ providerSupportsTokenCostMetrics: modelSupportsTokenCostMetrics(session.model),
219
347
  getUsageSnapshot: () => {
220
348
  const stats = session.getSessionStats();
221
349
  return { tokens: stats.tokens.total, costUsd: stats.cost };
@@ -231,11 +359,20 @@ export async function runRpcMode(
231
359
  // Shutdown request flag (wrapped in object to allow mutation with const)
232
360
  const shutdownState = { requested: false };
233
361
  let shutdownStarted = false;
362
+ // Tracks in-flight non-blocking command handlers so shutdown can drain them.
363
+ const inFlightCommands = new Set<Promise<void>>();
234
364
  async function shutdown(exitCode: number, reason: string): Promise<never> {
235
365
  if (shutdownStarted) {
236
366
  process.exit(exitCode);
237
367
  }
238
368
  shutdownStarted = true;
369
+ // Let in-flight non-blocking commands (bash/compact/handoff) finish and emit
370
+ // their responses before teardown, bounded so a never-resolving login cannot
371
+ // wedge shutdown (issue 13).
372
+ if (inFlightCommands.size > 0) {
373
+ await Promise.race([Promise.allSettled([...inFlightCommands]), Bun.sleep(5000)]);
374
+ }
375
+ await unregisterRpcSession(session.sessionId).catch(() => {});
239
376
  hostToolBridge.rejectAllPending(`${reason} before host tool execution completed`);
240
377
  hostUriBridge.clear(`${reason} before host URI request completed`);
241
378
  try {
@@ -399,7 +536,7 @@ export async function runRpcMode(
399
536
  }
400
537
 
401
538
  setTitle(title: string): void {
402
- // Title updates are low-value noise for most RPC hosts; opt in via PI_RPC_EMIT_TITLE=1.
539
+ // Title updates are low-value noise for most RPC hosts; opt in via GJC_RPC_EMIT_TITLE=1.
403
540
  if (!emitRpcTitles) return;
404
541
  this.output({
405
542
  type: "extension_ui_request",
@@ -514,6 +651,26 @@ export async function runRpcMode(
514
651
  unattendedControlPlane,
515
652
  });
516
653
 
654
+ // Fast-lane commands (cancellation + safe read/control, see
655
+ // isFastLaneRpcCommand) bypass the ordered serial chain and run immediately;
656
+ // everything else runs through a serial chain so causal order is preserved
657
+ // (e.g. an ordered `set_model` after `bash` still applies after the bash
658
+ // result) while the read loop itself never blocks — that is what lets a
659
+ // fast-lane command reach a long-running `bash`/`compact`/`handoff`/`login`
660
+ // instead of being head-of-line-blocked behind it (issue 13).
661
+ const runCommand = async (command: RpcCommand): Promise<void> => {
662
+ try {
663
+ output(await handleCommand(command));
664
+ } catch (err) {
665
+ output(error(command.id, command.type, decodeError(err)));
666
+ }
667
+ };
668
+ const trackCommand = (task: Promise<void>): void => {
669
+ inFlightCommands.add(task);
670
+ void task.finally(() => inFlightCommands.delete(task));
671
+ };
672
+ const { dispatch: dispatchCommand } = createRpcCommandScheduler(runCommand, trackCommand);
673
+
517
674
  /**
518
675
  * Check if shutdown was requested and perform shutdown if so.
519
676
  * Called after handling each command when waiting for the next command.
@@ -523,59 +680,134 @@ export async function runRpcMode(
523
680
  await shutdown(0, "RPC shutdown requested");
524
681
  }
525
682
 
526
- // Listen for JSONL input using Bun's stdin. Parse frame-by-frame so a malformed
527
- // command reports a parse error without poisoning the whole long-lived RPC session.
683
+ // Parse + route a single inbound JSONL frame. Shared by the stdio reader and the
684
+ // persistent UDS server so both transports use the same command surface.
528
685
  const inputDecoder = new TextDecoder("utf-8", { fatal: false });
529
- for await (const line of readLines(Bun.stdin.stream())) {
530
- const text = inputDecoder.decode(line).trim();
531
- if (!text) continue;
532
-
686
+ async function handleInboundLine(text: string): Promise<void> {
533
687
  let parsed: unknown;
534
688
  try {
535
689
  parsed = JSON.parse(text);
536
690
  } catch (err) {
537
691
  output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
538
- continue;
692
+ return;
539
693
  }
540
-
541
694
  try {
542
- // Handle extension UI responses
543
695
  if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
544
696
  const response = parsed as RpcExtensionUIResponse;
545
- const pending = pendingExtensionRequests.get(response.id);
546
- if (pending) {
547
- pending.resolve(response);
548
- }
549
- continue;
697
+ pendingExtensionRequests.get(response.id)?.resolve(response);
698
+ return;
550
699
  }
551
-
552
700
  if (isRpcHostToolResult(parsed)) {
553
701
  hostToolBridge.handleResult(parsed);
554
- continue;
702
+ return;
555
703
  }
556
-
557
704
  if (isRpcHostToolUpdate(parsed)) {
558
705
  hostToolBridge.handleUpdate(parsed);
559
- continue;
706
+ return;
560
707
  }
561
-
562
708
  if (isRpcHostUriResult(parsed)) {
563
709
  hostUriBridge.handleResult(parsed);
564
- continue;
710
+ return;
565
711
  }
566
-
567
- // Handle regular commands
568
- const command = parsed as RpcCommand;
569
- const response = await handleCommand(command);
570
- output(response);
571
-
572
- // Check for deferred shutdown request (idle between commands)
712
+ // Ordered commands run through a serial chain to preserve causal order; the
713
+ // reader never blocks, so cancellation commands stay responsive even while a
714
+ // long command is in flight (issue 13).
715
+ dispatchCommand(parsed as RpcCommand);
573
716
  await checkShutdownRequested();
574
717
  } catch (err) {
575
718
  output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
576
719
  }
577
720
  }
578
721
 
722
+ // Persistent UDS server (issue 09): keep the AgentSession alive across client
723
+ // reconnects instead of exiting on stdin EOF. Frames route to the active client
724
+ // socket; while no client is connected they are dropped (clients resync via
725
+ // get_state/get_messages on reconnect).
726
+ if (options?.listen) {
727
+ const socketPath = options.listen;
728
+ await fs.mkdir(path.dirname(socketPath), { recursive: true }).catch(() => {});
729
+ // Refuse to clobber a live previous owner: probe the path first and only
730
+ // unlink a stale endpoint. A second `--listen` on the same path must not
731
+ // remove the socket another running server is still serving (#606).
732
+ // Unexpected probe failures are treated as alive, so this also refuses
733
+ // rather than unlinking a socket path we could not safely classify.
734
+ if (await isUnixSocketAlive(socketPath)) {
735
+ throw new RpcListenRefusedError(socketPath);
736
+ }
737
+ await fs.rm(socketPath, { force: true }).catch(() => {});
738
+ await registerRpcSession({
739
+ sessionId: session.sessionId,
740
+ pid: process.pid,
741
+ transport: "socket",
742
+ cwd: session.sessionManager.getCwd(),
743
+ model: session.model?.id,
744
+ startedAt: new Date().toISOString(),
745
+ endpoint: socketPath,
746
+ }).catch(() => {});
747
+
748
+ const noopSink = (_line: string): void => {};
749
+ let currentSocket: object | undefined;
750
+ let buf = "";
751
+ Bun.listen({
752
+ unix: socketPath,
753
+ socket: {
754
+ open(socket) {
755
+ currentSocket = socket;
756
+ buf = "";
757
+ frameSink = (line: string) => {
758
+ socket.write(line);
759
+ };
760
+ output({ type: "ready" });
761
+ },
762
+ data(socket, data) {
763
+ if (socket !== currentSocket) return;
764
+ buf += inputDecoder.decode(data);
765
+ while (true) {
766
+ const nl = buf.indexOf("\n");
767
+ if (nl < 0) break;
768
+ const text = buf.slice(0, nl).trim();
769
+ buf = buf.slice(nl + 1);
770
+ if (text) void handleInboundLine(text);
771
+ }
772
+ },
773
+ close(socket) {
774
+ if (socket === currentSocket) {
775
+ currentSocket = undefined;
776
+ frameSink = noopSink;
777
+ }
778
+ },
779
+ error() {},
780
+ },
781
+ });
782
+
783
+ const onSignal = (): void => {
784
+ void shutdown(0, "RPC socket server signal");
785
+ };
786
+ process.on("SIGINT", onSignal);
787
+ process.on("SIGTERM", onSignal);
788
+ // Block until an explicit shutdown (signal/extension) calls process.exit.
789
+ await new Promise<never>(() => {});
790
+ throw new Error("RPC socket server returned unexpectedly");
791
+ }
792
+
793
+ // Register this stdio RPC session so other processes can discover it (issue 10).
794
+ await registerRpcSession({
795
+ sessionId: session.sessionId,
796
+ pid: process.pid,
797
+ transport: "stdio",
798
+ cwd: session.sessionManager.getCwd(),
799
+ model: session.model?.id,
800
+ startedAt: new Date().toISOString(),
801
+ }).catch(() => {});
802
+
803
+ // Listen for JSONL input using Bun's stdin. Parse frame-by-frame so a malformed
804
+ // command reports a parse error without poisoning the whole long-lived RPC session.
805
+ for await (const line of readLines(Bun.stdin.stream())) {
806
+ const text = inputDecoder.decode(line).trim();
807
+ if (!text) continue;
808
+ await handleInboundLine(text);
809
+ }
810
+
579
811
  // stdin closed — RPC client is gone, flush durable state and exit cleanly
580
812
  await shutdown(0, "RPC client disconnected");
581
813
  throw new Error("RPC shutdown returned unexpectedly");