@gajae-code/coding-agent 0.7.7 → 0.7.9

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 (96) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +14 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/keybindings.d.ts +1 -1
  8. package/dist/types/config/model-profile-activation.d.ts +19 -2
  9. package/dist/types/config/model-profiles.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +9 -0
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +45 -0
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  15. package/dist/types/gjc-runtime/launch-tmux.d.ts +14 -5
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  17. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  18. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
  19. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  20. package/dist/types/modes/components/hook-input.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +11 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  24. package/dist/types/modes/components/status-line.d.ts +7 -3
  25. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  26. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  27. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  28. package/dist/types/modes/interactive-mode.d.ts +3 -1
  29. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  30. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
  31. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  32. package/dist/types/modes/types.d.ts +3 -1
  33. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  34. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  35. package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
  36. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  37. package/dist/types/tools/ask.d.ts +24 -0
  38. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  39. package/package.json +7 -7
  40. package/src/cli/local-provider-smoke.ts +632 -0
  41. package/src/cli/notify-cli.ts +5 -3
  42. package/src/cli/update-cli.ts +155 -13
  43. package/src/cli.ts +19 -0
  44. package/src/commands/launch.ts +46 -1
  45. package/src/commands/local-provider.ts +62 -0
  46. package/src/commands/notify.ts +1 -1
  47. package/src/config/keybindings.ts +1 -1
  48. package/src/config/model-profile-activation.ts +92 -11
  49. package/src/config/model-profiles.ts +15 -2
  50. package/src/config/model-registry.ts +136 -6
  51. package/src/config/models-config-schema.ts +9 -0
  52. package/src/config/settings-schema.ts +48 -0
  53. package/src/config/settings.ts +11 -0
  54. package/src/coordinator-mcp/server.ts +280 -36
  55. package/src/deep-interview/render-middleware.ts +1 -0
  56. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -6
  57. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  58. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  59. package/src/gjc-runtime/launch-tmux.ts +321 -38
  60. package/src/gjc-runtime/launch-worktree.ts +16 -2
  61. package/src/gjc-runtime/team-runtime.ts +87 -7
  62. package/src/gjc-runtime/tmux-common.ts +16 -0
  63. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  64. package/src/gjc-runtime/ultragoal-guard.ts +11 -5
  65. package/src/internal-urls/docs-index.generated.ts +10 -9
  66. package/src/modes/components/agent-dashboard.ts +2 -1
  67. package/src/modes/components/custom-model-preset-wizard.ts +31 -192
  68. package/src/modes/components/hook-editor.ts +7 -4
  69. package/src/modes/components/hook-input.ts +4 -0
  70. package/src/modes/components/model-selector.ts +260 -12
  71. package/src/modes/components/session-observer-overlay.ts +3 -3
  72. package/src/modes/components/settings-selector.ts +506 -5
  73. package/src/modes/components/status-line/segments.ts +16 -19
  74. package/src/modes/components/status-line/types.ts +6 -2
  75. package/src/modes/components/status-line.ts +70 -35
  76. package/src/modes/controllers/event-controller.ts +98 -4
  77. package/src/modes/controllers/extension-ui-controller.ts +5 -0
  78. package/src/modes/controllers/input-controller.ts +15 -0
  79. package/src/modes/controllers/selector-controller.ts +142 -11
  80. package/src/modes/interactive-mode.ts +7 -2
  81. package/src/modes/prompt-action-autocomplete.ts +8 -0
  82. package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
  83. package/src/modes/tmux-scroll.ts +92 -0
  84. package/src/modes/types.ts +6 -1
  85. package/src/modes/utils/terminal-bell.ts +48 -0
  86. package/src/notifications/daemon-paths.ts +22 -0
  87. package/src/notifications/telegram-daemon-cli.ts +125 -13
  88. package/src/notifications/telegram-daemon.ts +6 -21
  89. package/src/prompts/system/system-prompt.md +5 -0
  90. package/src/sdk.ts +10 -1
  91. package/src/session/agent-session.ts +84 -6
  92. package/src/slash-commands/builtin-registry.ts +106 -13
  93. package/src/tools/ask.ts +11 -1
  94. package/src/tools/eval.ts +2 -2
  95. package/src/tools/render-utils.ts +2 -0
  96. package/src/tools/ultragoal-ask-guard.ts +39 -5
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { VERSION } from "@gajae-code/utils/dirs";
4
5
  import { safeStderrWrite } from "@gajae-code/utils/safe-stderr";
@@ -6,6 +7,8 @@ import type { Args } from "../cli/args";
6
7
  import { tmuxRuntimeSessionPath } from "./session-layout";
7
8
  import { GJC_COORDINATOR_SESSION_ID_ENV, GJC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
8
9
  import {
10
+ buildGjcTmuxExactOptionTarget,
11
+ buildGjcTmuxExactSessionTarget,
9
12
  buildGjcTmuxProfileCommands,
10
13
  buildGjcTmuxSessionName,
11
14
  buildGjcTmuxSessionSlug,
@@ -21,6 +24,7 @@ import {
21
24
  import { findGjcTmuxSessionByName, findGjcTmuxSessionByScope, type GjcTmuxSessionStatus } from "./tmux-sessions";
22
25
 
23
26
  export {
27
+ buildGjcTmuxExactSessionTarget,
24
28
  buildGjcTmuxProfileCommands,
25
29
  GJC_DEFAULT_TMUX_SESSION,
26
30
  GJC_TMUX_COMMAND_ENV,
@@ -33,6 +37,7 @@ export const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
33
37
  export const GJC_LAUNCH_POLICY_ENV = "GJC_LAUNCH_POLICY";
34
38
  export const GJC_TMUX_WINDOW_LABEL_MAX_WIDTH = 48;
35
39
  export const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE";
40
+ const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
36
41
 
37
42
  type LaunchPolicy = "direct" | "tmux";
38
43
 
@@ -70,9 +75,18 @@ export type TmuxSpawnSync = (command: string, args: string[], options: TmuxSpawn
70
75
  export interface TmuxSpawnOptions {
71
76
  cwd: string;
72
77
  env: NodeJS.ProcessEnv;
73
- stdin: "inherit";
74
- stdout: "inherit";
75
- stderr: "inherit";
78
+ stdin: "inherit" | "pipe";
79
+ stdout: "inherit" | "pipe";
80
+ stderr: "inherit" | "pipe";
81
+ /**
82
+ * When true, the spawn captures stderr into a buffer and forwards it to
83
+ * the parent stderr so the user still sees the live output. The captured
84
+ * text is also returned in TmuxSpawnResult.stderr for diagnostic
85
+ * surfacing in "attach failed" / "profile tagging failed" messages.
86
+ * Defaults to false to preserve the previous PTY-binding behavior for
87
+ * attach-session and other interactive commands.
88
+ */
89
+ captureStderr?: boolean;
76
90
  }
77
91
 
78
92
  export interface TmuxLaunchPlan {
@@ -168,6 +182,48 @@ function formatTmuxLaunchDiagnostic(stage: string, stderr?: string): string {
168
182
  return `gjc --tmux failed after creating tmux session: ${stage}.${suffix}\n`;
169
183
  }
170
184
 
185
+ /**
186
+ * Detect a corrupted gjc.cmd / gjc.bat wrapper at well-known PATH locations.
187
+ * On Windows, `gjc.cmd` / `gjc.bat` files at the front of PATH that turn out
188
+ * to be PE-binary garbage (e.g. a 194MB PE image written over the wrapper)
189
+ * cause cmd.exe to hang silently when invoked from PowerShell — cmd reads
190
+ * the binary as text and never returns, so the user sees the prompt return
191
+ * with no output but no actual launch. This probe surfaces that failure mode
192
+ * in the diagnostic so the user gets a clear "wrapper corrupted" hint instead
193
+ * of a silent exit. Best-effort: returns null when the file is missing,
194
+ * unreadable, or under 1KB (real CMD wrappers are 100-500 bytes; the original
195
+ * 194MB PE-binary garbage was obviously out of band). Sync because the
196
+ * call site (launchDefaultTmuxIfNeeded) is sync; uses statSync + 2-byte
197
+ * read.
198
+ */
199
+ function detectCorruptedGjcWrapper(): string | null {
200
+ if (process.platform !== "win32") return null;
201
+ const pathEnv = process.env.PATH ?? "";
202
+ if (!pathEnv) return null;
203
+ const seen = new Set<string>();
204
+ for (const dir of pathEnv.split(path.delimiter)) {
205
+ for (const name of ["gjc.cmd", "gjc.bat"]) {
206
+ const full = path.join(dir, name);
207
+ if (seen.has(full)) continue;
208
+ seen.add(full);
209
+ try {
210
+ const stat = fs.statSync(full);
211
+ if (!stat.isFile()) continue;
212
+ if (stat.size < 1024) continue;
213
+ if (stat.size > 64 * 1024) {
214
+ return `Detected suspicious gjc wrapper at ${full}: ${stat.size} bytes (expected <1KB). The wrapper may be corrupted; cmd.exe will hang reading it as text. Recreate it from the gjc-tmux.cmd template.`;
215
+ }
216
+ const head = fs.readFileSync(full);
217
+ if (head.byteLength < 2) continue;
218
+ const view = new Uint8Array(head);
219
+ if (view[0] === 0x4d && view[1] === 0x5a) {
220
+ return `Detected PE-binary gjc wrapper at ${full} (MZ header, ${stat.size} bytes). cmd.exe will hang reading it as text. Recreate the wrapper from the gjc-tmux.cmd template.`;
221
+ }
222
+ } catch {}
223
+ }
224
+ }
225
+ return null;
226
+ }
171
227
  function formatTmuxUnavailableDiagnostic(platform: NodeJS.Platform, tmuxCommand: string): string {
172
228
  if (platform === "win32") {
173
229
  return (
@@ -201,12 +257,30 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
201
257
  const envLines = Object.entries({ [GJC_TMUX_LAUNCHED_ENV]: "1", ...(context.extraEnv ?? {}) }).map(
202
258
  ([key, value]) => `$env:${key} = ${powershellQuote(value)}`,
203
259
  );
204
- const invocation = ["&", ...command.map(powershellQuote), ...stripRootTmuxFlag(rawArgs).map(powershellQuote)].join(
205
- " ",
206
- );
260
+ // Resolve the inner command and arguments. PowerShell's `&` call operator
261
+ // accepts a single command followed by its arguments directly (no script
262
+ // block needed). Wrapping the call in `& { ... }` would be invalid because
263
+ // adjacent single-quoted tokens inside a script block body are a parser
264
+ // error: `& { 'a' 'b' }` fails with "Unexpected token 'b'" because PowerShell
265
+ // only concatenates adjacent *double-quoted* strings, and even then only in
266
+ // expression position. Emitting the arguments as a comma-separated array
267
+ // (`& 'cmd' @('a','b')`) is also rejected because arrays are not valid as
268
+ // the second-and-later positional arguments to `&` in command position. The
269
+ // correct form is `& 'cmd' 'arg1' 'arg2'` — exactly what the joined
270
+ // resolvedCommand + innerArgs produces below.
271
+ const resolvedCommand = command.map(powershellQuote).join(" ");
272
+ const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
273
+ const invocation = `& ${resolvedCommand} ${innerArgs}`;
207
274
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
208
275
  const script = [...envLines, invocation, exitLine].join("\n");
209
- const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
276
+ // Encode the script as UTF-16LE base64 for pwsh -EncodedCommand. Do NOT
277
+ // prepend a UTF-16LE BOM (0xFF 0xFE): the BOM survives the decode and is
278
+ // inserted as a literal U+FEFF character in front of the first script
279
+ // token, which pwsh then reports as a "term not recognized" parse error
280
+ // (e.g. "$env:GJC_TMUX_LAUNCHED"). pwsh expects the decoded buffer to
281
+ // start with the first character of the script, not with a BOM.
282
+ const body = Buffer.from(script, "utf16le");
283
+ const encodedCommand = body.toString("base64");
210
284
  return `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
211
285
  }
212
286
 
@@ -236,7 +310,14 @@ export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProf
236
310
  if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
237
311
  const spawnSync = context.spawnSync ?? defaultSpawnSync;
238
312
  const cwd = context.cwd ?? process.cwd();
239
- const options: TmuxSpawnOptions = { cwd, env, stdin: "inherit", stdout: "inherit", stderr: "inherit" };
313
+ const options: TmuxSpawnOptions = {
314
+ cwd,
315
+ env,
316
+ stdin: "inherit",
317
+ stdout: "inherit",
318
+ stderr: "inherit",
319
+ captureStderr: true,
320
+ };
240
321
  const failures: GjcTmuxProfileResult["failures"] = [];
241
322
  for (const command of commands) {
242
323
  const result = spawnSync(context.tmuxCommand, command.args, options);
@@ -307,6 +388,8 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
307
388
  }
308
389
 
309
390
  const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
391
+ const GJC_TMUX_WINDOW_TITLE_PREFIX = "GJC-";
392
+ const GJC_TMUX_TERMINAL_TITLE_PREFIX = "GJC: ";
310
393
 
311
394
  function sanitizeTmuxWindowTitleSegment(value: string): string {
312
395
  return value.replace(/:+/g, "-");
@@ -319,20 +402,68 @@ function sanitizeTmuxWindowProjectName(project: string): string {
319
402
  return sanitizeTmuxWindowTitleSegment(trimmed);
320
403
  }
321
404
 
322
- export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
405
+ function buildGjcTmuxPrefixedTitle(prefix: string, cwd: string, branch: string | null | undefined): string {
323
406
  const project = sanitizeTmuxWindowProjectName(path.basename(path.resolve(cwd)) || "gjc");
407
+ const projectTitle = `${prefix}${project}`;
324
408
  const trimmedBranch = sanitizeTmuxWindowTitleSegment(branch?.trim() ?? "");
325
- if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
409
+ if (!trimmedBranch) return truncateVisible(projectTitle, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
326
410
 
327
411
  const separatorWidth = visibleWidth(GJC_TMUX_WINDOW_BRANCH_SEPARATOR);
328
- const projectWidth = visibleWidth(project);
329
- const fullTitle = `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
412
+ const projectWidth = visibleWidth(projectTitle);
413
+ const fullTitle = `${projectTitle}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
330
414
  if (visibleWidth(fullTitle) <= GJC_TMUX_WINDOW_LABEL_MAX_WIDTH) return fullTitle;
331
415
 
332
416
  const remainingBranchWidth = GJC_TMUX_WINDOW_LABEL_MAX_WIDTH - projectWidth - separatorWidth;
333
- if (remainingBranchWidth <= 0) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
417
+ if (remainingBranchWidth <= 0) return truncateVisible(projectTitle, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
418
+
419
+ return `${projectTitle}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
420
+ }
421
+
422
+ export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
423
+ return buildGjcTmuxPrefixedTitle(GJC_TMUX_WINDOW_TITLE_PREFIX, cwd, branch);
424
+ }
334
425
 
335
- return `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
426
+ function buildGjcTmuxRootTerminalTitle(cwd: string, branch: string | null | undefined): string {
427
+ return buildGjcTmuxPrefixedTitle(GJC_TMUX_TERMINAL_TITLE_PREFIX, cwd, branch);
428
+ }
429
+
430
+ function sanitizeGjcTmuxRootTerminalTitle(title: string): string {
431
+ return title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").trim() || "GJC";
432
+ }
433
+
434
+ function escapeTmuxFormatLiteral(value: string): string {
435
+ return value.replace(/#/g, "##");
436
+ }
437
+
438
+ function buildGjcTmuxRootTerminalTitleCommands(target: string, title: string): GjcTmuxProfileCommand[] {
439
+ const sanitized = escapeTmuxFormatLiteral(sanitizeGjcTmuxRootTerminalTitle(title));
440
+ return [
441
+ { description: "enable tmux client terminal title", args: ["set-option", "-t", target, "set-titles", "on"] },
442
+ {
443
+ description: "set tmux client terminal title",
444
+ args: ["set-option", "-t", target, "set-titles-string", sanitized],
445
+ },
446
+ ];
447
+ }
448
+
449
+ function applyGjcTmuxRootTerminalTitleProfile(context: {
450
+ tmuxCommand: string;
451
+ target: string;
452
+ title: string | undefined;
453
+ spawnSync: TmuxSpawnSync;
454
+ options: TmuxSpawnOptions;
455
+ }): void {
456
+ if (!context.title) return;
457
+ for (const command of buildGjcTmuxRootTerminalTitleCommands(
458
+ buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env }),
459
+ context.title,
460
+ )) {
461
+ context.spawnSync(context.tmuxCommand, command.args, context.options);
462
+ }
463
+ }
464
+
465
+ function shouldSetGjcTmuxRootTerminalTitle(parsed: Args, env: NodeJS.ProcessEnv): boolean {
466
+ return !parsed.noTitle && !env.PI_NO_TITLE;
336
467
  }
337
468
 
338
469
  function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
@@ -395,7 +526,11 @@ function readCurrentBranch(cwd: string): string | null {
395
526
  }
396
527
 
397
528
  function cleanupCreatedTmuxSession(plan: TmuxLaunchPlan, spawnSync: TmuxSpawnSync, options: TmuxSpawnOptions): void {
398
- spawnSync(plan.tmuxCommand, ["kill-session", "-t", `=${plan.sessionName}`], options);
529
+ spawnSync(
530
+ plan.tmuxCommand,
531
+ ["kill-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
532
+ options,
533
+ );
399
534
  }
400
535
  function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
401
536
  if (result.signalCode === "SIGHUP") return true;
@@ -472,15 +607,33 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
472
607
  }
473
608
 
474
609
  function defaultSpawnSync(command: string, args: string[], options: TmuxSpawnOptions): TmuxSpawnResult {
610
+ // When captureStderr is set on the options, route stderr through a
611
+ // pipe so we can both forward it to the parent stderr (so the user
612
+ // still sees the live output) and retain it in TmuxSpawnResult.stderr
613
+ // for diagnostic surfacing. PTY-bound commands (attach-session) keep
614
+ // stderr: "inherit" because psmux needs a real terminal handle.
615
+ const stdio = options.captureStderr
616
+ ? { stdin: options.stdin, stdout: options.stdout, stderr: "pipe" as const }
617
+ : { stdin: options.stdin, stdout: options.stdout, stderr: options.stderr };
475
618
  const result = Bun.spawnSync({
476
619
  cmd: [command, ...args],
477
620
  cwd: options.cwd,
478
621
  env: options.env,
479
- stdin: options.stdin,
480
- stdout: options.stdout,
481
- stderr: options.stderr,
622
+ ...stdio,
482
623
  });
483
- return { exitCode: result.exitCode, signalCode: result.signalCode };
624
+ let stderrText: string | undefined;
625
+ if (options.captureStderr) {
626
+ const stderrBytes = result.stderr;
627
+ stderrText = stderrBytes ? new TextDecoder().decode(stderrBytes) : "";
628
+ if (stderrText.length > 0) {
629
+ try {
630
+ process.stderr.write(stderrText);
631
+ } catch {
632
+ // parent stderr already closed during shutdown; ignore.
633
+ }
634
+ }
635
+ }
636
+ return { exitCode: result.exitCode, signalCode: result.signalCode, stderr: stderrText };
484
637
  }
485
638
 
486
639
  export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
@@ -497,23 +650,64 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
497
650
  stdout: "inherit",
498
651
  stderr: "inherit",
499
652
  };
653
+ const attachOptions: TmuxSpawnOptions = { ...options };
654
+ const controlOptions: TmuxSpawnOptions = { ...options, captureStderr: true };
655
+ // has-session / new-session retry / profile-tagging probe share these
656
+ // pipe-stdio options. PTY-bound commands (attach-session, rename-window,
657
+ // set-option in applyGjcTmuxProfile when we explicitly need the live TTY)
658
+ // use `options` or `attachOptions` instead.
659
+ const probeOptions: TmuxSpawnOptions = {
660
+ ...options,
661
+ stdin: "pipe",
662
+ stdout: "pipe",
663
+ stderr: "pipe",
664
+ captureStderr: true,
665
+ };
666
+ // new-session needs pipe stdio (not inherit) because the user terminal must
667
+ // remain untouched until attach-session takes over. Inheriting psmux's
668
+ // stdout/stderr for new-session can corrupt the terminal state or race with
669
+ // attach-session on Windows, where psmux 3.3.0/3.3.6's server can die if it
670
+ // sees the controlling TTY in an inconsistent state mid-spawn. Capturing
671
+ // both streams also gives the diagnostic writer the full error detail when
672
+ // new-session itself fails.
673
+ const newSessionOptions: TmuxSpawnOptions = {
674
+ ...options,
675
+ stdin: "pipe",
676
+ stdout: "pipe",
677
+ stderr: "pipe",
678
+ captureStderr: true,
679
+ };
500
680
 
501
- if (plan.attachSessionName) {
502
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.attachSessionName}`], options);
503
- if (attached.exitCode === 0) return true;
504
- }
681
+ const windowTitle = buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch);
682
+ const rootTerminalTitle = shouldSetGjcTmuxRootTerminalTitle(context.parsed, env)
683
+ ? buildGjcTmuxRootTerminalTitle(plan.project ?? plan.cwd, plan.branch)
684
+ : undefined;
505
685
 
506
- const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
507
- if (created.exitCode === 0) {
508
- renameTmuxWindow(
509
- plan.tmuxCommand,
510
- buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
686
+ if (plan.attachSessionName) {
687
+ applyGjcTmuxRootTerminalTitleProfile({
688
+ tmuxCommand: plan.tmuxCommand,
689
+ target: plan.attachSessionName,
690
+ title: rootTerminalTitle,
511
691
  spawnSync,
512
692
  options,
513
- `=${plan.sessionName}`,
693
+ });
694
+ const attached = spawnSync(
695
+ plan.tmuxCommand,
696
+ ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
697
+ attachOptions,
514
698
  );
515
-
516
- const profile = applyGjcTmuxProfile({
699
+ if (attached.exitCode === 0) return true;
700
+ }
701
+ const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
702
+ if (created.exitCode === 0) {
703
+ // On Windows + psmux 3.3.0/3.3.6, new-session can return exit 0
704
+ // before the psmux server finishes registering the session on its
705
+ // control socket. The follow-up set-option / attach-session then
706
+ // fails with "can't find session / no server running". Probe the
707
+ // server first, and if the race fired, retry new-session before
708
+ // we hand the session to rename-window / applyGjcTmuxProfile so the
709
+ // profile-tagging path always sees a registered session.
710
+ const buildProfileInputs = (): GjcTmuxProfileContext => ({
517
711
  tmuxCommand: plan.tmuxCommand,
518
712
  target: plan.sessionName,
519
713
  cwd: plan.cwd,
@@ -525,17 +719,106 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
525
719
  sessionStateFile: plan.sessionStateFile ?? null,
526
720
  version: VERSION,
527
721
  });
722
+ const probeHasSession = (): TmuxSpawnResult =>
723
+ spawnSync(
724
+ plan.tmuxCommand,
725
+ ["has-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
726
+ probeOptions,
727
+ );
728
+ const probeResult = probeHasSession();
729
+ if (probeResult.exitCode !== 0) {
730
+ const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
731
+ const retryProbe = probeHasSession();
732
+ if (retry.exitCode !== 0 || retryProbe.exitCode !== 0) {
733
+ (context.diagnosticWriter ?? safeStderrWrite)(
734
+ formatTmuxLaunchDiagnostic(
735
+ "new-session retry failed after missing session",
736
+ retry.stderr ?? retryProbe.stderr ?? created.stderr,
737
+ ),
738
+ );
739
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
740
+ return false;
741
+ }
742
+ }
743
+ renameTmuxWindow(
744
+ plan.tmuxCommand,
745
+ windowTitle,
746
+ spawnSync,
747
+ controlOptions,
748
+ buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
749
+ );
750
+ const profile = applyGjcTmuxProfile(buildProfileInputs());
751
+ // If the @gjc-profile ownership write failed, the cause can be
752
+ // either (a) a real psmux persistence-tag rejection (e.g.
753
+ // unsupported option on this server), or (b) the same new-session
754
+ // registration race above — psmux returned 0 but the server died
755
+ // before registering, so the follow-up set-option failed with
756
+ // "can't find session". Distinguish the two: re-probe; if the
757
+ // session is genuinely missing, retry new-session and re-apply the
758
+ // profile. Otherwise, surface the persistence-tag failure.
528
759
  const ownershipFailure = profile.failures.find(item => item.command.args.includes("@gjc-profile"));
529
760
  if (ownershipFailure) {
530
- cleanupCreatedTmuxSession(plan, spawnSync, options);
531
- (context.diagnosticWriter ?? safeStderrWrite)(
532
- formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
533
- );
534
- return true;
761
+ const probeAfterOwnership = probeHasSession();
762
+ if (probeAfterOwnership.exitCode === 0) {
763
+ // Session is present; tagging failed for a real reason.
764
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
765
+ (context.diagnosticWriter ?? safeStderrWrite)(
766
+ formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
767
+ );
768
+ return true;
769
+ }
770
+ // Session is missing — retry.
771
+ const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
772
+ const retryProbe = probeHasSession();
773
+ if (retry.exitCode !== 0 || retryProbe.exitCode !== 0) {
774
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
775
+ (context.diagnosticWriter ?? safeStderrWrite)(
776
+ formatTmuxLaunchDiagnostic(
777
+ "new-session retry failed after ownership failure",
778
+ retry.stderr ?? retryProbe.stderr ?? ownershipFailure.stderr,
779
+ ),
780
+ );
781
+ return true;
782
+ }
783
+ const retryProfile = applyGjcTmuxProfile(buildProfileInputs());
784
+ const retryOwnershipFailure = retryProfile.failures.find(item => item.command.args.includes("@gjc-profile"));
785
+ if (retryOwnershipFailure) {
786
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
787
+ (context.diagnosticWriter ?? safeStderrWrite)(
788
+ formatTmuxLaunchDiagnostic("profile tagging failed after retry", retryOwnershipFailure.stderr),
789
+ );
790
+ return true;
791
+ }
792
+ // Recovery succeeded via retry — fall through to attach-session below.
535
793
  }
794
+ applyGjcTmuxRootTerminalTitleProfile({
795
+ tmuxCommand: plan.tmuxCommand,
796
+ target: plan.sessionName,
797
+ title: rootTerminalTitle,
798
+ spawnSync,
799
+ options,
800
+ });
536
801
  }
537
- if (created.exitCode !== 0) return false;
538
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.sessionName}`], options);
802
+ const probeWarning = detectCorruptedGjcWrapper();
803
+ if (created.exitCode !== 0) {
804
+ // The new-session spawn failed. Surface the captured stderr so the
805
+ // user sees the actual psmux rejection (e.g. "cannot create session:
806
+ // server is shutting down") instead of a silent exit. The wrapper
807
+ // probe gives the user a deterministic hint when the silent-exit
808
+ // symptom is actually caused by a corrupted gjc.cmd / gjc.bat on
809
+ // PATH (a 194MB PE-binary at the wrapper path produces cmd.exe
810
+ // hangs that look like a tmux/psmux failure from the user's seat).
811
+ const stderr = created.stderr;
812
+ const suffix = probeWarning ? ` Wrapper warning: ${probeWarning}` : "";
813
+ (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("new-session failed", stderr) + suffix);
814
+ return false;
815
+ }
816
+ // attach-session needs PTY inherit for the user-facing attach; keep it unchanged.
817
+ const attached = spawnSync(
818
+ plan.tmuxCommand,
819
+ ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
820
+ attachOptions,
821
+ );
539
822
  if (attached.exitCode === 0) return true;
540
823
  if (isTmuxAttachDisconnectError(attached)) {
541
824
  (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr));
@@ -118,6 +118,20 @@ function findWorktreeByPath(entries: GitWorktreeEntry[], worktreePath: string):
118
118
  return entries.find(entry => path.resolve(entry.path) === resolved) ?? null;
119
119
  }
120
120
 
121
+ function describeWorktreeEntry(entry: GitWorktreeEntry): string {
122
+ return entry.detached ? `detached HEAD ${entry.head}` : (entry.branchRef ?? `HEAD ${entry.head}`);
123
+ }
124
+
125
+ function formatWorktreeTargetMismatch(plan: GjcLaunchWorktreePlan, existing: GitWorktreeEntry): string {
126
+ const expected = plan.detached ? `detached HEAD ${plan.baseRef}` : `branch refs/heads/${plan.branchName ?? ""}`;
127
+ return [
128
+ `worktree_target_mismatch:${plan.worktreePath}`,
129
+ `GJC launch worktree target is already registered for ${describeWorktreeEntry(existing)}, but this launch expects ${expected}.`,
130
+ `Path: ${plan.worktreePath}`,
131
+ "Refusing to delete or reuse the conflicting worktree automatically. Safe remediation: inspect the path, commit/stash any work, then remove or prune the stale worktree with git worktree remove <path> when it is no longer needed, or choose a different --worktree name.",
132
+ ].join("\n");
133
+ }
134
+
121
135
  function hasBranchInUse(entries: GitWorktreeEntry[], branchName: string, worktreePath: string): boolean {
122
136
  const expectedRef = `refs/heads/${branchName}`;
123
137
  const resolvedPath = path.resolve(worktreePath);
@@ -228,7 +242,7 @@ export function ensureLaunchWorktree(
228
242
  let dirty = isWorktreeDirty(plan.worktreePath);
229
243
  if (plan.detached) {
230
244
  if (!existingAtPath.detached) {
231
- throw new Error(`worktree_target_mismatch:${plan.worktreePath}`);
245
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
232
246
  }
233
247
  if (existingAtPath.head !== plan.baseRef) {
234
248
  if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
@@ -236,7 +250,7 @@ export function ensureLaunchWorktree(
236
250
  dirty = false;
237
251
  }
238
252
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
239
- throw new Error(`worktree_target_mismatch:${plan.worktreePath}`);
253
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
240
254
  }
241
255
  return {
242
256
  ...plan,
@@ -1896,13 +1896,27 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
1896
1896
  }
1897
1897
  return { sessionName, windowIndex, leaderPaneId, target: `${sessionName}:${windowIndex}` };
1898
1898
  }
1899
- export function resolveGjcWorkerCommand(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string {
1899
+ export function resolveGjcWorkerCommand(
1900
+ cwd = process.cwd(),
1901
+ env: NodeJS.ProcessEnv = process.env,
1902
+ platform: NodeJS.Platform = process.platform,
1903
+ argv: string[] = process.argv,
1904
+ execPath = process.execPath,
1905
+ ): string {
1900
1906
  const explicit = env.GJC_TEAM_WORKER_COMMAND?.trim();
1901
1907
  if (explicit) return explicit;
1902
- const entrypoint = process.argv[1];
1903
- if (entrypoint?.endsWith(".ts"))
1904
- return `${shellQuote(process.execPath)} ${shellQuote(path.resolve(cwd, entrypoint))}`;
1905
- if (entrypoint && path.basename(entrypoint).startsWith("gjc")) return shellQuote(path.resolve(cwd, entrypoint));
1908
+ const entrypoint = argv[1];
1909
+ if (!entrypoint) return "gjc";
1910
+ const pathModule = platform === "win32" ? path.win32 : path;
1911
+ const resolvedEntrypoint = pathModule.isAbsolute(entrypoint) ? entrypoint : pathModule.resolve(cwd, entrypoint);
1912
+ if (platform === "win32") {
1913
+ if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs"))
1914
+ return `${powershellQuote(execPath)} ${powershellQuote(resolvedEntrypoint)}`;
1915
+ if (pathModule.basename(entrypoint).startsWith("gjc")) return powershellQuote(resolvedEntrypoint);
1916
+ return "gjc";
1917
+ }
1918
+ if (entrypoint.endsWith(".ts")) return `${shellQuote(execPath)} ${shellQuote(resolvedEntrypoint)}`;
1919
+ if (path.basename(entrypoint).startsWith("gjc")) return shellQuote(path.resolve(cwd, entrypoint));
1906
1920
  return "gjc";
1907
1921
  }
1908
1922
  /** @internal Exported for unit tests. */
@@ -1917,7 +1931,22 @@ export function buildWorkerCommand(
1917
1931
  const workspace = worker.worktree_path
1918
1932
  ? `Worker worktree: ${worker.worktree_path}.`
1919
1933
  : `Worker cwd: ${config.leader.cwd}.`;
1920
- const prompt = [
1934
+ // The worker prompt body is dispatched into the pane through
1935
+ // `tmux send-keys`, which treats embedded LF characters as Enter
1936
+ // keypresses. A multi-line prompt therefore lands in the pane as
1937
+ // multiple prompt bodies followed by premature Enter presses, which on
1938
+ // Windows + psmux/ConPTY causes the worker CLI to bail out before the
1939
+ // startup ACK fires. Normalize the body to a single line by replacing
1940
+ // any LF/CRLF with a space, and strip a defensive U+FEFF in case a
1941
+ // caller managed to inject a UTF-8 BOM into the task text. Empty /
1942
+ // whitespace-only bodies fall back to a one-line placeholder so the
1943
+ // worker never sits idle at an empty prompt.
1944
+ const normalizePrompt = (raw: string): string =>
1945
+ raw
1946
+ .replace(/[\uFEFF\u200B]/g, "")
1947
+ .replace(/\r?\n+/g, " ")
1948
+ .trim();
1949
+ const rawPrompt = [
1921
1950
  `You are ${worker.id} in gjc team ${config.team_name}.`,
1922
1951
  `Team state root: ${config.state_root}.`,
1923
1952
  workspace,
@@ -1926,6 +1955,7 @@ export function buildWorkerCommand(
1926
1955
  `Before claiming work, send startup ACK: gjc team api worker-startup-ack --input '{"team_name":"${config.team_name}","worker_id":"${worker.id}","protocol_version":"1"}' --json.`,
1927
1956
  `Use gjc team api update-worker-status to report task-local activity, then claim-task/transition-task-status with this worker id; keep heartbeat current during long work, record completion_evidence (summary plus a passed command or verified inspection/artifact item) before completed, and do not mutate leader-owned goal state.`,
1928
1957
  ].join("\n");
1958
+ const prompt = normalizePrompt(rawPrompt) || `Worker ${worker.id} ready.`;
1929
1959
  const envLines = [
1930
1960
  envAssignment("GJC_TEAM_WORKER", `${config.team_name}/${worker.id}`),
1931
1961
  envAssignment("GJC_TEAM_INTERNAL_WORKER", `${config.team_name}/${worker.id}`),
@@ -1937,6 +1967,32 @@ export function buildWorkerCommand(
1937
1967
  ...(worker.worktree_path ? [envAssignment("GJC_TEAM_WORKTREE_PATH", worker.worktree_path)] : []),
1938
1968
  ];
1939
1969
  const joined = platform === "win32" ? envLines.join(" ") : envLines.join(" ");
1970
+ // On Windows we wrap the worker command invocation in `& { & 'cmd' 'arg1' ... }`
1971
+ // so pwsh keeps the whole multi-statement body in command position. Two
1972
+ // failure modes this avoids:
1973
+ // 1. Bare `bun 'cli.ts' 'prompt'` after `$env:X = 'y'; ...` would be
1974
+ // parsed in expression position and PowerShell would reject the
1975
+ // second quoted token with "Unexpected token '<cli.ts>'".
1976
+ // 2. `& { 'cmd' 'arg1' 'arg2' }` (single & inside a block with adjacent
1977
+ // single-quoted tokens) is itself invalid because pwsh does not
1978
+ // concatenate adjacent single-quoted strings inside a script block.
1979
+ // The nested `&` inside the block forces command-position parsing for
1980
+ // the invocation. POSIX shells do not need this — they already treat
1981
+ // `cmd 'arg'` as a normal command invocation after `;`-separated
1982
+ // variable assignments.
1983
+ //
1984
+ // ASSUMPTION: this branch only fires when the worker pane is a PowerShell
1985
+ // shell. psmux launches pwsh by default on Windows, so the unset /
1986
+ // unset-and-replace cases are correct. If a user has explicitly set
1987
+ // `set -g default-shell "C:/Program Files/Git/bin/bash.exe"` (or any
1988
+ // other non-pwsh shell), this branch will send PowerShell syntax to a
1989
+ // bash pane and the worker will fail with a parse error. Detecting the
1990
+ // pane's shell at runtime and switching the quoting + invocation style
1991
+ // accordingly is a follow-up; for now the pwsh default is assumed.
1992
+ if (platform === "win32") {
1993
+ const invocation = `& ${config.worker_command} ${quote(prompt)}`;
1994
+ return `& { ${joined} ${invocation} }`;
1995
+ }
1940
1996
  return `${joined} ${config.worker_command} ${quote(prompt)}`;
1941
1997
  }
1942
1998
  interface GjcTeamInitialLane {
@@ -2055,7 +2111,6 @@ async function startTmuxSession(
2055
2111
  "#{pane_id}",
2056
2112
  "-c",
2057
2113
  worker.worktree_path ?? config.leader.cwd,
2058
- buildWorkerCommand(config, worker),
2059
2114
  ],
2060
2115
  { stdout: "pipe", stderr: "pipe" },
2061
2116
  );
@@ -2066,6 +2121,31 @@ async function startTmuxSession(
2066
2121
  rollbackPaneIds.push(paneId);
2067
2122
  if (worker.index === 1) rightStackRootPaneId = paneId;
2068
2123
  workers.push({ ...worker, pane_id: paneId });
2124
+ // On psmux/ConPTY (Windows pwsh default-shell) panes, tmux writes the
2125
+ // split-window command argv to the new pane's stdin but pwsh's
2126
+ // readline never receives an Enter, so the worker never starts.
2127
+ // Create the pane without a command, then dispatch the worker
2128
+ // command through send-keys + Enter so it actually executes.
2129
+ // Two-step dispatch because tmux's `-l` (literal mode) flag is
2130
+ // global per send-keys invocation, not per arg: passing `-l
2131
+ // <body> Enter` would treat "Enter" as literal text too. Sending
2132
+ // the body in literal mode first and the Enter keypress second
2133
+ // keeps the body verbatim while still submitting the prompt as a
2134
+ // keystroke.
2135
+ const body = buildWorkerCommand(config, worker);
2136
+ Bun.spawnSync([config.tmux_command, "send-keys", "-l", "-t", paneId, body], {
2137
+ stdout: "ignore",
2138
+ stderr: "ignore",
2139
+ });
2140
+ const sendKeys = Bun.spawnSync([config.tmux_command, "send-keys", "-t", paneId, "Enter"], {
2141
+ stdout: "ignore",
2142
+ stderr: "ignore",
2143
+ });
2144
+ // void-cast the exit code so the linter does not flag an unused
2145
+ // expression; the value is intentionally discarded here because
2146
+ // the actual spawn outcome is recovered by the leader through
2147
+ // the worker startup-ack watcher, not via the spawn exit code.
2148
+ void sendKeys.exitCode;
2069
2149
  }
2070
2150
  Bun.spawnSync([config.tmux_command, "select-layout", "-t", config.tmux_target, "main-vertical"], {
2071
2151
  stdout: "ignore",