@gajae-code/coding-agent 0.5.0 → 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 (125) hide show
  1. package/CHANGELOG.md +19 -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/config/file-lock-gc.d.ts +5 -0
  7. package/dist/types/config/file-lock.d.ts +7 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  10. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  11. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  12. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  13. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  14. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  15. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  19. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  22. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  23. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  25. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  26. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  27. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  28. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  29. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  30. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
  31. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  32. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  33. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  34. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  35. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  36. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  37. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  38. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  40. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  41. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  42. package/dist/types/session/agent-session.d.ts +1 -1
  43. package/dist/types/session/blob-store.d.ts +39 -3
  44. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  45. package/dist/types/tools/ask.d.ts +15 -1
  46. package/dist/types/tools/subagent.d.ts +6 -0
  47. package/package.json +7 -7
  48. package/src/async/job-manager.ts +52 -0
  49. package/src/cli/args.ts +3 -0
  50. package/src/cli/auth-broker-cli.ts +1 -0
  51. package/src/cli/list-models.ts +13 -1
  52. package/src/cli.ts +1 -0
  53. package/src/commands/gc.ts +22 -0
  54. package/src/commands/harness.ts +7 -3
  55. package/src/config/file-lock-gc.ts +181 -0
  56. package/src/config/file-lock.ts +14 -0
  57. package/src/config/model-profiles.ts +24 -15
  58. package/src/coordinator/contract.ts +1 -0
  59. package/src/coordinator-mcp/server.ts +459 -3
  60. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  61. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  62. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  63. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  66. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  67. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  68. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  69. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  70. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  71. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  72. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  75. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  76. package/src/defaults/gjc-defaults.ts +7 -0
  77. package/src/defaults/gjc-grok-cli.ts +22 -0
  78. package/src/extensibility/extensions/index.ts +1 -0
  79. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  80. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  81. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  82. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  83. package/src/gjc-runtime/gc-render.ts +70 -0
  84. package/src/gjc-runtime/gc-runtime.ts +403 -0
  85. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  86. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  87. package/src/gjc-runtime/state-renderer.ts +12 -3
  88. package/src/gjc-runtime/state-runtime.ts +46 -29
  89. package/src/gjc-runtime/team-gc.ts +49 -0
  90. package/src/gjc-runtime/team-runtime.ts +179 -2
  91. package/src/gjc-runtime/tmux-common.ts +14 -0
  92. package/src/gjc-runtime/tmux-gc.ts +176 -0
  93. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  94. package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
  95. package/src/harness-control-plane/gc-adapter.ts +184 -0
  96. package/src/harness-control-plane/owner.ts +11 -0
  97. package/src/harness-control-plane/storage.ts +70 -0
  98. package/src/internal-urls/docs-index.generated.ts +14 -8
  99. package/src/main.ts +7 -2
  100. package/src/modes/components/hook-selector.ts +19 -0
  101. package/src/modes/components/model-selector.ts +25 -8
  102. package/src/modes/components/status-line/segments.ts +1 -1
  103. package/src/modes/controllers/command-controller.ts +25 -6
  104. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  105. package/src/modes/controllers/selector-controller.ts +1 -0
  106. package/src/modes/rpc/rpc-mode.ts +151 -33
  107. package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
  108. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  109. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  110. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  111. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  112. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  113. package/src/sdk.ts +17 -3
  114. package/src/session/agent-session.ts +77 -8
  115. package/src/session/blob-store.ts +59 -3
  116. package/src/session/session-manager.ts +4 -4
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  118. package/src/skill-state/workflow-hud.ts +106 -10
  119. package/src/slash-commands/builtin-registry.ts +3 -2
  120. package/src/task/executor.ts +9 -0
  121. package/src/tools/ask.ts +56 -1
  122. package/src/tools/job.ts +3 -2
  123. package/src/tools/monitor.ts +36 -1
  124. package/src/tools/subagent-render.ts +9 -0
  125. package/src/tools/subagent.ts +26 -2
package/src/main.ts CHANGED
@@ -30,6 +30,7 @@ import { activateModelProfile } from "./config/model-profile-activation";
30
30
  import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
31
31
  import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver";
32
32
  import { getDefault, type SettingPath, Settings, settings } from "./config/settings";
33
+ import { BUNDLED_GROK_BUILD_EXTENSION_ID, getBundledGrokBuildExtensionFactory } from "./defaults/gjc-grok-cli";
33
34
  import { initializeWithSettings } from "./discovery";
34
35
  import { exportFromFile } from "./export/html";
35
36
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
@@ -742,7 +743,9 @@ export async function runRootCommand(
742
743
  await runListModelsCommand({
743
744
  modelRegistry,
744
745
  cwd: getProjectDir(),
745
- additionalExtensionPaths: [],
746
+ extensionFactories: [
747
+ { factory: getBundledGrokBuildExtensionFactory(), name: BUNDLED_GROK_BUILD_EXTENSION_ID },
748
+ ],
746
749
  settingsExtensions: [],
747
750
  disabledExtensionIds: [],
748
751
  disableExtensionDiscovery: true,
@@ -975,7 +978,9 @@ export async function runRootCommand(
975
978
 
976
979
  if (mode === "rpc" || mode === "rpc-ui") {
977
980
  const { runRpcMode } = await import("./modes/rpc/rpc-mode");
978
- await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined);
981
+ await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, {
982
+ listen: parsedArgs.rpcListen,
983
+ });
979
984
  } else if (mode === "bridge") {
980
985
  const { runBridgeMode } = await import("./modes/bridge/bridge-mode");
981
986
  await runBridgeMode(session, setToolUIContext);
@@ -3,6 +3,7 @@
3
3
  * Displays a list of string options with keyboard navigation.
4
4
  */
5
5
  import {
6
+ type AutocompleteProvider,
6
7
  Container,
7
8
  Editor,
8
9
  Markdown,
@@ -73,6 +74,12 @@ export interface HookSelectorOptions {
73
74
  optionLabel: string;
74
75
  onSubmit: (text: string) => void;
75
76
  };
77
+ /**
78
+ * Autocomplete provider for the inline custom-input editor. When present,
79
+ * the "Other (type your own)" editor gains the same `@` file-link and `/`
80
+ * completion behavior as the main prompt editor.
81
+ */
82
+ autocompleteProvider?: AutocompleteProvider;
76
83
  }
77
84
 
78
85
  class OutlinedList extends Container {
@@ -320,6 +327,7 @@ export class HookSelectorComponent extends Container {
320
327
  #helpTextComponent: Text;
321
328
  #baseHelpText: string;
322
329
  #tui: TUI | undefined;
330
+ #autocompleteProvider: AutocompleteProvider | undefined;
323
331
  constructor(
324
332
  title: string,
325
333
  options: string[],
@@ -342,6 +350,7 @@ export class HookSelectorComponent extends Container {
342
350
  this.#outline = opts?.outline === true;
343
351
  this.#customInput = opts?.customInput;
344
352
  this.#tui = opts?.tui;
353
+ this.#autocompleteProvider = opts?.autocompleteProvider;
345
354
 
346
355
  this.addChild(new DynamicBorder());
347
356
  this.addChild(new Spacer(1));
@@ -491,6 +500,13 @@ export class HookSelectorComponent extends Container {
491
500
 
492
501
  /** Keys while the inline custom-input editor is open below the option list. */
493
502
  #handleInputModeKey(keyData: string, editor: Editor): void {
503
+ // While the autocomplete dropdown is open, every key belongs to the
504
+ // editor (navigate/apply/cancel the suggestion) instead of submitting
505
+ // or backing out of input mode.
506
+ if (editor.isAutocompleteOpen()) {
507
+ editor.handleInput(keyData);
508
+ return;
509
+ }
494
510
  // Escape backs out to option selection instead of cancelling the dialog,
495
511
  // so a stray Esc never throws away the question context.
496
512
  if (matchesKey(keyData, "escape") || matchesAppInterrupt(keyData)) {
@@ -521,6 +537,9 @@ export class HookSelectorComponent extends Container {
521
537
  editor.setBorderVisible(false);
522
538
  editor.setPromptGutter("> ");
523
539
  editor.disableSubmit = true;
540
+ if (this.#autocompleteProvider) {
541
+ editor.setAutocompleteProvider(this.#autocompleteProvider);
542
+ }
524
543
  this.#inlineEditor = editor;
525
544
  this.#inputArea.addChild(new Spacer(1));
526
545
  this.#inputArea.addChild(editor);
@@ -705,6 +705,15 @@ export class ModelSelectorComponent extends Container {
705
705
  return this.#getMissingProviders(profileOrProfiles).length === 0;
706
706
  }
707
707
 
708
+ /**
709
+ * A preset group is a list of alternative presets, not an all-or-nothing
710
+ * bundle. Treat the group as usable when at least one member preset has all
711
+ * of its required providers authenticated.
712
+ */
713
+ #isPresetGroupUsable(profiles: ModelProfileDefinition[]): boolean {
714
+ return profiles.some(profile => this.#isPresetAuthenticated(profile));
715
+ }
716
+
708
717
  async #refreshProviderAuth(): Promise<void> {
709
718
  const providers = new Set<string>();
710
719
  for (const profiles of this.#getPresetGroups().values()) {
@@ -763,7 +772,7 @@ export class ModelSelectorComponent extends Container {
763
772
  continue;
764
773
  }
765
774
  if (row.kind === "group") {
766
- const authenticated = this.#isPresetAuthenticated(row.profiles);
775
+ const authenticated = this.#isPresetGroupUsable(row.profiles);
767
776
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
768
777
  const label = `${mark} ${row.groupId}`;
769
778
  const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
@@ -1158,18 +1167,26 @@ export class ModelSelectorComponent extends Container {
1158
1167
  this.#switchToModelMode();
1159
1168
  return;
1160
1169
  }
1161
- const missing =
1162
- row.kind === "group" ? this.#getMissingProviders(row.profiles) : this.#getMissingProviders(row.profile);
1170
+ if (row.kind === "group") {
1171
+ // A group is a list of alternative presets; only surface a login hint
1172
+ // when none of its members are usable. A partially-usable group stays
1173
+ // navigable so the user can drill in and pick a usable member.
1174
+ if (!this.#isPresetGroupUsable(row.profiles)) {
1175
+ const missing = this.#getMissingProviders(row.profiles);
1176
+ this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
1177
+ this.#renderPresetLanding();
1178
+ }
1179
+ return;
1180
+ }
1181
+ const missing = this.#getMissingProviders(row.profile);
1163
1182
  if (missing.length > 0) {
1164
1183
  this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
1165
1184
  this.#renderPresetLanding();
1166
1185
  return;
1167
1186
  }
1168
- if (row.kind === "profile") {
1169
- this.#previewProfileName = row.profile.name;
1170
- this.#presetLoginHint = undefined;
1171
- this.#renderPresetLanding();
1172
- }
1187
+ this.#previewProfileName = row.profile.name;
1188
+ this.#presetLoginHint = undefined;
1189
+ this.#renderPresetLanding();
1173
1190
  }
1174
1191
 
1175
1192
  #beginActionMenuOrSelect(item: ModelItem | CanonicalModelItem): void {
@@ -66,7 +66,7 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
66
66
  const gajaeSegment: StatusLineSegment = {
67
67
  id: "gajae",
68
68
  render(_ctx) {
69
- return { content: theme.fg("accent", "GJC"), visible: true };
69
+ return { content: theme.fg("accent", "🦞"), visible: true };
70
70
  },
71
71
  };
72
72
 
@@ -13,6 +13,7 @@ import {
13
13
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@gajae-code/tui";
14
14
  import { formatDuration, Snowflake, setProjectDir } from "@gajae-code/utils";
15
15
  import { $ } from "bun";
16
+ import { jobElapsedMs } from "../../async";
16
17
  import { reset as resetCapabilities } from "../../capability";
17
18
  import { clearClaudePluginRootsCache } from "../../discovery/helpers";
18
19
  import { loadCustomShare } from "../../export/custom-share";
@@ -1279,7 +1280,7 @@ const BAR_WIDTH_MAX = 24;
1279
1280
  const BAR_WIDTH_MIN = 4;
1280
1281
 
1281
1282
  function renderJobLine(job: AsyncJobSnapshotItem, now: number): string {
1282
- const duration = formatDuration(Math.max(0, now - job.startTime));
1283
+ const duration = formatDuration(jobElapsedMs(job, now));
1283
1284
  const status = formatJobStatus(job.status);
1284
1285
  return `${theme.fg("dim", job.id)} ${theme.fg("dim", `[${job.type}]`)} ${status} ${theme.fg("dim", `(${duration})`)}`;
1285
1286
  }
@@ -1538,7 +1539,7 @@ function resolveColumnWidth(count: number, available: number, trailing: number):
1538
1539
  return ideal;
1539
1540
  }
1540
1541
 
1541
- function renderUsageReports(
1542
+ export function renderUsageReports(
1542
1543
  reports: UsageReport[],
1543
1544
  uiTheme: typeof theme,
1544
1545
  nowMs: number,
@@ -1592,17 +1593,35 @@ function renderUsageReports(
1592
1593
 
1593
1594
  lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
1594
1595
 
1596
+ // Stable account column order shared across every window group, so each
1597
+ // account keeps the same column in the 5h / 7d / ... rows. Rank accounts
1598
+ // by total usage across their windows (a per-account value identical for
1599
+ // every group), tie-broken by label for determinism.
1600
+ const rankedAccounts = providerReports
1601
+ .map(report => {
1602
+ const [firstLimit] = report.limits;
1603
+ return {
1604
+ report,
1605
+ total: report.limits.reduce((sum, limit) => sum + (resolveFraction(limit) ?? 0), 0),
1606
+ label: firstLimit ? formatAccountLabel(firstLimit, report, 0) : formatUnlimitedReportLabel(report, 0),
1607
+ };
1608
+ })
1609
+ .sort((a, b) => (a.total !== b.total ? b.total - a.total : a.label.localeCompare(b.label)));
1610
+ const accountRank = new Map<UsageReport, number>();
1611
+ rankedAccounts.forEach((entry, rank) => {
1612
+ accountRank.set(entry.report, rank);
1613
+ });
1614
+
1595
1615
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1596
1616
  const entries = group.limits.map((limit, index) => ({
1597
1617
  limit,
1598
1618
  report: group.reports[index],
1599
- fraction: resolveFraction(limit),
1600
1619
  index,
1601
1620
  }));
1602
1621
  entries.sort((a, b) => {
1603
- const aFraction = a.fraction ?? -1;
1604
- const bFraction = b.fraction ?? -1;
1605
- if (aFraction !== bFraction) return bFraction - aFraction;
1622
+ const aRank = accountRank.get(a.report) ?? Number.MAX_SAFE_INTEGER;
1623
+ const bRank = accountRank.get(b.report) ?? Number.MAX_SAFE_INTEGER;
1624
+ if (aRank !== bRank) return aRank - bRank;
1606
1625
  return a.index - b.index;
1607
1626
  });
1608
1627
  const sortedLimits = entries.map(entry => entry.limit);
@@ -645,6 +645,9 @@ export class ExtensionUiController {
645
645
  timeout: dialogOptions?.timeout,
646
646
  onTimeout: dialogOptions?.onTimeout,
647
647
  tui: this.ctx.ui,
648
+ // Share the main prompt editor's autocomplete provider so the
649
+ // inline "Other (type your own)" editor supports `@` file links.
650
+ autocompleteProvider: dialogOptions?.customInput ? this.ctx.editor.getAutocompleteProvider() : undefined,
648
651
  outline: dialogOptions?.outline,
649
652
  wrapFocused: dialogOptions?.wrapFocused,
650
653
  scrollTitleRows,
@@ -70,6 +70,7 @@ const CALLBACK_SERVER_PROVIDERS = new Set<string>([
70
70
  "google-gemini-cli",
71
71
  "google-antigravity",
72
72
  "xai",
73
+ "grok-build",
73
74
  ]);
74
75
 
75
76
  const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
@@ -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, readLines, Snowflake } from "@gajae-code/utils";
15
17
  import type {
16
18
  ExtensionUIContext,
17
19
  ExtensionUIDialogOptions,
@@ -23,6 +25,7 @@ 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
30
  import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
28
31
  import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
@@ -70,13 +73,15 @@ 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
+
80
85
  function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info" {
81
86
  if (event.includes("denied")) return "denied";
82
87
  if (event.includes("exceeded")) return "exceeded";
@@ -156,6 +161,7 @@ export function requestRpcEditor(
156
161
  export async function runRpcMode(
157
162
  session: AgentSession,
158
163
  setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
164
+ options?: { listen?: string },
159
165
  ): Promise<never> {
160
166
  // Signal to RPC clients that the server is ready to accept commands
161
167
  // Suppress terminal notifications: they write \x07 (BEL) or OSC sequences directly to
@@ -164,10 +170,18 @@ export async function runRpcMode(
164
170
  // may write there.
165
171
  process.env.PI_NOTIFICATIONS = "off";
166
172
 
167
- process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`);
173
+ // Frames go to a swappable sink: stdout for stdio, the active client socket for a
174
+ // persistent --listen (UDS) server. Defaults to stdout, so the stdio path is unchanged.
175
+ let frameSink = (line: string): void => {
176
+ process.stdout.write(line);
177
+ };
168
178
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
169
- process.stdout.write(`${JSON.stringify(obj)}\n`);
179
+ frameSink(`${JSON.stringify(obj)}\n`);
170
180
  };
181
+ // stdio announces readiness immediately; the UDS server announces it per client connection.
182
+ if (!options?.listen) {
183
+ output({ type: "ready" });
184
+ }
171
185
  const emitRpcTitles = shouldEmitRpcTitles();
172
186
  const decodeError = (err: unknown): string => (err instanceof Error ? err.message : String(err));
173
187
 
@@ -231,11 +245,20 @@ export async function runRpcMode(
231
245
  // Shutdown request flag (wrapped in object to allow mutation with const)
232
246
  const shutdownState = { requested: false };
233
247
  let shutdownStarted = false;
248
+ // Tracks in-flight non-blocking command handlers so shutdown can drain them.
249
+ const inFlightCommands = new Set<Promise<void>>();
234
250
  async function shutdown(exitCode: number, reason: string): Promise<never> {
235
251
  if (shutdownStarted) {
236
252
  process.exit(exitCode);
237
253
  }
238
254
  shutdownStarted = true;
255
+ // Let in-flight non-blocking commands (bash/compact/handoff) finish and emit
256
+ // their responses before teardown, bounded so a never-resolving login cannot
257
+ // wedge shutdown (issue 13).
258
+ if (inFlightCommands.size > 0) {
259
+ await Promise.race([Promise.allSettled([...inFlightCommands]), Bun.sleep(5000)]);
260
+ }
261
+ await unregisterRpcSession(session.sessionId).catch(() => {});
239
262
  hostToolBridge.rejectAllPending(`${reason} before host tool execution completed`);
240
263
  hostUriBridge.clear(`${reason} before host URI request completed`);
241
264
  try {
@@ -399,7 +422,7 @@ export async function runRpcMode(
399
422
  }
400
423
 
401
424
  setTitle(title: string): void {
402
- // Title updates are low-value noise for most RPC hosts; opt in via PI_RPC_EMIT_TITLE=1.
425
+ // Title updates are low-value noise for most RPC hosts; opt in via GJC_RPC_EMIT_TITLE=1.
403
426
  if (!emitRpcTitles) return;
404
427
  this.output({
405
428
  type: "extension_ui_request",
@@ -514,6 +537,34 @@ export async function runRpcMode(
514
537
  unattendedControlPlane,
515
538
  });
516
539
 
540
+ // Cancellation commands must interrupt in-flight work, so they bypass the ordered
541
+ // queue and run immediately. Everything else runs through a serial chain so causal
542
+ // order is preserved (e.g. `get_state` after `bash` still observes the bash result)
543
+ // while the read loop itself never blocks — that is what lets a cancellation command
544
+ // reach a long-running `bash`/`compact`/`handoff`/`login` instead of being
545
+ // head-of-line-blocked behind it (issue 13).
546
+ const CANCELLATION_COMMANDS = new Set<RpcCommand["type"]>(["abort", "abort_bash", "abort_retry"]);
547
+ let orderedChain: Promise<void> = Promise.resolve();
548
+ const runCommand = async (command: RpcCommand): Promise<void> => {
549
+ try {
550
+ output(await handleCommand(command));
551
+ } catch (err) {
552
+ output(error(command.id, command.type, decodeError(err)));
553
+ }
554
+ };
555
+ const trackCommand = (task: Promise<void>): void => {
556
+ inFlightCommands.add(task);
557
+ void task.finally(() => inFlightCommands.delete(task));
558
+ };
559
+ const dispatchCommand = (command: RpcCommand): void => {
560
+ if (CANCELLATION_COMMANDS.has(command.type)) {
561
+ trackCommand(runCommand(command));
562
+ return;
563
+ }
564
+ orderedChain = orderedChain.then(() => runCommand(command));
565
+ trackCommand(orderedChain);
566
+ };
567
+
517
568
  /**
518
569
  * Check if shutdown was requested and perform shutdown if so.
519
570
  * Called after handling each command when waiting for the next command.
@@ -523,59 +574,126 @@ export async function runRpcMode(
523
574
  await shutdown(0, "RPC shutdown requested");
524
575
  }
525
576
 
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.
577
+ // Parse + route a single inbound JSONL frame. Shared by the stdio reader and the
578
+ // persistent UDS server so both transports use the same command surface.
528
579
  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
-
580
+ async function handleInboundLine(text: string): Promise<void> {
533
581
  let parsed: unknown;
534
582
  try {
535
583
  parsed = JSON.parse(text);
536
584
  } catch (err) {
537
585
  output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
538
- continue;
586
+ return;
539
587
  }
540
-
541
588
  try {
542
- // Handle extension UI responses
543
589
  if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
544
590
  const response = parsed as RpcExtensionUIResponse;
545
- const pending = pendingExtensionRequests.get(response.id);
546
- if (pending) {
547
- pending.resolve(response);
548
- }
549
- continue;
591
+ pendingExtensionRequests.get(response.id)?.resolve(response);
592
+ return;
550
593
  }
551
-
552
594
  if (isRpcHostToolResult(parsed)) {
553
595
  hostToolBridge.handleResult(parsed);
554
- continue;
596
+ return;
555
597
  }
556
-
557
598
  if (isRpcHostToolUpdate(parsed)) {
558
599
  hostToolBridge.handleUpdate(parsed);
559
- continue;
600
+ return;
560
601
  }
561
-
562
602
  if (isRpcHostUriResult(parsed)) {
563
603
  hostUriBridge.handleResult(parsed);
564
- continue;
604
+ return;
565
605
  }
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)
606
+ // Ordered commands run through a serial chain to preserve causal order; the
607
+ // reader never blocks, so cancellation commands stay responsive even while a
608
+ // long command is in flight (issue 13).
609
+ dispatchCommand(parsed as RpcCommand);
573
610
  await checkShutdownRequested();
574
611
  } catch (err) {
575
612
  output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
576
613
  }
577
614
  }
578
615
 
616
+ // Persistent UDS server (issue 09): keep the AgentSession alive across client
617
+ // reconnects instead of exiting on stdin EOF. Frames route to the active client
618
+ // socket; while no client is connected they are dropped (clients resync via
619
+ // get_state/get_messages on reconnect).
620
+ if (options?.listen) {
621
+ const socketPath = options.listen;
622
+ await fs.mkdir(path.dirname(socketPath), { recursive: true }).catch(() => {});
623
+ await fs.rm(socketPath, { force: true }).catch(() => {});
624
+ await registerRpcSession({
625
+ sessionId: session.sessionId,
626
+ pid: process.pid,
627
+ transport: "socket",
628
+ cwd: session.sessionManager.getCwd(),
629
+ model: session.model?.id,
630
+ startedAt: new Date().toISOString(),
631
+ endpoint: socketPath,
632
+ }).catch(() => {});
633
+
634
+ const noopSink = (_line: string): void => {};
635
+ let currentSocket: object | undefined;
636
+ let buf = "";
637
+ Bun.listen({
638
+ unix: socketPath,
639
+ socket: {
640
+ open(socket) {
641
+ currentSocket = socket;
642
+ buf = "";
643
+ frameSink = (line: string) => {
644
+ socket.write(line);
645
+ };
646
+ output({ type: "ready" });
647
+ },
648
+ data(socket, data) {
649
+ if (socket !== currentSocket) return;
650
+ buf += inputDecoder.decode(data);
651
+ while (true) {
652
+ const nl = buf.indexOf("\n");
653
+ if (nl < 0) break;
654
+ const text = buf.slice(0, nl).trim();
655
+ buf = buf.slice(nl + 1);
656
+ if (text) void handleInboundLine(text);
657
+ }
658
+ },
659
+ close(socket) {
660
+ if (socket === currentSocket) {
661
+ currentSocket = undefined;
662
+ frameSink = noopSink;
663
+ }
664
+ },
665
+ error() {},
666
+ },
667
+ });
668
+
669
+ const onSignal = (): void => {
670
+ void shutdown(0, "RPC socket server signal");
671
+ };
672
+ process.on("SIGINT", onSignal);
673
+ process.on("SIGTERM", onSignal);
674
+ // Block until an explicit shutdown (signal/extension) calls process.exit.
675
+ await new Promise<never>(() => {});
676
+ throw new Error("RPC socket server returned unexpectedly");
677
+ }
678
+
679
+ // Register this stdio RPC session so other processes can discover it (issue 10).
680
+ await registerRpcSession({
681
+ sessionId: session.sessionId,
682
+ pid: process.pid,
683
+ transport: "stdio",
684
+ cwd: session.sessionManager.getCwd(),
685
+ model: session.model?.id,
686
+ startedAt: new Date().toISOString(),
687
+ }).catch(() => {});
688
+
689
+ // Listen for JSONL input using Bun's stdin. Parse frame-by-frame so a malformed
690
+ // command reports a parse error without poisoning the whole long-lived RPC session.
691
+ for await (const line of readLines(Bun.stdin.stream())) {
692
+ const text = inputDecoder.decode(line).trim();
693
+ if (!text) continue;
694
+ await handleInboundLine(text);
695
+ }
696
+
579
697
  // stdin closed — RPC client is gone, flush durable state and exit cleanly
580
698
  await shutdown(0, "RPC client disconnected");
581
699
  throw new Error("RPC shutdown returned unexpectedly");