@gajae-code/coding-agent 0.15.4 → 0.15.6

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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -17,6 +17,7 @@ import type {
17
17
  } from "../session/agent-session";
18
18
  import type { ArtifactManager } from "../session/artifacts";
19
19
  import type { ClientBridge } from "../session/client-bridge";
20
+ import type { FoldAdapter } from "../session/fold-coordinator";
20
21
  import type { CustomMessage } from "../session/messages";
21
22
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
22
23
  import type { SkillActiveEntry } from "../skill-state/active-state";
@@ -235,12 +236,13 @@ export interface ToolSession {
235
236
  assertEvalExecutionAllowed?: () => void;
236
237
  /** Track tool-owned eval work so session disposal can await/abort it like direct session eval runs. */
237
238
  trackEvalExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
238
- /** Register a safe request handler that asks a managed foreground bash call to fold into a background job. */
239
- registerForegroundBashBackgroundRequestHandler?: (handler: () => void) => () => void;
240
- /** Whether a managed foreground bash call is currently foldable into a background job. */
239
+ /** Register a foldable foreground wait so the fold chord can move it into a background job. */
240
+ registerForegroundFoldParticipant?: (adapter: FoldAdapter) => () => void;
241
+ /** Whether a foreground wait is currently foldable into a background job. */
241
242
  hasForegroundBashBackgroundRequestHandler?: () => boolean;
242
- /** Request that the active managed foreground bash call fold into a background job, if supported. */
243
- requestForegroundBashBackground?: () => boolean;
243
+ /** Request that the active foreground wait fold into a background job, if supported. */
244
+ requestForegroundBashBackground?: () => Promise<boolean>;
245
+
244
246
  /** Get the session-owned or inherited async job manager. */
245
247
  getAsyncJobManager?: () => AsyncJobManager | undefined;
246
248
  /** Resolves when the session queues user steering (or `signal` aborts) without consuming it; wait-style tools use this to end their observation early. */
@@ -11,17 +11,20 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
11
11
  const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
12
12
  const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
13
13
  const FILE_RAW_ONLY_RE = /^raw$/i;
14
- // Schemes whose authority grammar is identifier-shaped and may therefore carry
15
- // a read selector immediately after the authority. Colons in a path, query, or
16
- // fragment remain part of the URL and are never considered selectors.
17
- const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
14
+ // Schemes whose authority grammar is identifier-shaped and cannot contain a
15
+ // literal colon. Colons in a path, query, or fragment remain part of the URL
16
+ // and are never considered selectors.
17
+ const INTERNAL_SCHEMES_WITH_UNAMBIGUOUS_AUTHORITIES: Record<string, true> = {
18
18
  agent: true,
19
19
  artifact: true,
20
- issue: true,
21
- local: true,
22
20
  memory: true,
23
- gjc: true,
21
+ issue: true,
24
22
  pr: true,
23
+ };
24
+ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
25
+ ...INTERNAL_SCHEMES_WITH_UNAMBIGUOUS_AUTHORITIES,
26
+ gjc: true,
27
+ local: true,
25
28
  rule: true,
26
29
  skill: true,
27
30
  };
@@ -33,6 +36,18 @@ function normalizeUnicodeSpaces(str: string): string {
33
36
  return str.replace(UNICODE_SPACES, " ");
34
37
  }
35
38
 
39
+ function rawSkillPrefixForName(authority: string, skillName: string): string | undefined {
40
+ for (let index = 1; index < authority.length; index++) {
41
+ if (authority[index] !== ":") continue;
42
+ try {
43
+ if (decodeURIComponent(authority.slice(0, index)) === skillName) return authority.slice(0, index);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
36
51
  function tryMacOSScreenshotPath(filePath: string): string {
37
52
  // macOS writes a narrow no-break space before AM/PM, but the model normalizes it
38
53
  // to a plain space. The original name is not always `…PM.png`: attachment paths
@@ -149,12 +164,11 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
149
164
  /**
150
165
  * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
151
166
  *
152
- * Artifact authorities are generated identifiers, so their complete tail is
153
- * unambiguously a selector and malformed selectors can be rejected before
154
- * resolving the artifact. Other resources may legitimately use colons in
155
- * their identities, so they only lose strict, recognized selector suffixes.
156
- * Skill authorities additionally use the active registry to distinguish a
157
- * skill name from its selector without making path-utils depend on skills.
167
+ * Selector-capable internal authorities use identifier-shaped resource names,
168
+ * so an authority-only `:<tail>` is an explicit read selector even when the
169
+ * selector is malformed. Skill authorities are the exception: namespaced
170
+ * skill names already contain a colon, so the active registry gets first
171
+ * refusal and the fallback recognizes a later colon as the selector boundary.
158
172
  */
159
173
  export function splitInternalUrlSel(
160
174
  rawPath: string,
@@ -167,32 +181,66 @@ export function splitInternalUrlSel(
167
181
 
168
182
  const schemeEnd = schemeMatch[0].length;
169
183
  const authorityTerminator = rawPath.slice(schemeEnd).search(/[/?#]/);
170
- if (authorityTerminator !== -1) return { path: rawPath };
171
- const authority = rawPath.slice(schemeEnd);
184
+ const authorityEnd = authorityTerminator === -1 ? rawPath.length : schemeEnd + authorityTerminator;
185
+ const authority = rawPath.slice(schemeEnd, authorityEnd);
186
+ const authoritySuffix = rawPath.slice(authorityEnd);
172
187
  const firstColon = authority.indexOf(":");
173
188
  if (firstColon === -1) return { path: rawPath };
174
-
175
- if (scheme === "artifact") {
176
- return { path: rawPath.slice(0, schemeEnd + firstColon), sel: authority.slice(firstColon + 1) };
177
- }
189
+ if (firstColon === 0) return { path: rawPath };
178
190
 
179
191
  if (scheme === "skill") {
180
192
  const activeSkillNames = options.activeSkillNames ?? [];
181
- if (activeSkillNames.includes(authority)) return { path: rawPath };
182
- const skillName = activeSkillNames
183
- .filter(name => authority.startsWith(`${name}:`))
184
- .sort((a, b) => b.length - a.length)[0];
185
- if (skillName) {
193
+ let decodedAuthority: string | undefined;
194
+ try {
195
+ decodedAuthority = decodeURIComponent(authority);
196
+ } catch {
197
+ // Let the resolver report malformed URL encoding when no selector boundary
198
+ // can be established from the raw authority.
199
+ }
200
+ if (decodedAuthority !== undefined && activeSkillNames.includes(decodedAuthority)) return { path: rawPath };
201
+ const rawSkillPrefix = activeSkillNames
202
+ .map(name => ({ name, prefix: rawSkillPrefixForName(authority, name) }))
203
+ .filter(item => item.prefix !== undefined)
204
+ .sort((a, b) => b.name.length - a.name.length)[0]?.prefix;
205
+ if (rawSkillPrefix) {
186
206
  return {
187
- path: `${rawPath.slice(0, schemeEnd)}${skillName}`,
188
- sel: authority.slice(skillName.length + 1),
207
+ path: `${rawPath.slice(0, schemeEnd)}${rawSkillPrefix}${authoritySuffix}`,
208
+ sel: authority.slice(rawSkillPrefix.length + 1),
189
209
  };
190
210
  }
211
+ const strict = splitPathAndSel(authority);
212
+ if (strict.sel !== undefined) {
213
+ return { path: `${rawPath.slice(0, schemeEnd)}${strict.path}${authoritySuffix}`, sel: strict.sel };
214
+ }
215
+ const firstTail = authority.slice(firstColon + 1);
216
+ if (FILE_LINE_RANGE_RE.test(firstTail)) {
217
+ return { path: `${rawPath.slice(0, schemeEnd + firstColon)}${authoritySuffix}`, sel: firstTail };
218
+ }
219
+ const selectorColon = authority.indexOf(":", firstColon + 1);
220
+ if (selectorColon === -1) return { path: rawPath };
221
+ return {
222
+ path: `${rawPath.slice(0, schemeEnd + selectorColon)}${authoritySuffix}`,
223
+ sel: authority.slice(selectorColon + 1),
224
+ };
225
+ }
226
+
227
+ if (!INTERNAL_SCHEMES_WITH_UNAMBIGUOUS_AUTHORITIES[scheme]) {
228
+ // A path/query/fragment after a path-like authority makes a colon-bearing
229
+ // resource identity indistinguishable from an authority selector. Preserve
230
+ // the complete URL; path-like selectors are accepted only on authority-only
231
+ // URLs, where the existing strict grammar is the sole interpretation.
232
+ if (authoritySuffix !== "") return { path: rawPath };
233
+ const strict = splitPathAndSel(authority);
234
+ if (strict.sel !== undefined) {
235
+ return { path: `${rawPath.slice(0, schemeEnd)}${strict.path}`, sel: strict.sel };
236
+ }
237
+ return { path: rawPath };
191
238
  }
192
239
 
193
- const strict = splitPathAndSel(authority);
194
- if (strict.sel === undefined) return { path: rawPath };
195
- return { path: `${rawPath.slice(0, schemeEnd)}${strict.path}`, sel: strict.sel };
240
+ return {
241
+ path: `${rawPath.slice(0, schemeEnd + firstColon)}${authoritySuffix}`,
242
+ sel: authority.slice(firstColon + 1),
243
+ };
196
244
  }
197
245
 
198
246
  function assertNotInternalUrl(expanded: string, original: string): void {
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Prevent agent-authored tmux input from being delivered to the agent pane.
3
+ *
4
+ * This check runs before bash spawns anything. It is intentionally limited to
5
+ * tmux input verbs; inspection and control of another pane remain available.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+
10
+ const INPUT_VERBS = new Set(["send-keys", "paste-buffer", "send-prefix"]);
11
+ const SHELL_RUNNERS = new Set(["sh", "bash", "dash", "zsh", "ksh", "busybox"]);
12
+ const COMMAND_WRAPPERS = new Set(["eval", "exec", "command", "builtin", "nohup", "time"]);
13
+ const CONTROL_WORDS = new Set(["then", "do", "else", "fi", "done", "esac"]);
14
+ const MAX_INDIRECTION_DEPTH = 4;
15
+
16
+ export interface TmuxSelfInjectionResult {
17
+ block: boolean;
18
+ reason?: string;
19
+ }
20
+
21
+ export type TmuxPaneResolver = (options: {
22
+ socketArgs: string[];
23
+ target: string;
24
+ env: Record<string, string>;
25
+ }) => Promise<string | undefined>;
26
+
27
+ export interface TmuxSelfInjectionOptions {
28
+ env?: Record<string, string | undefined>;
29
+ cwd?: string;
30
+ resolvePaneId?: TmuxPaneResolver;
31
+ }
32
+
33
+ interface Token {
34
+ text: string;
35
+ quoted: boolean;
36
+ commandStart: boolean;
37
+ }
38
+
39
+ function isSeparator(ch: string): boolean {
40
+ return ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")" || ch === "\n";
41
+ }
42
+
43
+ function isAssignment(text: string): boolean {
44
+ return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(text);
45
+ }
46
+
47
+ /** Small quote-aware lexer. Unknown shell syntax is represented conservatively. */
48
+ function tokenize(command: string): Token[] {
49
+ const tokens: Token[] = [];
50
+ let text = "";
51
+ let quoted = false;
52
+ let quote: "'" | '"' | undefined;
53
+ let atCommandStart = true;
54
+ let wrapperPending = false;
55
+ let previous = "";
56
+
57
+ const flush = () => {
58
+ if (text.length === 0) return;
59
+ const token: Token = { text, quoted, commandStart: atCommandStart };
60
+ tokens.push(token);
61
+ if (atCommandStart) {
62
+ if (isAssignment(text) || CONTROL_WORDS.has(text) || wrapperPending) {
63
+ atCommandStart = true;
64
+ } else if (COMMAND_WRAPPERS.has(text)) {
65
+ atCommandStart = true;
66
+ wrapperPending = true;
67
+ } else if (text === "sudo" || text === "env") {
68
+ atCommandStart = true;
69
+ wrapperPending = true;
70
+ } else {
71
+ atCommandStart = false;
72
+ wrapperPending = false;
73
+ }
74
+ } else if (wrapperPending) {
75
+ // A wrapper's options and option arguments are not command names.
76
+ // The first ordinary word after them is handled by the special case
77
+ // below when the next token is seen.
78
+ atCommandStart = false;
79
+ }
80
+ text = "";
81
+ quoted = false;
82
+ };
83
+
84
+ for (let index = 0; index < command.length; index++) {
85
+ const ch = command[index];
86
+ if (quote !== undefined) {
87
+ if (ch === quote) {
88
+ quote = undefined;
89
+ continue;
90
+ }
91
+ if (quote === '"' && ch === "\\" && index + 1 < command.length) {
92
+ text += command[++index];
93
+ continue;
94
+ }
95
+ text += ch;
96
+ continue;
97
+ }
98
+ if (ch === "'" || ch === '"') {
99
+ quote = ch;
100
+ quoted = true;
101
+ continue;
102
+ }
103
+ if (ch === "\\" && index + 1 < command.length) {
104
+ text += command[++index];
105
+ quoted = true;
106
+ continue;
107
+ }
108
+ if (/\s/.test(ch)) {
109
+ flush();
110
+ previous = ch;
111
+ continue;
112
+ }
113
+ if (isSeparator(ch)) {
114
+ flush();
115
+ atCommandStart = true;
116
+ wrapperPending = false;
117
+ previous = ch;
118
+ continue;
119
+ }
120
+ // `sudo -u user tmux` and `env FOO=bar tmux` need the wrapped command
121
+ // recognized. Options, option arguments, and environment assignments
122
+ // stay outside command position.
123
+ if (text.length === 0 && wrapperPending && previous && /\s/.test(previous)) {
124
+ if (text.length === 0 && ch === "-") {
125
+ // The option itself is not a command; its argument is also skipped.
126
+ atCommandStart = false;
127
+ }
128
+ }
129
+ if (text.length === 0 && wrapperPending && atCommandStart) {
130
+ atCommandStart = true;
131
+ }
132
+ text += ch;
133
+ }
134
+ flush();
135
+ return tokens;
136
+ }
137
+
138
+ interface Invocation {
139
+ verb: string;
140
+ args: string[];
141
+ socketArgs: string[];
142
+ socket?: { flag: "-L" | "-S"; value: string };
143
+ }
144
+
145
+ function parseInvocation(tokens: Token[]): Invocation | undefined {
146
+ let index = 0;
147
+ const socketArgs: string[] = [];
148
+ let socket: Invocation["socket"];
149
+ while (index < tokens.length && !tokens[index].commandStart) {
150
+ const option = tokens[index].text;
151
+ if (option === "--") {
152
+ index++;
153
+ break;
154
+ }
155
+ if (option === "-L" || option === "-S") {
156
+ const value = tokens[index + 1];
157
+ if (!value || value.commandStart) return undefined;
158
+ socket = { flag: option, value: value.text };
159
+ socketArgs.push(option, value.text);
160
+ index += 2;
161
+ continue;
162
+ }
163
+ if (option.startsWith("-L") || option.startsWith("-S")) {
164
+ const flag = option.slice(0, 2) as "-L" | "-S";
165
+ const value = option.slice(2);
166
+ if (!value) return undefined;
167
+ socket = { flag, value };
168
+ socketArgs.push(flag, value);
169
+ index++;
170
+ continue;
171
+ }
172
+ if (option.startsWith("-")) {
173
+ index++;
174
+ continue;
175
+ }
176
+ break;
177
+ }
178
+ const verb = tokens[index];
179
+ if (!verb || verb.commandStart) return undefined;
180
+ const args: string[] = [];
181
+ for (index++; index < tokens.length && !tokens[index].commandStart; index++) args.push(tokens[index].text);
182
+ return { verb: verb.text, args, socketArgs, socket };
183
+ }
184
+
185
+ function targetFromArgs(args: string[]): string | undefined {
186
+ for (let index = 0; index < args.length; index++) {
187
+ if (args[index] === "-t") return args[index + 1];
188
+ if (args[index].startsWith("-t") && args[index].length > 2) return args[index].slice(2);
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ function collectShellPayloads(tokens: Token[]): string[] {
194
+ const payloads: string[] = [];
195
+ for (let index = 0; index < tokens.length; index++) {
196
+ const token = tokens[index];
197
+ if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;
198
+ for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
199
+ if (tokens[cursor].text === "-c" && tokens[cursor + 1]?.quoted) {
200
+ payloads.push(tokens[cursor + 1].text);
201
+ break;
202
+ }
203
+ }
204
+ }
205
+ return payloads;
206
+ }
207
+
208
+ function collectShellScripts(tokens: Token[], cwd: string): string[] {
209
+ const scripts: string[] = [];
210
+ for (let index = 0; index < tokens.length; index++) {
211
+ const token = tokens[index];
212
+ if (token.commandStart && (token.text.startsWith("./") || token.text.startsWith("/"))) {
213
+ scripts.push(path.resolve(cwd, token.text));
214
+ continue;
215
+ }
216
+ if (!token.commandStart || !SHELL_RUNNERS.has(token.text)) continue;
217
+ for (let cursor = index + 1; cursor < tokens.length && !tokens[cursor].commandStart; cursor++) {
218
+ const argument = tokens[cursor].text;
219
+ if (argument === "-c") break;
220
+ if (argument.startsWith("-")) continue;
221
+ scripts.push(path.resolve(cwd, argument));
222
+ break;
223
+ }
224
+ }
225
+ return scripts;
226
+ }
227
+
228
+ interface Identity {
229
+ socketPath: string;
230
+ paneId: string;
231
+ }
232
+
233
+ function currentIdentity(env: Record<string, string | undefined>): Identity | undefined {
234
+ const tmux = env.TMUX;
235
+ const paneId = env.TMUX_PANE;
236
+ const socketPath = tmux?.split(",")[0];
237
+ return socketPath && paneId ? { socketPath, paneId } : undefined;
238
+ }
239
+
240
+ function socketPathFor(
241
+ socket: NonNullable<Invocation["socket"]>,
242
+ env: Record<string, string | undefined>,
243
+ cwd: string,
244
+ uid: number,
245
+ ): string {
246
+ if (socket.flag === "-S") return path.isAbsolute(socket.value) ? socket.value : path.resolve(cwd, socket.value);
247
+ return path.join(env.TMUX_TMPDIR ?? "/tmp", `tmux-${uid}`, socket.value);
248
+ }
249
+
250
+ function sameSocket(left: string, right: string): boolean {
251
+ if (left === right) return true;
252
+ try {
253
+ return fs.realpathSync(left) === fs.realpathSync(right);
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ function assignedTmuxSocket(tokens: Token[], commandIndex: number): string | undefined {
260
+ for (let index = commandIndex - 1; index >= 0 && commandIndex - index <= 4; index--) {
261
+ const assignment = tokens[index].text.match(/^TMUX=([^,\s]+)/);
262
+ if (assignment) return assignment[1];
263
+ if (!tokens[index].commandStart) break;
264
+ }
265
+ return undefined;
266
+ }
267
+
268
+ const defaultResolvePaneId: TmuxPaneResolver = async ({ socketArgs, target, env }) => {
269
+ const processHandle = Bun.spawn(["tmux", ...socketArgs, "display-message", "-p", "-t", target, "#{pane_id}"], {
270
+ env,
271
+ stdin: "ignore",
272
+ stdout: "pipe",
273
+ stderr: "ignore",
274
+ });
275
+ const killTimer = setTimeout(() => processHandle.kill(), 2_000);
276
+ try {
277
+ const output = await new Response(processHandle.stdout).text();
278
+ await processHandle.exited;
279
+ const paneId = output.trim().split("\n")[0];
280
+ return paneId?.startsWith("%") ? paneId : undefined;
281
+ } finally {
282
+ clearTimeout(killTimer);
283
+ }
284
+ };
285
+
286
+ export async function checkTmuxSelfInjection(
287
+ command: string,
288
+ options: TmuxSelfInjectionOptions = {},
289
+ depth = 0,
290
+ ): Promise<TmuxSelfInjectionResult> {
291
+ const env = options.env ?? process.env;
292
+ const identity = currentIdentity(env);
293
+ if (!identity) return { block: false };
294
+ const cwd = options.cwd ?? env.PWD ?? ".";
295
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
296
+ const resolvePaneId = options.resolvePaneId ?? defaultResolvePaneId;
297
+ const tokens = tokenize(command);
298
+
299
+ for (let index = 0; index < tokens.length; index++) {
300
+ if (!tokens[index].commandStart || (tokens[index].text !== "tmux" && !tokens[index].text.endsWith("/tmux")))
301
+ continue;
302
+ const invocation = parseInvocation(tokens.slice(index + 1));
303
+ if (!invocation || !INPUT_VERBS.has(invocation.verb)) continue;
304
+ const assignedSocket = assignedTmuxSocket(tokens, index);
305
+ const commandSocket = invocation.socket ? socketPathFor(invocation.socket, env, cwd, uid) : assignedSocket;
306
+ if (commandSocket && !sameSocket(commandSocket, identity.socketPath)) continue;
307
+
308
+ const target = targetFromArgs(invocation.args);
309
+ if (target === undefined) {
310
+ return {
311
+ block: true,
312
+ reason: `Blocked: ${invocation.verb} without a target would inject keystrokes into this agent pane (${identity.paneId}).`,
313
+ };
314
+ }
315
+ if (target === identity.paneId) {
316
+ return {
317
+ block: true,
318
+ reason: `Blocked: ${invocation.verb} targets this agent pane (${identity.paneId}); injected bytes would become a forged user turn.`,
319
+ };
320
+ }
321
+ if (!target.startsWith("%")) {
322
+ const resolved = await resolvePaneId({
323
+ socketArgs: invocation.socketArgs,
324
+ target,
325
+ env: { ...env, TMUX: `${identity.socketPath},0,0` } as Record<string, string>,
326
+ });
327
+ if (resolved !== undefined && resolved !== identity.paneId) continue;
328
+ if (resolved === identity.paneId || resolved === undefined) {
329
+ return {
330
+ block: true,
331
+ reason:
332
+ resolved === identity.paneId
333
+ ? `Blocked: ${invocation.verb} target ${target} resolves to this agent pane (${identity.paneId}); injected bytes would become a forged user turn.`
334
+ : `Blocked: could not verify that ${invocation.verb} target ${target} is a different pane on the current tmux server; refusing fail-closed.`,
335
+ };
336
+ }
337
+ }
338
+ }
339
+
340
+ if (depth < MAX_INDIRECTION_DEPTH) {
341
+ for (const payload of collectShellPayloads(tokens)) {
342
+ const result = await checkTmuxSelfInjection(payload, options, depth + 1);
343
+ if (result.block) return result;
344
+ }
345
+ for (const scriptPath of collectShellScripts(tokens, cwd)) {
346
+ try {
347
+ const file = Bun.file(scriptPath);
348
+ if (!(await file.exists()) || file.size > 1_000_000) continue;
349
+ const script = await file.text();
350
+ const result = await checkTmuxSelfInjection(script, options, depth + 1);
351
+ if (result.block) return result;
352
+ } catch {
353
+ // An unreadable or missing script is not itself evidence that it
354
+ // targets this pane; let the shell report the normal file error.
355
+ }
356
+ }
357
+ }
358
+ return { block: false };
359
+ }
@@ -2319,7 +2319,7 @@ export const TOOL_CATALOG: Readonly<Record<string, ToolCatalogEntry>> = {
2319
2319
  "job": {
2320
2320
  "name": "job",
2321
2321
  "label": "Job",
2322
- "description": "Inspects, waits, or cancels async jobs.\n\nBackground job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.\n\nIn the interactive TUI, supported managed foreground bash can be folded into a background job by pressing `Ctrl+B` twice while it is running. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.\n\n# Operations\n\n## `list: true`\nUse to inspect what's running.\n\n## `tail: [id, …]`\nShow the retained output buffer for one or more background jobs without waiting.\n- Use this to reopen/tail a backgrounded long-running bash/tool output after folding it away.\n- Output is bounded by the manager retention window; stale cursors may report that only the retained tail is available.\n- Prefer `tail` over polling when you only need to peek at progress, so the conversation can continue without flooding the TUI.\n\n## `poll: [id, …]`\nBlock until the specified jobs finish or the wait window (~30 s, not configurable) elapses.\n- Use when you are genuinely blocked on a result and have no other work to do.\n- Returns the current snapshot when the timer elapses; running jobs remain running.\n- Completed jobs include their final output in the returned snapshot.\n\n## `cancel: [id, …]`\nStop running jobs.\n- Use when a job is stalled, hung, or no longer needed.\n- Returns immediately after cancelling.",
2322
+ "description": "Inspects, waits, or cancels async jobs.\n\nBackground job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.\n\nIn the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.\n\n# Operations\n\n## `list: true`\nUse to inspect what's running.\n\n## `tail: [id, …]`\nShow the retained output buffer for one or more background jobs without waiting.\n- Use this to reopen/tail a backgrounded long-running bash/tool output after folding it away.\n- Output is bounded by the manager retention window; stale cursors may report that only the retained tail is available.\n- Prefer `tail` over polling when you only need to peek at progress, so the conversation can continue without flooding the TUI.\n\n## `poll: [id, …]`\nBlock until the specified jobs finish or the wait window (~30 s, not configurable) elapses.\n- Use when you are genuinely blocked on a result and have no other work to do.\n- Returns the current snapshot when the timer elapses; running jobs remain running.\n- Completed jobs include their final output in the returned snapshot.\n\n## `cancel: [id, …]`\nStop running jobs.\n- Use when a job is stalled, hung, or no longer needed.\n- Returns immediately after cancelling.",
2323
2323
  "parameters": {
2324
2324
  "type": "object",
2325
2325
  "properties": {