@gajae-code/coding-agent 0.7.8 → 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 (60) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +6 -0
  3. package/dist/types/config/keybindings.d.ts +1 -1
  4. package/dist/types/config/model-profile-activation.d.ts +19 -2
  5. package/dist/types/config/model-profiles.d.ts +1 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +2 -2
  8. package/dist/types/gjc-runtime/launch-tmux.d.ts +12 -3
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
  10. package/dist/types/modes/components/hook-input.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +9 -0
  12. package/dist/types/modes/components/status-line.d.ts +3 -0
  13. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  14. package/dist/types/modes/interactive-mode.d.ts +3 -1
  15. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
  16. package/dist/types/modes/types.d.ts +3 -1
  17. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  18. package/dist/types/notifications/telegram-daemon-cli.d.ts +8 -2
  19. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  20. package/dist/types/tools/ask.d.ts +24 -0
  21. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  22. package/package.json +7 -7
  23. package/src/cli/notify-cli.ts +1 -1
  24. package/src/cli.ts +1 -0
  25. package/src/config/keybindings.ts +1 -1
  26. package/src/config/model-profile-activation.ts +92 -11
  27. package/src/config/model-profiles.ts +15 -2
  28. package/src/config/model-registry.ts +81 -5
  29. package/src/config/settings-schema.ts +3 -2
  30. package/src/coordinator-mcp/server.ts +4 -2
  31. package/src/deep-interview/render-middleware.ts +1 -0
  32. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -6
  33. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  34. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  35. package/src/gjc-runtime/launch-tmux.ts +303 -46
  36. package/src/gjc-runtime/team-runtime.ts +69 -4
  37. package/src/gjc-runtime/ultragoal-guard.ts +11 -5
  38. package/src/internal-urls/docs-index.generated.ts +6 -6
  39. package/src/modes/components/custom-model-preset-wizard.ts +2 -4
  40. package/src/modes/components/hook-editor.ts +7 -4
  41. package/src/modes/components/hook-input.ts +4 -0
  42. package/src/modes/components/model-selector.ts +84 -13
  43. package/src/modes/components/settings-selector.ts +36 -21
  44. package/src/modes/components/status-line/segments.ts +10 -2
  45. package/src/modes/components/status-line.ts +1 -0
  46. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  47. package/src/modes/controllers/selector-controller.ts +130 -7
  48. package/src/modes/interactive-mode.ts +7 -2
  49. package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
  50. package/src/modes/types.ts +6 -1
  51. package/src/notifications/daemon-paths.ts +22 -0
  52. package/src/notifications/telegram-daemon-cli.ts +13 -4
  53. package/src/notifications/telegram-daemon.ts +2 -21
  54. package/src/prompts/system/system-prompt.md +5 -0
  55. package/src/sdk.ts +10 -1
  56. package/src/session/agent-session.ts +10 -1
  57. package/src/tools/ask.ts +11 -1
  58. package/src/tools/eval.ts +2 -2
  59. package/src/tools/render-utils.ts +2 -0
  60. 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,7 @@ 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,
9
11
  buildGjcTmuxExactSessionTarget,
10
12
  buildGjcTmuxProfileCommands,
11
13
  buildGjcTmuxSessionName,
@@ -35,6 +37,7 @@ export const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
35
37
  export const GJC_LAUNCH_POLICY_ENV = "GJC_LAUNCH_POLICY";
36
38
  export const GJC_TMUX_WINDOW_LABEL_MAX_WIDTH = 48;
37
39
  export const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE";
40
+ const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
38
41
 
39
42
  type LaunchPolicy = "direct" | "tmux";
40
43
 
@@ -72,9 +75,18 @@ export type TmuxSpawnSync = (command: string, args: string[], options: TmuxSpawn
72
75
  export interface TmuxSpawnOptions {
73
76
  cwd: string;
74
77
  env: NodeJS.ProcessEnv;
75
- stdin: "inherit";
76
- stdout: "inherit";
77
- 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;
78
90
  }
79
91
 
80
92
  export interface TmuxLaunchPlan {
@@ -170,6 +182,48 @@ function formatTmuxLaunchDiagnostic(stage: string, stderr?: string): string {
170
182
  return `gjc --tmux failed after creating tmux session: ${stage}.${suffix}\n`;
171
183
  }
172
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
+ }
173
227
  function formatTmuxUnavailableDiagnostic(platform: NodeJS.Platform, tmuxCommand: string): string {
174
228
  if (platform === "win32") {
175
229
  return (
@@ -203,24 +257,30 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
203
257
  const envLines = Object.entries({ [GJC_TMUX_LAUNCHED_ENV]: "1", ...(context.extraEnv ?? {}) }).map(
204
258
  ([key, value]) => `$env:${key} = ${powershellQuote(value)}`,
205
259
  );
206
- // The inner `&` invocation must wrap the resolved gjc command in a
207
- // script-block so the trailing PowerShell exec-policy flags from the
208
- // outer invocation are not forwarded into the bun / node / .bat
209
- // binary the gjc command resolves to. Without the wrapping, the flags
210
- // reach the runtime binary and cause an immediate failure that closes
211
- // the psmux pane before the gjc --tmux attach can land.
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.
212
271
  const resolvedCommand = command.map(powershellQuote).join(" ");
213
272
  const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
214
- const invocation = `& { ${resolvedCommand} ${innerArgs} }`;
273
+ const invocation = `& ${resolvedCommand} ${innerArgs}`;
215
274
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
216
275
  const script = [...envLines, invocation, exitLine].join("\n");
217
- // PowerShell -EncodedCommand requires a UTF-16LE BOM (0xFF 0xFE) at the
218
- // start of the decoded buffer; without it pwsh may misinterpret the
219
- // leading bytes and reject the script with a parse error, which would
220
- // kill the psmux pane before the gjc --tmux attach could land.
221
- const bom = Buffer.from([0xff, 0xfe]);
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.
222
282
  const body = Buffer.from(script, "utf16le");
223
- const encodedCommand = Buffer.concat([bom, body]).toString("base64");
283
+ const encodedCommand = body.toString("base64");
224
284
  return `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
225
285
  }
226
286
 
@@ -250,7 +310,14 @@ export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProf
250
310
  if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
251
311
  const spawnSync = context.spawnSync ?? defaultSpawnSync;
252
312
  const cwd = context.cwd ?? process.cwd();
253
- 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
+ };
254
321
  const failures: GjcTmuxProfileResult["failures"] = [];
255
322
  for (const command of commands) {
256
323
  const result = spawnSync(context.tmuxCommand, command.args, options);
@@ -321,6 +388,8 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
321
388
  }
322
389
 
323
390
  const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
391
+ const GJC_TMUX_WINDOW_TITLE_PREFIX = "GJC-";
392
+ const GJC_TMUX_TERMINAL_TITLE_PREFIX = "GJC: ";
324
393
 
325
394
  function sanitizeTmuxWindowTitleSegment(value: string): string {
326
395
  return value.replace(/:+/g, "-");
@@ -333,20 +402,68 @@ function sanitizeTmuxWindowProjectName(project: string): string {
333
402
  return sanitizeTmuxWindowTitleSegment(trimmed);
334
403
  }
335
404
 
336
- export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
405
+ function buildGjcTmuxPrefixedTitle(prefix: string, cwd: string, branch: string | null | undefined): string {
337
406
  const project = sanitizeTmuxWindowProjectName(path.basename(path.resolve(cwd)) || "gjc");
407
+ const projectTitle = `${prefix}${project}`;
338
408
  const trimmedBranch = sanitizeTmuxWindowTitleSegment(branch?.trim() ?? "");
339
- if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
409
+ if (!trimmedBranch) return truncateVisible(projectTitle, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
340
410
 
341
411
  const separatorWidth = visibleWidth(GJC_TMUX_WINDOW_BRANCH_SEPARATOR);
342
- const projectWidth = visibleWidth(project);
343
- const fullTitle = `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
412
+ const projectWidth = visibleWidth(projectTitle);
413
+ const fullTitle = `${projectTitle}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
344
414
  if (visibleWidth(fullTitle) <= GJC_TMUX_WINDOW_LABEL_MAX_WIDTH) return fullTitle;
345
415
 
346
416
  const remainingBranchWidth = GJC_TMUX_WINDOW_LABEL_MAX_WIDTH - projectWidth - separatorWidth;
347
- 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
+ }
348
421
 
349
- return `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
422
+ export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
423
+ return buildGjcTmuxPrefixedTitle(GJC_TMUX_WINDOW_TITLE_PREFIX, cwd, branch);
424
+ }
425
+
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;
350
467
  }
351
468
 
352
469
  function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
@@ -490,15 +607,33 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
490
607
  }
491
608
 
492
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 };
493
618
  const result = Bun.spawnSync({
494
619
  cmd: [command, ...args],
495
620
  cwd: options.cwd,
496
621
  env: options.env,
497
- stdin: options.stdin,
498
- stdout: options.stdout,
499
- stderr: options.stderr,
622
+ ...stdio,
500
623
  });
501
- 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 };
502
637
  }
503
638
 
504
639
  export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
@@ -515,27 +650,64 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
515
650
  stdout: "inherit",
516
651
  stderr: "inherit",
517
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
+ };
680
+
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;
518
685
 
519
686
  if (plan.attachSessionName) {
687
+ applyGjcTmuxRootTerminalTitleProfile({
688
+ tmuxCommand: plan.tmuxCommand,
689
+ target: plan.attachSessionName,
690
+ title: rootTerminalTitle,
691
+ spawnSync,
692
+ options,
693
+ });
520
694
  const attached = spawnSync(
521
695
  plan.tmuxCommand,
522
696
  ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
523
- options,
697
+ attachOptions,
524
698
  );
525
699
  if (attached.exitCode === 0) return true;
526
700
  }
527
-
528
- const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
701
+ const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
529
702
  if (created.exitCode === 0) {
530
- renameTmuxWindow(
531
- plan.tmuxCommand,
532
- buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
533
- spawnSync,
534
- options,
535
- buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
536
- );
537
-
538
- const profile = applyGjcTmuxProfile({
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 => ({
539
711
  tmuxCommand: plan.tmuxCommand,
540
712
  target: plan.sessionName,
541
713
  cwd: plan.cwd,
@@ -547,20 +719,105 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
547
719
  sessionStateFile: plan.sessionStateFile ?? null,
548
720
  version: VERSION,
549
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.
550
759
  const ownershipFailure = profile.failures.find(item => item.command.args.includes("@gjc-profile"));
551
760
  if (ownershipFailure) {
552
- cleanupCreatedTmuxSession(plan, spawnSync, options);
553
- (context.diagnosticWriter ?? safeStderrWrite)(
554
- formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
555
- );
556
- 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.
557
793
  }
794
+ applyGjcTmuxRootTerminalTitleProfile({
795
+ tmuxCommand: plan.tmuxCommand,
796
+ target: plan.sessionName,
797
+ title: rootTerminalTitle,
798
+ spawnSync,
799
+ options,
800
+ });
558
801
  }
559
- if (created.exitCode !== 0) return false;
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.
560
817
  const attached = spawnSync(
561
818
  plan.tmuxCommand,
562
819
  ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
563
- options,
820
+ attachOptions,
564
821
  );
565
822
  if (attached.exitCode === 0) return true;
566
823
  if (isTmuxAttachDisconnectError(attached)) {
@@ -1931,7 +1931,22 @@ export function buildWorkerCommand(
1931
1931
  const workspace = worker.worktree_path
1932
1932
  ? `Worker worktree: ${worker.worktree_path}.`
1933
1933
  : `Worker cwd: ${config.leader.cwd}.`;
1934
- 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 = [
1935
1950
  `You are ${worker.id} in gjc team ${config.team_name}.`,
1936
1951
  `Team state root: ${config.state_root}.`,
1937
1952
  workspace,
@@ -1940,6 +1955,7 @@ export function buildWorkerCommand(
1940
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.`,
1941
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.`,
1942
1957
  ].join("\n");
1958
+ const prompt = normalizePrompt(rawPrompt) || `Worker ${worker.id} ready.`;
1943
1959
  const envLines = [
1944
1960
  envAssignment("GJC_TEAM_WORKER", `${config.team_name}/${worker.id}`),
1945
1961
  envAssignment("GJC_TEAM_INTERNAL_WORKER", `${config.team_name}/${worker.id}`),
@@ -1951,8 +1967,33 @@ export function buildWorkerCommand(
1951
1967
  ...(worker.worktree_path ? [envAssignment("GJC_TEAM_WORKTREE_PATH", worker.worktree_path)] : []),
1952
1968
  ];
1953
1969
  const joined = platform === "win32" ? envLines.join(" ") : envLines.join(" ");
1954
- const invocation = platform === "win32" ? `& ${config.worker_command}` : config.worker_command;
1955
- return `${joined} ${invocation} ${quote(prompt)}`;
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
+ }
1996
+ return `${joined} ${config.worker_command} ${quote(prompt)}`;
1956
1997
  }
1957
1998
  interface GjcTeamInitialLane {
1958
1999
  label: string;
@@ -2070,7 +2111,6 @@ async function startTmuxSession(
2070
2111
  "#{pane_id}",
2071
2112
  "-c",
2072
2113
  worker.worktree_path ?? config.leader.cwd,
2073
- buildWorkerCommand(config, worker),
2074
2114
  ],
2075
2115
  { stdout: "pipe", stderr: "pipe" },
2076
2116
  );
@@ -2081,6 +2121,31 @@ async function startTmuxSession(
2081
2121
  rollbackPaneIds.push(paneId);
2082
2122
  if (worker.index === 1) rightStackRootPaneId = paneId;
2083
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;
2084
2149
  }
2085
2150
  Bun.spawnSync([config.tmux_command, "select-layout", "-t", config.tmux_target, "main-vertical"], {
2086
2151
  stdout: "ignore",
@@ -69,9 +69,12 @@ function isKnownUltragoalObjective(currentObjective: string): boolean {
69
69
  );
70
70
  }
71
71
 
72
- async function ultragoalReadPaths(cwd: string): Promise<{ paths: UltragoalPaths; sessionId: string | null }> {
73
- const envSessionId = process.env.GJC_SESSION_ID?.trim();
74
- if (envSessionId) return { paths: getUltragoalPaths(cwd, envSessionId), sessionId: envSessionId };
72
+ async function ultragoalReadPaths(
73
+ cwd: string,
74
+ options: { sessionId?: string | null } = {},
75
+ ): Promise<{ paths: UltragoalPaths; sessionId: string | null }> {
76
+ const explicitSessionId = options.sessionId?.trim() || process.env.GJC_SESSION_ID?.trim();
77
+ if (explicitSessionId) return { paths: getUltragoalPaths(cwd, explicitSessionId), sessionId: explicitSessionId };
75
78
  try {
76
79
  const session = await resolveGjcSessionForRead(cwd, { envSessionId: process.env.GJC_SESSION_ID });
77
80
  return { paths: getUltragoalPaths(cwd, session.gjcSessionId), sessionId: session.gjcSessionId };
@@ -372,11 +375,14 @@ export async function readUltragoalVerificationState(input: {
372
375
  return receiptDiagnostic;
373
376
  }
374
377
 
375
- export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBlockDiagnostic> {
378
+ export async function isUltragoalAskBlocked(
379
+ cwd: string,
380
+ options: { sessionId?: string | null } = {},
381
+ ): Promise<UltragoalAskBlockDiagnostic> {
376
382
  let paths: UltragoalPaths;
377
383
  let sessionId: string | null;
378
384
  try {
379
- ({ paths, sessionId } = await ultragoalReadPaths(cwd));
385
+ ({ paths, sessionId } = await ultragoalReadPaths(cwd, options));
380
386
  } catch (error) {
381
387
  return activeAskDiagnostic({
382
388
  reason: `Unable to resolve durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,