@gajae-code/coding-agent 0.15.6 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/root-flags.d.ts +14 -0
  4. package/dist/types/cli/update-cli.d.ts +13 -0
  5. package/dist/types/cli.d.ts +7 -0
  6. package/dist/types/commands/launch.d.ts +10 -0
  7. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  8. package/dist/types/config/file-lock.d.ts +5 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/settings-schema.d.ts +10 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  13. package/dist/types/crash/record-loader.d.ts +15 -0
  14. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  15. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  17. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  18. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  19. package/dist/types/master-mode/context.d.ts +21 -0
  20. package/dist/types/master-mode/first-request.d.ts +34 -0
  21. package/dist/types/master-mode/snapshot.d.ts +32 -0
  22. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  24. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  25. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  26. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  27. package/dist/types/modes/print-mode.d.ts +17 -0
  28. package/dist/types/modes/ui-language.d.ts +7 -1
  29. package/dist/types/sdk/broker/broker.d.ts +77 -1
  30. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  31. package/dist/types/sdk/broker/index.d.ts +3 -0
  32. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  33. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  34. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  35. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  36. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  37. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  38. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  39. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  40. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  41. package/dist/types/sdk/bus/index.d.ts +6 -0
  42. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  43. package/dist/types/sdk/cli/index.d.ts +1 -0
  44. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  45. package/dist/types/sdk/cli/rows.d.ts +4 -3
  46. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  47. package/dist/types/sdk/client/client.d.ts +3 -2
  48. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  49. package/dist/types/sdk/host/control/index.d.ts +1 -1
  50. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  51. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  52. package/dist/types/sdk/host/host.d.ts +10 -0
  53. package/dist/types/sdk/host/session-runtime.d.ts +52 -0
  54. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  55. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  56. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  57. package/dist/types/sdk/router/session-router.d.ts +2 -0
  58. package/dist/types/sdk/session.d.ts +16 -0
  59. package/dist/types/sdk/startup-capability.d.ts +2 -1
  60. package/dist/types/session/agent-session.d.ts +5 -0
  61. package/dist/types/session/credential-store-errors.d.ts +1 -0
  62. package/dist/types/session/session-manager.d.ts +65 -0
  63. package/dist/types/session/yield-queue.d.ts +5 -4
  64. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  65. package/dist/types/tools/bash.d.ts +4 -0
  66. package/dist/types/tools/gh.d.ts +8 -0
  67. package/dist/types/tools/index.d.ts +4 -0
  68. package/package.json +7 -7
  69. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  71. package/src/cli/accounts-cli.ts +9 -5
  72. package/src/cli/args.ts +17 -0
  73. package/src/cli/fast-help.ts +1 -0
  74. package/src/cli/root-flags.ts +5 -0
  75. package/src/cli/update-cli.ts +77 -11
  76. package/src/commands/launch.ts +86 -18
  77. package/src/commands/sdk.ts +113 -21
  78. package/src/config/autorouting-tier-map.ts +12 -0
  79. package/src/config/file-lock.ts +158 -83
  80. package/src/config/model-preset-registry.ts +1 -5
  81. package/src/config/model-profiles.ts +5 -5
  82. package/src/config/model-registry.ts +23 -0
  83. package/src/config/model-resolver.ts +5 -0
  84. package/src/config/settings-schema.ts +8 -1
  85. package/src/config/settings.ts +5 -0
  86. package/src/coordinator-mcp/projection-scan.ts +21 -0
  87. package/src/coordinator-mcp/question-state.ts +9 -11
  88. package/src/coordinator-mcp/server.ts +94 -20
  89. package/src/crash/index-store.ts +2 -0
  90. package/src/crash/record-loader.ts +10 -0
  91. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  92. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  93. package/src/gjc-runtime/session-state-lock.ts +79 -6
  94. package/src/gjc-runtime/session-state-sidecar.ts +929 -23
  95. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  96. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  97. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  98. package/src/hooks/skill-state.ts +6 -0
  99. package/src/internal-urls/docs-index.generated.ts +7 -7
  100. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  101. package/src/main.ts +57 -2
  102. package/src/master-mode/context.ts +50 -0
  103. package/src/master-mode/first-request.ts +65 -0
  104. package/src/master-mode/snapshot.ts +205 -0
  105. package/src/modes/acp/acp-agent.ts +605 -156
  106. package/src/modes/components/footer.ts +4 -2
  107. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  108. package/src/modes/components/oauth-selector.ts +6 -1
  109. package/src/modes/components/settings-selector.ts +1 -0
  110. package/src/modes/components/status-line/model-name.ts +53 -0
  111. package/src/modes/components/status-line/priority-row.ts +107 -0
  112. package/src/modes/components/status-line/segments.ts +23 -9
  113. package/src/modes/components/tool-status-header.ts +151 -15
  114. package/src/modes/controllers/input-controller.ts +28 -0
  115. package/src/modes/controllers/selector-controller.ts +191 -96
  116. package/src/modes/print-mode.ts +64 -0
  117. package/src/modes/ui-language.ts +49 -2
  118. package/src/prompts/system/master-mode.md +28 -0
  119. package/src/sdk/broker/broker.ts +1636 -26
  120. package/src/sdk/broker/endpoint-authority.ts +86 -0
  121. package/src/sdk/broker/ensure.ts +5 -0
  122. package/src/sdk/broker/index.ts +3 -0
  123. package/src/sdk/broker/lifecycle.ts +186 -41
  124. package/src/sdk/broker/master-capability.ts +232 -0
  125. package/src/sdk/broker/session-index.ts +121 -17
  126. package/src/sdk/broker/session-scope.ts +272 -0
  127. package/src/sdk/broker/spawn-authority.ts +1031 -0
  128. package/src/sdk/broker/spawn-substrate.ts +548 -0
  129. package/src/sdk/broker/state-version.ts +26 -14
  130. package/src/sdk/broker/transport.ts +1 -0
  131. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  132. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  133. package/src/sdk/bus/chat-daemon-runtime.ts +35 -4
  134. package/src/sdk/bus/index.ts +51 -4
  135. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  136. package/src/sdk/cli/index.ts +1 -0
  137. package/src/sdk/cli/master-cli.ts +259 -0
  138. package/src/sdk/cli/rows.ts +11 -8
  139. package/src/sdk/cli/session-cli.ts +264 -5
  140. package/src/sdk/client/client.ts +14 -8
  141. package/src/sdk/host/control/dispatch.ts +99 -38
  142. package/src/sdk/host/control/index.ts +2 -0
  143. package/src/sdk/host/control/operations.ts +2 -0
  144. package/src/sdk/host/control/runtime-gate.ts +203 -11
  145. package/src/sdk/host/host.ts +27 -2
  146. package/src/sdk/host/session-runtime.test.ts +210 -6
  147. package/src/sdk/host/session-runtime.ts +291 -49
  148. package/src/sdk/lifecycle/broker-client.ts +28 -0
  149. package/src/sdk/lifecycle/service.ts +279 -20
  150. package/src/sdk/lifecycle-session.ts +10 -2
  151. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  152. package/src/sdk/protocol/operation-registry.ts +13 -3
  153. package/src/sdk/router/session-router.ts +67 -59
  154. package/src/sdk/session.ts +357 -155
  155. package/src/sdk/startup-capability.ts +1 -0
  156. package/src/session/agent-session.ts +640 -103
  157. package/src/session/blob-store.ts +15 -5
  158. package/src/session/credential-store-errors.ts +2 -0
  159. package/src/session/internal/managed-session-storage.ts +153 -78
  160. package/src/session/session-manager.ts +337 -24
  161. package/src/session/yield-queue.ts +24 -10
  162. package/src/setup/frictionless-onboarding.ts +2 -0
  163. package/src/telemetry/events.ts +1138 -12
  164. package/src/tools/bash.ts +256 -42
  165. package/src/tools/gh.ts +104 -48
  166. package/src/tools/github-cache.ts +2 -2
  167. package/src/tools/index.ts +4 -0
package/src/tools/bash.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
3
4
  import type { Component } from "@gajae-code/tui";
4
5
  import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
@@ -17,6 +18,7 @@ import { InternalUrlRouter } from "../internal-urls";
17
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
18
19
  import { highlightCode, type Theme } from "../modes/theme/theme";
19
20
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
21
+ import { renderSpawnTable, runSdkSpawn, type SdkSpawnArgs } from "../sdk/cli/master-cli";
20
22
  import type { ArtifactManager } from "../session/artifacts";
21
23
  import type {
22
24
  ClientBridgeTerminalExitStatus,
@@ -87,6 +89,7 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
87
89
  const BASH_ERROR_MAX_BYTES = 4096;
88
90
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
89
91
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
92
+ const MASTER_CAPABILITY_ENV = "GJC_MASTER_CAPABILITY";
90
93
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
91
94
  const ACP_RELEASE_TIMEOUT_MS = 1_000;
92
95
  const READ_ONLY_BASH_ENV: Record<string, string> = {
@@ -601,6 +604,107 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
601
604
  return normalized;
602
605
  }
603
606
 
607
+ /**
608
+ * Return true only for a direct, shell-syntax-free `gjc sdk spawn` command.
609
+ *
610
+ * Master authority is process-local and must not become ambient state for an
611
+ * arbitrary shell pipeline. Keep a tiny lexer here instead of a prefix check:
612
+ * shell quoting is allowed for ordinary argument values, while operators,
613
+ * expansions, escapes, globbing, and line breaks all refuse the privileged
614
+ * environment injection path.
615
+ */
616
+ function strictDirectSdkSpawnTokens(command: string): string[] | undefined {
617
+ if (command.length === 0) return undefined;
618
+ const tokens: string[] = [];
619
+ let token = "";
620
+ let tokenStarted = false;
621
+ let quote: "'" | '"' | undefined;
622
+ for (const character of command) {
623
+ if (quote !== undefined) {
624
+ if (character === quote) {
625
+ quote = undefined;
626
+ tokenStarted = true;
627
+ } else {
628
+ token += character;
629
+ tokenStarted = true;
630
+ }
631
+ continue;
632
+ }
633
+ if (";&|<>()$`\\*?[]{}#!\n\r".includes(character)) return undefined;
634
+ if (character === "'" || character === '"') {
635
+ quote = character;
636
+ tokenStarted = true;
637
+ continue;
638
+ }
639
+ if (/\s/u.test(character)) {
640
+ if (tokenStarted) {
641
+ tokens.push(token);
642
+ token = "";
643
+ tokenStarted = false;
644
+ }
645
+ continue;
646
+ }
647
+ token += character;
648
+ tokenStarted = true;
649
+ }
650
+ if (quote !== undefined) return undefined;
651
+ if (tokenStarted) tokens.push(token);
652
+ return tokens.length >= 3 && tokens[0] === "gjc" && tokens[1] === "sdk" && tokens[2] === "spawn"
653
+ ? tokens
654
+ : undefined;
655
+ }
656
+
657
+ export function isStrictDirectSdkSpawnCommand(command: string): boolean {
658
+ return strictDirectSdkSpawnTokens(command) !== undefined;
659
+ }
660
+
661
+ export function parseDirectSdkSpawnArgs(command: string): SdkSpawnArgs {
662
+ const tokens = strictDirectSdkSpawnTokens(command);
663
+ if (tokens === undefined) throw new ToolError("Master spawn command is not a direct shell-safe invocation.");
664
+ const args: SdkSpawnArgs = {};
665
+ for (let index = 3; index < tokens.length; index++) {
666
+ const token = tokens[index]!;
667
+ if (token === "--json") {
668
+ args.json = true;
669
+ continue;
670
+ }
671
+ const equals = token.indexOf("=");
672
+ const name = equals < 0 ? token : token.slice(0, equals);
673
+ const value = equals < 0 ? tokens[++index] : token.slice(equals + 1);
674
+ if (value === undefined || value.length === 0) throw new ToolError(`Master spawn flag ${name} requires a value.`);
675
+ switch (name) {
676
+ case "--cwd":
677
+ args.cwd = value;
678
+ break;
679
+ case "--prompt":
680
+ args.prompt = value;
681
+ break;
682
+ case "--model":
683
+ args.model = value;
684
+ break;
685
+ case "--profile":
686
+ args.profile = value;
687
+ break;
688
+ case "--idempotency-key":
689
+ args.idempotencyKey = value;
690
+ break;
691
+ case "--agent-dir":
692
+ throw new ToolError("Master spawn cannot override the session agent directory.");
693
+ default:
694
+ throw new ToolError(`Unsupported master spawn flag: ${name}`);
695
+ }
696
+ }
697
+ return args;
698
+ }
699
+
700
+ export function masterCommandEnvOverrides(
701
+ env: Record<string, string> | undefined,
702
+ directMasterSpawn: boolean,
703
+ ): Record<string, string> {
704
+ if (directMasterSpawn) return {};
705
+ return Object.fromEntries(Object.entries(env ?? {}).filter(([key]) => key !== MASTER_CAPABILITY_ENV));
706
+ }
707
+
604
708
  function escapeBashEnvValueForDisplay(value: string): string {
605
709
  return value
606
710
  .replaceAll("\\", "\\\\")
@@ -874,6 +978,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
874
978
  notices?: readonly string[];
875
979
 
876
980
  resolvedEnv?: Record<string, string>;
981
+ directMasterSpawn: boolean;
877
982
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
878
983
  startBackgrounded: boolean;
879
984
  onCompletion?: () => void;
@@ -885,7 +990,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
885
990
  throw new ToolError("Background job manager unavailable for this session.");
886
991
  }
887
992
 
888
- const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
993
+ const label = options.directMasterSpawn
994
+ ? "gjc sdk spawn"
995
+ : options.command.length > 120
996
+ ? `${options.command.slice(0, 117)}...`
997
+ : options.command;
889
998
  let latestText = "";
890
999
  let backgrounded = options.startBackgrounded;
891
1000
  const runningDetails = (jobId: string): Record<string, unknown> | undefined =>
@@ -908,37 +1017,37 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
908
1017
 
909
1018
  let executionResult: BashResult | BashInteractiveResult | undefined;
910
1019
  try {
911
- const result = await executeBash(options.command, {
912
- cwd: options.commandCwd,
913
- settings: this.session.settings,
914
- sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
915
- timeout: options.timeoutMs,
916
- signal: runSignal,
917
- env: options.resolvedEnv,
918
- artifactPath,
919
- artifactId,
920
- artifactPublisher,
921
- spillThreshold,
922
- headBytes,
923
- oneShot: true,
924
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
925
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
926
- onChunk: chunk => {
927
- tailBuffer.append(chunk);
928
- latestText = tailBuffer.text();
929
- void reportProgress(latestText, runningDetails(jobId));
930
- },
931
- onRawChunk: chunk => {
932
- // Forward the unthrottled sanitized chunk to the async-job
933
- // substrate so the Monitor tool can read the complete process
934
- // stream by byte offset, independent of the throttled preview
935
- // path above.
936
- manager.appendOutput(jobId, chunk);
937
- },
938
- onMinimizedSave: async originalText => {
939
- return saveBashOriginalArtifact(this.session, originalText);
940
- },
941
- });
1020
+ const result = options.directMasterSpawn
1021
+ ? await this.#runDirectMasterSpawn(
1022
+ options.command,
1023
+ options.commandCwd,
1024
+ options.timeoutMs,
1025
+ runSignal,
1026
+ options.resolvedEnv,
1027
+ )
1028
+ : await executeBash(options.command, {
1029
+ cwd: options.commandCwd,
1030
+ settings: this.session.settings,
1031
+ sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
1032
+ timeout: options.timeoutMs,
1033
+ signal: runSignal,
1034
+ env: options.resolvedEnv,
1035
+ artifactPath,
1036
+ artifactId,
1037
+ artifactPublisher,
1038
+ spillThreshold,
1039
+ headBytes,
1040
+ oneShot: true,
1041
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1042
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1043
+ onChunk: chunk => {
1044
+ tailBuffer.append(chunk);
1045
+ latestText = tailBuffer.text();
1046
+ void reportProgress(latestText, runningDetails(jobId));
1047
+ },
1048
+ onRawChunk: chunk => manager.appendOutput(jobId, chunk),
1049
+ onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
1050
+ });
942
1051
  executionResult = result;
943
1052
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
944
1053
  requestedTimeoutSec: options.requestedTimeoutSec,
@@ -1061,6 +1170,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1061
1170
  command: string;
1062
1171
  commandCwd: string;
1063
1172
  resolvedEnv: Record<string, string>;
1173
+ directMasterSpawn: boolean;
1064
1174
  requestedTimeoutSec: number;
1065
1175
  timeoutSec: number;
1066
1176
  timeoutMs: number;
@@ -1206,14 +1316,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1206
1316
  // silently ignore the caller's override.
1207
1317
  const explicitAgentDirOverride =
1208
1318
  expandedEnv?.GJC_CODING_AGENT_DIR !== undefined || expandedEnv?.PI_CODING_AGENT_DIR !== undefined;
1319
+ // The master capability is a one-shot authority for the direct spawn CLI,
1320
+ // never ambient state for ordinary Bash or a chained/pipelined descendant.
1321
+ // Check both spellings so cwd normalization or URL expansion cannot turn a
1322
+ // command that contained shell syntax into a privileged one.
1323
+ const directMasterSpawn = isStrictDirectSdkSpawnCommand(rawCommand) && isStrictDirectSdkSpawnCommand(command);
1324
+ const masterCapability = directMasterSpawn ? this.session.getMasterBashCapability?.() : undefined;
1325
+ const masterOwnerSessionId = directMasterSpawn ? this.session.getMasterOwnerSessionId?.() : undefined;
1326
+ const commandEnvOverrides = masterCommandEnvOverrides(expandedEnv, directMasterSpawn);
1209
1327
  const resolvedEnv = {
1210
1328
  ...buildGjcRuntimeSessionEnv({
1211
1329
  sessionFile: null,
1212
1330
  sessionId: this.session.getSessionId?.(),
1213
1331
  cwd: this.session.cwd,
1214
1332
  }),
1215
- ...(sessionAgentDir && !explicitAgentDirOverride ? { GJC_CODING_AGENT_DIR: sessionAgentDir } : {}),
1216
- ...expandedEnv,
1333
+ ...(sessionAgentDir && (!explicitAgentDirOverride || directMasterSpawn)
1334
+ ? { GJC_CODING_AGENT_DIR: sessionAgentDir }
1335
+ : {}),
1336
+ ...commandEnvOverrides,
1337
+ ...(masterCapability ? { [MASTER_CAPABILITY_ENV]: masterCapability } : {}),
1338
+ ...(masterOwnerSessionId ? { GJC_MASTER_OWNER_SESSION_ID: masterOwnerSessionId } : {}),
1217
1339
  ...(this.session.bashRestrictionProfile === "read-only" ? READ_ONLY_BASH_ENV : {}),
1218
1340
  ...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),
1219
1341
  };
@@ -1245,7 +1367,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1245
1367
  const sleepAdvisory = longSleepAdvisory(command, timeoutSec);
1246
1368
  if (sleepAdvisory) notices.push(sleepAdvisory);
1247
1369
 
1248
- return { command, commandCwd, resolvedEnv, requestedTimeoutSec, timeoutSec, timeoutMs, notices };
1370
+ return {
1371
+ command,
1372
+ commandCwd,
1373
+ resolvedEnv,
1374
+ directMasterSpawn,
1375
+ requestedTimeoutSec,
1376
+ timeoutSec,
1377
+ timeoutMs,
1378
+ notices,
1379
+ };
1380
+ }
1381
+
1382
+ async #runDirectMasterSpawn(
1383
+ command: string,
1384
+ commandCwd: string,
1385
+ timeoutMs: number,
1386
+ signal: AbortSignal | undefined,
1387
+ resolvedEnv: Record<string, string> | undefined,
1388
+ ): Promise<BashResult> {
1389
+ const masterCapability = resolvedEnv?.[MASTER_CAPABILITY_ENV];
1390
+ const sessionAgentDir = resolvedEnv?.GJC_CODING_AGENT_DIR;
1391
+ if (masterCapability === undefined || sessionAgentDir === undefined)
1392
+ throw new ToolError("Master spawn authority context is unavailable.");
1393
+ const args = parseDirectSdkSpawnArgs(command);
1394
+ args.agentDir = sessionAgentDir;
1395
+ if (args.cwd !== undefined && !path.isAbsolute(args.cwd)) args.cwd = path.resolve(commandCwd, args.cwd);
1396
+ const request = runSdkSpawn(args, {
1397
+ env: {
1398
+ ...process.env,
1399
+ GJC_MASTER_CAPABILITY: masterCapability,
1400
+ GJC_SESSION_ID: resolvedEnv?.GJC_MASTER_OWNER_SESSION_ID ?? this.session.getSessionId?.() ?? undefined,
1401
+ GJC_CODING_AGENT_DIR: sessionAgentDir,
1402
+ },
1403
+ });
1404
+ const timeout = Bun.sleep(timeoutMs).then(() => {
1405
+ throw new ToolError(`Command timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
1406
+ });
1407
+ const aborted = Promise.withResolvers<never>();
1408
+ const onAbort = (): void => aborted.reject(new ToolAbortError("Command aborted"));
1409
+ if (signal?.aborted) onAbort();
1410
+ else signal?.addEventListener("abort", onAbort, { once: true });
1411
+ let result: Awaited<typeof request>;
1412
+ try {
1413
+ result = await Promise.race([request, timeout, aborted.promise]);
1414
+ } finally {
1415
+ signal?.removeEventListener("abort", onAbort);
1416
+ }
1417
+ const output = args.json ? JSON.stringify(result.rendered) : renderSpawnTable(result.rendered);
1418
+ const outputBytes = Buffer.byteLength(output, "utf8");
1419
+ const outputLines = output.split("\n").length;
1420
+ return {
1421
+ output,
1422
+ exitCode: result.exitCode,
1423
+ cancelled: false,
1424
+ truncated: false,
1425
+ totalLines: outputLines,
1426
+ totalBytes: outputBytes,
1427
+ outputLines,
1428
+ outputBytes,
1429
+ };
1430
+ }
1431
+
1432
+ async #executeDirectMasterSpawn(
1433
+ command: string,
1434
+ commandCwd: string,
1435
+ timeoutSec: number,
1436
+ timeoutMs: number,
1437
+ requestedTimeoutSec: number,
1438
+ notices: readonly string[],
1439
+ signal: AbortSignal | undefined,
1440
+ resolvedEnv: Record<string, string>,
1441
+ ): Promise<AgentToolResult<BashToolDetails>> {
1442
+ const result = await this.#runDirectMasterSpawn(command, commandCwd, timeoutMs, signal, resolvedEnv);
1443
+ return this.#buildCompletedResult(result, timeoutSec, { requestedTimeoutSec, notices });
1249
1444
  }
1250
1445
 
1251
1446
  /**
@@ -1343,8 +1538,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1343
1538
  spillThreshold,
1344
1539
  headBytes,
1345
1540
  oneShot: true,
1346
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1347
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1541
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1542
+ disableShellSnapshot:
1543
+ this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1348
1544
  onChunk: chunk => {
1349
1545
  tailBuffer.append(chunk);
1350
1546
  void reportProgress(tailBuffer.text(), {
@@ -1416,11 +1612,25 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1416
1612
  command,
1417
1613
  commandCwd,
1418
1614
  resolvedEnv,
1615
+ directMasterSpawn,
1419
1616
  requestedTimeoutSec,
1420
1617
  timeoutSec,
1421
1618
  timeoutMs,
1422
1619
  notices: pendingNotices,
1423
1620
  } = prepared;
1621
+ const masterCapability = directMasterSpawn ? resolvedEnv[MASTER_CAPABILITY_ENV] : undefined;
1622
+ if (directMasterSpawn && masterCapability !== undefined && !asyncRequested) {
1623
+ return await this.#executeDirectMasterSpawn(
1624
+ command,
1625
+ commandCwd,
1626
+ timeoutSec,
1627
+ timeoutMs,
1628
+ requestedTimeoutSec,
1629
+ pendingNotices,
1630
+ signal,
1631
+ resolvedEnv,
1632
+ );
1633
+ }
1424
1634
 
1425
1635
  if (asyncRequested) {
1426
1636
  // Availability is endpoint-first: a concurrent top-level session
@@ -1440,6 +1650,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1440
1650
  notices: pendingNotices,
1441
1651
 
1442
1652
  resolvedEnv,
1653
+ directMasterSpawn,
1443
1654
  onUpdate,
1444
1655
  startBackgrounded: true,
1445
1656
  toolCallId,
@@ -1457,7 +1668,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1457
1668
  // Skip when pty=true (PTY needs the local terminal UI).
1458
1669
  const clientBridge =
1459
1670
  this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
1460
- const clientTerminalActive = Boolean(clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty);
1671
+ const clientTerminalActive = Boolean(
1672
+ !directMasterSpawn && clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty,
1673
+ );
1461
1674
 
1462
1675
  // Run non-PTY bash through the managed job path so Ctrl+B-twice fold-on-demand works
1463
1676
  // even when auto-background is disabled. When a client terminal will handle the
@@ -1487,6 +1700,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1487
1700
  notices: pendingNotices,
1488
1701
 
1489
1702
  resolvedEnv,
1703
+ directMasterSpawn,
1490
1704
  onUpdate,
1491
1705
  startBackgrounded,
1492
1706
  onCompletion: () => {
@@ -2200,7 +2414,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2200
2414
  const artifactPublisher = createBashArtifactPublisher(this.session);
2201
2415
 
2202
2416
  const interactiveUi =
2203
- this.session.bashRestrictionProfile === "read-only"
2417
+ this.session.bashRestrictionProfile === "read-only" || directMasterSpawn
2204
2418
  ? undefined
2205
2419
  : canUseInteractiveBashPty(pty, ctx)
2206
2420
  ? ctx?.ui
@@ -2368,7 +2582,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2368
2582
  cwd: commandCwd,
2369
2583
  settings: this.session.settings,
2370
2584
  sessionKey: this.session.getSessionId?.() ?? undefined,
2371
- oneShot: this.session.bashRestrictionProfile === "read-only",
2585
+ oneShot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2372
2586
  timeout: timeoutMs,
2373
2587
  signal,
2374
2588
  env: resolvedEnv,
@@ -2379,8 +2593,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2379
2593
  headBytes,
2380
2594
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
2381
2595
  onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
2382
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
2383
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
2596
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2597
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2384
2598
  });
2385
2599
  ptyFoldUnregister?.();
2386
2600
  // Folded: the foreground was settled by the fold, so return the background
package/src/tools/gh.ts CHANGED
@@ -2806,6 +2806,14 @@ export interface PrDiffFile {
2806
2806
  changeType: "modified" | "added" | "deleted" | "renamed" | "binary";
2807
2807
  /** Pre-image path for renames/deletes; same as `path` otherwise. */
2808
2808
  oldPath?: string;
2809
+ /**
2810
+ * Set when `path` kept its C-style escaped source form because the quoted
2811
+ * token held invalid UTF-8 or a malformed escape; distinguishes it from a
2812
+ * valid path whose decoded text looks identical.
2813
+ */
2814
+ pathEscaped?: boolean;
2815
+ /** Same as {@link PrDiffFile.pathEscaped}, for `oldPath`. */
2816
+ oldPathEscaped?: boolean;
2809
2817
  /** Byte offset of the section's `diff --git` line in the unified diff. */
2810
2818
  startOffset: number;
2811
2819
  /** Byte offset of the next section (or end-of-text). */
@@ -2861,6 +2869,8 @@ export function parsePrUnifiedDiff(text: string): PrDiffPayload {
2861
2869
  interface ParsedDiffHeaderToken {
2862
2870
  value: string;
2863
2871
  nextIndex: number;
2872
+ /** Set when the token kept its C-style escaped source form. */
2873
+ escaped?: boolean;
2864
2874
  }
2865
2875
 
2866
2876
  function skipDiffHeaderSpaces(text: string, index: number): number {
@@ -2869,63 +2879,87 @@ function skipDiffHeaderSpaces(text: string, index: number): number {
2869
2879
  return i;
2870
2880
  }
2871
2881
 
2872
- function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken {
2873
- const next = text.charAt(slashIndex + 1);
2874
- if (next === "") return { value: "\\", nextIndex: slashIndex + 1 };
2882
+ interface ParsedDiffQuotedEscape {
2883
+ byte: number;
2884
+ nextIndex: number;
2885
+ }
2875
2886
 
2876
- if (next >= "0" && next <= "7") {
2877
- let end = slashIndex + 1;
2878
- while (end < text.length && end < slashIndex + 4) {
2879
- const digit = text.charAt(end);
2880
- if (digit < "0" || digit > "7") break;
2881
- end += 1;
2882
- }
2883
- return {
2884
- value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)),
2885
- nextIndex: end,
2886
- };
2887
- }
2887
+ const DIFF_PATH_ENCODER = new TextEncoder();
2888
+ const DIFF_PATH_DECODER = new TextDecoder("utf-8", { fatal: true });
2888
2889
 
2889
- switch (next) {
2890
- case "a":
2891
- return { value: "\x07", nextIndex: slashIndex + 2 };
2892
- case "b":
2893
- return { value: "\b", nextIndex: slashIndex + 2 };
2894
- case "f":
2895
- return { value: "\f", nextIndex: slashIndex + 2 };
2896
- case "n":
2897
- return { value: "\n", nextIndex: slashIndex + 2 };
2898
- case "r":
2899
- return { value: "\r", nextIndex: slashIndex + 2 };
2900
- case "t":
2901
- return { value: "\t", nextIndex: slashIndex + 2 };
2902
- case "v":
2903
- return { value: "\v", nextIndex: slashIndex + 2 };
2904
- case "\\":
2905
- case '"':
2906
- return { value: next, nextIndex: slashIndex + 2 };
2907
- default:
2908
- return { value: next, nextIndex: slashIndex + 2 };
2890
+ const DIFF_NAMED_ESCAPE_BYTES: Record<string, number> = {
2891
+ a: 0x07,
2892
+ b: 0x08,
2893
+ f: 0x0c,
2894
+ n: 0x0a,
2895
+ r: 0x0d,
2896
+ t: 0x09,
2897
+ v: 0x0b,
2898
+ "\\": 0x5c,
2899
+ '"': 0x22,
2900
+ };
2901
+
2902
+ /**
2903
+ * Git quotes paths C-style: its named escapes plus exactly three octal digits
2904
+ * per byte (leading digit 0-3), so a multi-byte UTF-8 character arrives as
2905
+ * consecutive octal escapes. Anything else is malformed for this grammar.
2906
+ */
2907
+ function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffQuotedEscape | undefined {
2908
+ const first = text.charAt(slashIndex + 1);
2909
+ if (first >= "0" && first <= "3") {
2910
+ const second = text.charAt(slashIndex + 2);
2911
+ const third = text.charAt(slashIndex + 3);
2912
+ if (second < "0" || second > "7" || third < "0" || third > "7") return undefined;
2913
+ return { byte: Number.parseInt(text.slice(slashIndex + 1, slashIndex + 4), 8), nextIndex: slashIndex + 4 };
2909
2914
  }
2915
+ const named = DIFF_NAMED_ESCAPE_BYTES[first];
2916
+ if (named === undefined) return undefined;
2917
+ return { byte: named, nextIndex: slashIndex + 2 };
2910
2918
  }
2911
2919
 
2920
+ /**
2921
+ * Escaped bytes are decoded as strict UTF-8. A token holding invalid UTF-8 or
2922
+ * a malformed escape keeps its escaped source form, so distinct non-UTF-8
2923
+ * byte sequences never collapse into one replacement-character path.
2924
+ */
2912
2925
  function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
2913
2926
  if (text.charAt(startIndex) !== '"') return undefined;
2914
- let value = "";
2915
- for (let i = startIndex + 1; i < text.length; i += 1) {
2916
- const ch = text.charAt(i);
2917
- if (ch === '"') return { value, nextIndex: i + 1 };
2927
+ const bytes: number[] = [];
2928
+ let malformed = false;
2929
+ for (let i = startIndex + 1; i < text.length; ) {
2930
+ const codePoint = text.codePointAt(i) as number;
2931
+ const ch = String.fromCodePoint(codePoint);
2932
+ if (ch === '"') {
2933
+ if (!malformed) {
2934
+ try {
2935
+ return { value: DIFF_PATH_DECODER.decode(Uint8Array.from(bytes)), nextIndex: i + 1 };
2936
+ } catch {}
2937
+ }
2938
+ return { value: text.slice(startIndex + 1, i), nextIndex: i + 1, escaped: true };
2939
+ }
2918
2940
  if (ch !== "\\") {
2919
- value += ch;
2941
+ bytes.push(...DIFF_PATH_ENCODER.encode(ch));
2942
+ i += ch.length;
2920
2943
  continue;
2921
2944
  }
2922
2945
  const escaped = parseDiffQuotedEscape(text, i);
2923
- value += escaped.value;
2924
- i = escaped.nextIndex - 1;
2946
+ if (escaped === undefined) {
2947
+ malformed = true;
2948
+ i += 2;
2949
+ continue;
2950
+ }
2951
+ bytes.push(escaped.byte);
2952
+ i = escaped.nextIndex;
2925
2953
  }
2926
2954
  return undefined;
2927
2955
  }
2928
2956
 
2957
+ function parseDiffPathLine(line: string, prefix: string): { value: string; escaped?: boolean } {
2958
+ const rest = line.slice(prefix.length);
2959
+ const token = parseDiffQuotedToken(rest, 0);
2960
+ return token ? { value: token.value, escaped: token.escaped } : { value: rest };
2961
+ }
2962
+
2929
2963
  function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
2930
2964
  const start = skipDiffHeaderSpaces(text, startIndex);
2931
2965
  if (start >= text.length) return undefined;
@@ -2940,7 +2974,14 @@ function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | und
2940
2974
  return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
2941
2975
  }
2942
2976
 
2943
- function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } {
2977
+ interface PrDiffHeaderPaths {
2978
+ oldPath?: string;
2979
+ newPath?: string;
2980
+ oldPathEscaped?: boolean;
2981
+ newPathEscaped?: boolean;
2982
+ }
2983
+
2984
+ function parsePrDiffHeaderPaths(header: string): PrDiffHeaderPaths {
2944
2985
  const trail = header.slice("diff --git ".length);
2945
2986
  if (trail.startsWith('"')) {
2946
2987
  const oldToken = parseDiffQuotedToken(trail, 0);
@@ -2950,6 +2991,8 @@ function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: s
2950
2991
  return {
2951
2992
  oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"),
2952
2993
  newPath: stripPrDiffPathPrefix(newToken.value, "b/"),
2994
+ oldPathEscaped: oldToken.escaped,
2995
+ newPathEscaped: newToken.escaped,
2953
2996
  };
2954
2997
  }
2955
2998
 
@@ -2980,6 +3023,8 @@ function parsePrDiffSection(section: string, startOffset: number, endOffset: num
2980
3023
  const headerPaths = parsePrDiffHeaderPaths(header);
2981
3024
  let oldPath = headerPaths.oldPath;
2982
3025
  let newPath = headerPaths.newPath;
3026
+ let oldPathEscaped = headerPaths.oldPathEscaped;
3027
+ let newPathEscaped = headerPaths.newPathEscaped;
2983
3028
 
2984
3029
  let changeType: PrDiffFile["changeType"] = "modified";
2985
3030
  let isBinary = false;
@@ -2999,11 +3044,11 @@ function parsePrDiffSection(section: string, startOffset: number, endOffset: num
2999
3044
  }
3000
3045
  if (line.startsWith("rename from ")) {
3001
3046
  changeType = "renamed";
3002
- oldPath = line.slice("rename from ".length);
3047
+ ({ value: oldPath, escaped: oldPathEscaped } = parseDiffPathLine(line, "rename from "));
3003
3048
  continue;
3004
3049
  }
3005
3050
  if (line.startsWith("rename to ")) {
3006
- newPath = line.slice("rename to ".length);
3051
+ ({ value: newPath, escaped: newPathEscaped } = parseDiffPathLine(line, "rename to "));
3007
3052
  continue;
3008
3053
  }
3009
3054
  if (line.startsWith("Binary files ") && line.endsWith(" differ")) {
@@ -3028,8 +3073,15 @@ function parsePrDiffSection(section: string, startOffset: number, endOffset: num
3028
3073
  deletions = 0;
3029
3074
  }
3030
3075
 
3031
- const displayPath =
3032
- changeType === "deleted" ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
3076
+ const preferOld = changeType === "deleted";
3077
+ const displayPath = preferOld ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
3078
+ const displayEscaped = preferOld
3079
+ ? oldPath !== undefined
3080
+ ? oldPathEscaped
3081
+ : newPathEscaped
3082
+ : newPath !== undefined
3083
+ ? newPathEscaped
3084
+ : oldPathEscaped;
3033
3085
  const file: PrDiffFile = {
3034
3086
  path: displayPath,
3035
3087
  additions,
@@ -3038,8 +3090,12 @@ function parsePrDiffSection(section: string, startOffset: number, endOffset: num
3038
3090
  startOffset,
3039
3091
  endOffset,
3040
3092
  };
3041
- if (oldPath && oldPath !== displayPath) {
3093
+ if (displayEscaped) {
3094
+ file.pathEscaped = true;
3095
+ }
3096
+ if (oldPath && (oldPath !== displayPath || oldPathEscaped !== displayEscaped)) {
3042
3097
  file.oldPath = oldPath;
3098
+ if (oldPathEscaped) file.oldPathEscaped = true;
3043
3099
  }
3044
3100
  return file;
3045
3101
  }
@@ -103,7 +103,7 @@ export function openDb(): Database | null {
103
103
  // than running an in-place ALTER dance.
104
104
  const userVersion = (db.prepare("PRAGMA user_version").get() as { user_version?: number } | undefined)
105
105
  ?.user_version;
106
- if (userVersion !== undefined && userVersion < 3) {
106
+ if (userVersion !== undefined && userVersion < 4) {
107
107
  db.run("DROP TABLE IF EXISTS github_view_cache");
108
108
  }
109
109
  db.run(`
@@ -120,7 +120,7 @@ export function openDb(): Database | null {
120
120
  PRIMARY KEY (auth_key, repo, kind, number, include_comments)
121
121
  );
122
122
  CREATE INDEX IF NOT EXISTS idx_github_view_cache_fetched ON github_view_cache(fetched_at);
123
- PRAGMA user_version = 3;
123
+ PRAGMA user_version = 4;
124
124
  `);
125
125
  protectDbFiles(dbPath);
126
126
  cachedDb = db;