@gajae-code/coding-agent 0.7.8 → 0.7.10

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 (92) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +8 -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 +12 -3
  8. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  9. package/dist/types/gjc-runtime/launch-tmux.d.ts +19 -3
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -1
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
  12. package/dist/types/modes/components/hook-input.d.ts +1 -0
  13. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  14. package/dist/types/modes/components/model-selector.d.ts +9 -0
  15. package/dist/types/modes/components/status-line.d.ts +3 -0
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +3 -1
  18. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +41 -7
  19. package/dist/types/modes/types.d.ts +3 -1
  20. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  21. package/dist/types/notifications/html-format.d.ts +2 -0
  22. package/dist/types/notifications/recent-activity.d.ts +5 -1
  23. package/dist/types/notifications/telegram-daemon-cli.d.ts +8 -2
  24. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  25. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  26. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  27. package/dist/types/notifications/threaded-render.d.ts +1 -0
  28. package/dist/types/tools/ask.d.ts +26 -0
  29. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  30. package/dist/types/utils/cmux-workspace.d.ts +23 -0
  31. package/dist/types/web/search/index.d.ts +1 -1
  32. package/dist/types/web/search/provider.d.ts +5 -0
  33. package/dist/types/web/search/render.d.ts +1 -0
  34. package/package.json +7 -7
  35. package/src/cli/notify-cli.ts +1 -1
  36. package/src/cli/web-search-cli.ts +3 -2
  37. package/src/cli.ts +1 -0
  38. package/src/config/keybindings.ts +1 -1
  39. package/src/config/model-profile-activation.ts +92 -11
  40. package/src/config/model-profiles.ts +15 -2
  41. package/src/config/model-registry.ts +81 -5
  42. package/src/config/settings-schema.ts +14 -3
  43. package/src/coordinator-mcp/server.ts +4 -2
  44. package/src/deep-interview/render-middleware.ts +1 -0
  45. package/src/defaults/gjc/skills/deep-interview/SKILL.md +40 -8
  46. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  47. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
  48. package/src/extensibility/extensions/types.ts +11 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  50. package/src/gjc-runtime/launch-tmux.ts +366 -48
  51. package/src/gjc-runtime/team-runtime.ts +69 -4
  52. package/src/gjc-runtime/ultragoal-guard.ts +150 -24
  53. package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
  54. package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
  55. package/src/gjc-runtime/workflow-manifest.ts +0 -1
  56. package/src/hooks/skill-state.ts +33 -73
  57. package/src/internal-urls/docs-index.generated.ts +8 -7
  58. package/src/modes/components/custom-model-preset-wizard.ts +2 -4
  59. package/src/modes/components/hook-editor.ts +7 -4
  60. package/src/modes/components/hook-input.ts +4 -0
  61. package/src/modes/components/hook-selector.ts +21 -3
  62. package/src/modes/components/model-selector.ts +84 -13
  63. package/src/modes/components/settings-selector.ts +83 -24
  64. package/src/modes/components/status-line/presets.ts +11 -0
  65. package/src/modes/components/status-line/segments.ts +10 -2
  66. package/src/modes/components/status-line.ts +1 -0
  67. package/src/modes/controllers/extension-ui-controller.ts +20 -2
  68. package/src/modes/controllers/selector-controller.ts +130 -7
  69. package/src/modes/interactive-mode.ts +7 -2
  70. package/src/modes/shared/agent-wire/deep-interview-gate.ts +64 -13
  71. package/src/modes/types.ts +6 -1
  72. package/src/notifications/daemon-paths.ts +22 -0
  73. package/src/notifications/html-format.ts +75 -1
  74. package/src/notifications/index.ts +12 -1
  75. package/src/notifications/recent-activity.ts +32 -8
  76. package/src/notifications/telegram-daemon-cli.ts +13 -4
  77. package/src/notifications/telegram-daemon-control.ts +9 -0
  78. package/src/notifications/telegram-daemon.ts +69 -45
  79. package/src/notifications/telegram-reference.ts +36 -14
  80. package/src/notifications/threaded-render.ts +14 -6
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/sdk.ts +13 -6
  83. package/src/session/agent-session.ts +10 -1
  84. package/src/tools/ask.ts +147 -17
  85. package/src/tools/eval.ts +2 -2
  86. package/src/tools/render-utils.ts +2 -0
  87. package/src/tools/ultragoal-ask-guard.ts +39 -5
  88. package/src/utils/cmux-workspace.ts +106 -0
  89. package/src/utils/title-generator.ts +2 -0
  90. package/src/web/search/index.ts +22 -10
  91. package/src/web/search/provider.ts +15 -0
  92. package/src/web/search/render.ts +3 -0
@@ -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,12 +37,15 @@ 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
 
41
44
  interface TtyState {
42
45
  stdin: boolean;
43
46
  stdout: boolean;
47
+ columns?: number;
48
+ rows?: number;
44
49
  }
45
50
 
46
51
  export interface TmuxLaunchContext {
@@ -66,15 +71,28 @@ export interface TmuxSpawnResult {
66
71
  signalCode?: string | null;
67
72
  stderr?: string;
68
73
  }
74
+ export interface TmuxTerminalSize {
75
+ columns: number;
76
+ rows: number;
77
+ }
69
78
 
70
79
  export type TmuxSpawnSync = (command: string, args: string[], options: TmuxSpawnOptions) => TmuxSpawnResult;
71
80
 
72
81
  export interface TmuxSpawnOptions {
73
82
  cwd: string;
74
83
  env: NodeJS.ProcessEnv;
75
- stdin: "inherit";
76
- stdout: "inherit";
77
- stderr: "inherit";
84
+ stdin: "inherit" | "pipe";
85
+ stdout: "inherit" | "pipe";
86
+ stderr: "inherit" | "pipe";
87
+ /**
88
+ * When true, the spawn captures stderr into a buffer and forwards it to
89
+ * the parent stderr so the user still sees the live output. The captured
90
+ * text is also returned in TmuxSpawnResult.stderr for diagnostic
91
+ * surfacing in "attach failed" / "profile tagging failed" messages.
92
+ * Defaults to false to preserve the previous PTY-binding behavior for
93
+ * attach-session and other interactive commands.
94
+ */
95
+ captureStderr?: boolean;
78
96
  }
79
97
 
80
98
  export interface TmuxLaunchPlan {
@@ -83,6 +101,7 @@ export interface TmuxLaunchPlan {
83
101
  cwd: string;
84
102
  innerCommand: string;
85
103
  newSessionArgs: string[];
104
+ initialSize?: TmuxTerminalSize;
86
105
  branch?: string | null;
87
106
  attachSessionName?: string;
88
107
  project?: string | null;
@@ -170,6 +189,48 @@ function formatTmuxLaunchDiagnostic(stage: string, stderr?: string): string {
170
189
  return `gjc --tmux failed after creating tmux session: ${stage}.${suffix}\n`;
171
190
  }
172
191
 
192
+ /**
193
+ * Detect a corrupted gjc.cmd / gjc.bat wrapper at well-known PATH locations.
194
+ * On Windows, `gjc.cmd` / `gjc.bat` files at the front of PATH that turn out
195
+ * to be PE-binary garbage (e.g. a 194MB PE image written over the wrapper)
196
+ * cause cmd.exe to hang silently when invoked from PowerShell — cmd reads
197
+ * the binary as text and never returns, so the user sees the prompt return
198
+ * with no output but no actual launch. This probe surfaces that failure mode
199
+ * in the diagnostic so the user gets a clear "wrapper corrupted" hint instead
200
+ * of a silent exit. Best-effort: returns null when the file is missing,
201
+ * unreadable, or under 1KB (real CMD wrappers are 100-500 bytes; the original
202
+ * 194MB PE-binary garbage was obviously out of band). Sync because the
203
+ * call site (launchDefaultTmuxIfNeeded) is sync; uses statSync + 2-byte
204
+ * read.
205
+ */
206
+ function detectCorruptedGjcWrapper(): string | null {
207
+ if (process.platform !== "win32") return null;
208
+ const pathEnv = process.env.PATH ?? "";
209
+ if (!pathEnv) return null;
210
+ const seen = new Set<string>();
211
+ for (const dir of pathEnv.split(path.delimiter)) {
212
+ for (const name of ["gjc.cmd", "gjc.bat"]) {
213
+ const full = path.join(dir, name);
214
+ if (seen.has(full)) continue;
215
+ seen.add(full);
216
+ try {
217
+ const stat = fs.statSync(full);
218
+ if (!stat.isFile()) continue;
219
+ if (stat.size < 1024) continue;
220
+ if (stat.size > 64 * 1024) {
221
+ 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.`;
222
+ }
223
+ const head = fs.readFileSync(full);
224
+ if (head.byteLength < 2) continue;
225
+ const view = new Uint8Array(head);
226
+ if (view[0] === 0x4d && view[1] === 0x5a) {
227
+ 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.`;
228
+ }
229
+ } catch {}
230
+ }
231
+ }
232
+ return null;
233
+ }
173
234
  function formatTmuxUnavailableDiagnostic(platform: NodeJS.Platform, tmuxCommand: string): string {
174
235
  if (platform === "win32") {
175
236
  return (
@@ -203,24 +264,30 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
203
264
  const envLines = Object.entries({ [GJC_TMUX_LAUNCHED_ENV]: "1", ...(context.extraEnv ?? {}) }).map(
204
265
  ([key, value]) => `$env:${key} = ${powershellQuote(value)}`,
205
266
  );
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.
267
+ // Resolve the inner command and arguments. PowerShell's `&` call operator
268
+ // accepts a single command followed by its arguments directly (no script
269
+ // block needed). Wrapping the call in `& { ... }` would be invalid because
270
+ // adjacent single-quoted tokens inside a script block body are a parser
271
+ // error: `& { 'a' 'b' }` fails with "Unexpected token 'b'" because PowerShell
272
+ // only concatenates adjacent *double-quoted* strings, and even then only in
273
+ // expression position. Emitting the arguments as a comma-separated array
274
+ // (`& 'cmd' @('a','b')`) is also rejected because arrays are not valid as
275
+ // the second-and-later positional arguments to `&` in command position. The
276
+ // correct form is `& 'cmd' 'arg1' 'arg2'` — exactly what the joined
277
+ // resolvedCommand + innerArgs produces below.
212
278
  const resolvedCommand = command.map(powershellQuote).join(" ");
213
279
  const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
214
- const invocation = `& { ${resolvedCommand} ${innerArgs} }`;
280
+ const invocation = `& ${resolvedCommand} ${innerArgs}`;
215
281
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
216
282
  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]);
283
+ // Encode the script as UTF-16LE base64 for pwsh -EncodedCommand. Do NOT
284
+ // prepend a UTF-16LE BOM (0xFF 0xFE): the BOM survives the decode and is
285
+ // inserted as a literal U+FEFF character in front of the first script
286
+ // token, which pwsh then reports as a "term not recognized" parse error
287
+ // (e.g. "$env:GJC_TMUX_LAUNCHED"). pwsh expects the decoded buffer to
288
+ // start with the first character of the script, not with a BOM.
222
289
  const body = Buffer.from(script, "utf16le");
223
- const encodedCommand = Buffer.concat([bom, body]).toString("base64");
290
+ const encodedCommand = body.toString("base64");
224
291
  return `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
225
292
  }
226
293
 
@@ -250,7 +317,14 @@ export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProf
250
317
  if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
251
318
  const spawnSync = context.spawnSync ?? defaultSpawnSync;
252
319
  const cwd = context.cwd ?? process.cwd();
253
- const options: TmuxSpawnOptions = { cwd, env, stdin: "inherit", stdout: "inherit", stderr: "inherit" };
320
+ const options: TmuxSpawnOptions = {
321
+ cwd,
322
+ env,
323
+ stdin: "inherit",
324
+ stdout: "inherit",
325
+ stderr: "inherit",
326
+ captureStderr: true,
327
+ };
254
328
  const failures: GjcTmuxProfileResult["failures"] = [];
255
329
  for (const command of commands) {
256
330
  const result = spawnSync(context.tmuxCommand, command.args, options);
@@ -321,6 +395,8 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
321
395
  }
322
396
 
323
397
  const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
398
+ const GJC_TMUX_WINDOW_TITLE_PREFIX = "GJC-";
399
+ const GJC_TMUX_TERMINAL_TITLE_PREFIX = "GJC: ";
324
400
 
325
401
  function sanitizeTmuxWindowTitleSegment(value: string): string {
326
402
  return value.replace(/:+/g, "-");
@@ -333,20 +409,68 @@ function sanitizeTmuxWindowProjectName(project: string): string {
333
409
  return sanitizeTmuxWindowTitleSegment(trimmed);
334
410
  }
335
411
 
336
- export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
412
+ function buildGjcTmuxPrefixedTitle(prefix: string, cwd: string, branch: string | null | undefined): string {
337
413
  const project = sanitizeTmuxWindowProjectName(path.basename(path.resolve(cwd)) || "gjc");
414
+ const projectTitle = `${prefix}${project}`;
338
415
  const trimmedBranch = sanitizeTmuxWindowTitleSegment(branch?.trim() ?? "");
339
- if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
416
+ if (!trimmedBranch) return truncateVisible(projectTitle, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
340
417
 
341
418
  const separatorWidth = visibleWidth(GJC_TMUX_WINDOW_BRANCH_SEPARATOR);
342
- const projectWidth = visibleWidth(project);
343
- const fullTitle = `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
419
+ const projectWidth = visibleWidth(projectTitle);
420
+ const fullTitle = `${projectTitle}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
344
421
  if (visibleWidth(fullTitle) <= GJC_TMUX_WINDOW_LABEL_MAX_WIDTH) return fullTitle;
345
422
 
346
423
  const remainingBranchWidth = GJC_TMUX_WINDOW_LABEL_MAX_WIDTH - projectWidth - separatorWidth;
347
- if (remainingBranchWidth <= 0) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
424
+ if (remainingBranchWidth <= 0) return truncateVisible(projectTitle, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
425
+
426
+ return `${projectTitle}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
427
+ }
428
+
429
+ export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
430
+ return buildGjcTmuxPrefixedTitle(GJC_TMUX_WINDOW_TITLE_PREFIX, cwd, branch);
431
+ }
432
+
433
+ function buildGjcTmuxRootTerminalTitle(cwd: string, branch: string | null | undefined): string {
434
+ return buildGjcTmuxPrefixedTitle(GJC_TMUX_TERMINAL_TITLE_PREFIX, cwd, branch);
435
+ }
436
+
437
+ function sanitizeGjcTmuxRootTerminalTitle(title: string): string {
438
+ return title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").trim() || "GJC";
439
+ }
348
440
 
349
- return `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
441
+ function escapeTmuxFormatLiteral(value: string): string {
442
+ return value.replace(/#/g, "##");
443
+ }
444
+
445
+ function buildGjcTmuxRootTerminalTitleCommands(target: string, title: string): GjcTmuxProfileCommand[] {
446
+ const sanitized = escapeTmuxFormatLiteral(sanitizeGjcTmuxRootTerminalTitle(title));
447
+ return [
448
+ { description: "enable tmux client terminal title", args: ["set-option", "-t", target, "set-titles", "on"] },
449
+ {
450
+ description: "set tmux client terminal title",
451
+ args: ["set-option", "-t", target, "set-titles-string", sanitized],
452
+ },
453
+ ];
454
+ }
455
+
456
+ function applyGjcTmuxRootTerminalTitleProfile(context: {
457
+ tmuxCommand: string;
458
+ target: string;
459
+ title: string | undefined;
460
+ spawnSync: TmuxSpawnSync;
461
+ options: TmuxSpawnOptions;
462
+ }): void {
463
+ if (!context.title) return;
464
+ for (const command of buildGjcTmuxRootTerminalTitleCommands(
465
+ buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env }),
466
+ context.title,
467
+ )) {
468
+ context.spawnSync(context.tmuxCommand, command.args, context.options);
469
+ }
470
+ }
471
+
472
+ function shouldSetGjcTmuxRootTerminalTitle(parsed: Args, env: NodeJS.ProcessEnv): boolean {
473
+ return !parsed.noTitle && !env.PI_NO_TITLE;
350
474
  }
351
475
 
352
476
  function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
@@ -420,6 +544,43 @@ function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
420
544
  const stderr = result.stderr?.toLowerCase() ?? "";
421
545
  return stderr.includes("eio") || stderr.includes("input/output error");
422
546
  }
547
+ function normalizeTmuxTerminalDimension(value: number | undefined): number | undefined {
548
+ if (value === undefined || !Number.isSafeInteger(value) || value <= 0) return undefined;
549
+ return value;
550
+ }
551
+
552
+ function resolveCallerTmuxTerminalSize(tty: TtyState): TmuxTerminalSize | undefined {
553
+ if (!tty.stdout) return undefined;
554
+ const columns = normalizeTmuxTerminalDimension(tty.columns);
555
+ const rows = normalizeTmuxTerminalDimension(tty.rows);
556
+ if (columns === undefined || rows === undefined) return undefined;
557
+ return { columns, rows };
558
+ }
559
+
560
+ function buildTmuxNewSessionSizeArgs(size: TmuxTerminalSize | undefined): string[] {
561
+ return size ? ["-x", String(size.columns), "-y", String(size.rows)] : [];
562
+ }
563
+
564
+ function resizeCreatedTmuxWindowToCallerTerminalSize(
565
+ plan: TmuxLaunchPlan,
566
+ spawnSync: TmuxSpawnSync,
567
+ options: TmuxSpawnOptions,
568
+ ): void {
569
+ if (!plan.initialSize) return;
570
+ spawnSync(
571
+ plan.tmuxCommand,
572
+ [
573
+ "resize-window",
574
+ "-t",
575
+ buildGjcTmuxExactOptionTarget(plan.sessionName, { env: options.env }),
576
+ "-x",
577
+ String(plan.initialSize.columns),
578
+ "-y",
579
+ String(plan.initialSize.rows),
580
+ ],
581
+ options,
582
+ );
583
+ }
423
584
 
424
585
  export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaunchPlan | undefined {
425
586
  const env = context.env ?? process.env;
@@ -427,7 +588,12 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
427
588
  if (!context.parsed.tmux || policy === "direct") return undefined;
428
589
  if (env.TMUX || env[GJC_TMUX_LAUNCHED_ENV] === "1") return undefined;
429
590
  const platform = context.platform ?? process.platform;
430
- const tty = context.tty ?? { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY) };
591
+ const tty = context.tty ?? {
592
+ stdin: Boolean(process.stdin.isTTY),
593
+ stdout: Boolean(process.stdout.isTTY),
594
+ columns: process.stdout.columns,
595
+ rows: process.stdout.rows,
596
+ };
431
597
  if (policy === "tmux" && !isInteractiveRootLaunch(context.parsed, tty)) return undefined;
432
598
 
433
599
  const cwd = context.cwd ?? process.cwd();
@@ -475,12 +641,23 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
475
641
  },
476
642
  context.rawArgs,
477
643
  );
644
+ const initialSize = resolveCallerTmuxTerminalSize(tty);
478
645
  return {
479
646
  tmuxCommand,
480
647
  sessionName,
481
648
  cwd,
482
649
  innerCommand,
483
- newSessionArgs: ["new-session", "-d", "-s", sessionName, "-c", cwd, innerCommand],
650
+ newSessionArgs: [
651
+ "new-session",
652
+ "-d",
653
+ ...buildTmuxNewSessionSizeArgs(initialSize),
654
+ "-s",
655
+ sessionName,
656
+ "-c",
657
+ cwd,
658
+ innerCommand,
659
+ ],
660
+ initialSize,
484
661
  branch,
485
662
  project,
486
663
  sessionId,
@@ -490,15 +667,33 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
490
667
  }
491
668
 
492
669
  function defaultSpawnSync(command: string, args: string[], options: TmuxSpawnOptions): TmuxSpawnResult {
670
+ // When captureStderr is set on the options, route stderr through a
671
+ // pipe so we can both forward it to the parent stderr (so the user
672
+ // still sees the live output) and retain it in TmuxSpawnResult.stderr
673
+ // for diagnostic surfacing. PTY-bound commands (attach-session) keep
674
+ // stderr: "inherit" because psmux needs a real terminal handle.
675
+ const stdio = options.captureStderr
676
+ ? { stdin: options.stdin, stdout: options.stdout, stderr: "pipe" as const }
677
+ : { stdin: options.stdin, stdout: options.stdout, stderr: options.stderr };
493
678
  const result = Bun.spawnSync({
494
679
  cmd: [command, ...args],
495
680
  cwd: options.cwd,
496
681
  env: options.env,
497
- stdin: options.stdin,
498
- stdout: options.stdout,
499
- stderr: options.stderr,
682
+ ...stdio,
500
683
  });
501
- return { exitCode: result.exitCode, signalCode: result.signalCode };
684
+ let stderrText: string | undefined;
685
+ if (options.captureStderr) {
686
+ const stderrBytes = result.stderr;
687
+ stderrText = stderrBytes ? new TextDecoder().decode(stderrBytes) : "";
688
+ if (stderrText.length > 0) {
689
+ try {
690
+ process.stderr.write(stderrText);
691
+ } catch {
692
+ // parent stderr already closed during shutdown; ignore.
693
+ }
694
+ }
695
+ }
696
+ return { exitCode: result.exitCode, signalCode: result.signalCode, stderr: stderrText };
502
697
  }
503
698
 
504
699
  export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
@@ -515,27 +710,64 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
515
710
  stdout: "inherit",
516
711
  stderr: "inherit",
517
712
  };
713
+ const attachOptions: TmuxSpawnOptions = { ...options };
714
+ const controlOptions: TmuxSpawnOptions = { ...options, captureStderr: true };
715
+ // has-session / new-session retry / profile-tagging probe share these
716
+ // pipe-stdio options. PTY-bound commands (attach-session, rename-window,
717
+ // set-option in applyGjcTmuxProfile when we explicitly need the live TTY)
718
+ // use `options` or `attachOptions` instead.
719
+ const probeOptions: TmuxSpawnOptions = {
720
+ ...options,
721
+ stdin: "pipe",
722
+ stdout: "pipe",
723
+ stderr: "pipe",
724
+ captureStderr: true,
725
+ };
726
+ // new-session needs pipe stdio (not inherit) because the user terminal must
727
+ // remain untouched until attach-session takes over. Inheriting psmux's
728
+ // stdout/stderr for new-session can corrupt the terminal state or race with
729
+ // attach-session on Windows, where psmux 3.3.0/3.3.6's server can die if it
730
+ // sees the controlling TTY in an inconsistent state mid-spawn. Capturing
731
+ // both streams also gives the diagnostic writer the full error detail when
732
+ // new-session itself fails.
733
+ const newSessionOptions: TmuxSpawnOptions = {
734
+ ...options,
735
+ stdin: "pipe",
736
+ stdout: "pipe",
737
+ stderr: "pipe",
738
+ captureStderr: true,
739
+ };
740
+
741
+ const windowTitle = buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch);
742
+ const rootTerminalTitle = shouldSetGjcTmuxRootTerminalTitle(context.parsed, env)
743
+ ? buildGjcTmuxRootTerminalTitle(plan.project ?? plan.cwd, plan.branch)
744
+ : undefined;
518
745
 
519
746
  if (plan.attachSessionName) {
747
+ applyGjcTmuxRootTerminalTitleProfile({
748
+ tmuxCommand: plan.tmuxCommand,
749
+ target: plan.attachSessionName,
750
+ title: rootTerminalTitle,
751
+ spawnSync,
752
+ options,
753
+ });
520
754
  const attached = spawnSync(
521
755
  plan.tmuxCommand,
522
756
  ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
523
- options,
757
+ attachOptions,
524
758
  );
525
759
  if (attached.exitCode === 0) return true;
526
760
  }
527
-
528
- const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
761
+ const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
529
762
  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({
763
+ // On Windows + psmux 3.3.0/3.3.6, new-session can return exit 0
764
+ // before the psmux server finishes registering the session on its
765
+ // control socket. The follow-up set-option / attach-session then
766
+ // fails with "can't find session / no server running". Probe the
767
+ // server first, and if the race fired, retry new-session before
768
+ // we hand the session to rename-window / applyGjcTmuxProfile so the
769
+ // profile-tagging path always sees a registered session.
770
+ const buildProfileInputs = (): GjcTmuxProfileContext => ({
539
771
  tmuxCommand: plan.tmuxCommand,
540
772
  target: plan.sessionName,
541
773
  cwd: plan.cwd,
@@ -547,20 +779,106 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
547
779
  sessionStateFile: plan.sessionStateFile ?? null,
548
780
  version: VERSION,
549
781
  });
782
+ const probeHasSession = (): TmuxSpawnResult =>
783
+ spawnSync(
784
+ plan.tmuxCommand,
785
+ ["has-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
786
+ probeOptions,
787
+ );
788
+ const probeResult = probeHasSession();
789
+ if (probeResult.exitCode !== 0) {
790
+ const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
791
+ const retryProbe = probeHasSession();
792
+ if (retry.exitCode !== 0 || retryProbe.exitCode !== 0) {
793
+ (context.diagnosticWriter ?? safeStderrWrite)(
794
+ formatTmuxLaunchDiagnostic(
795
+ "new-session retry failed after missing session",
796
+ retry.stderr ?? retryProbe.stderr ?? created.stderr,
797
+ ),
798
+ );
799
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
800
+ return false;
801
+ }
802
+ }
803
+ renameTmuxWindow(
804
+ plan.tmuxCommand,
805
+ windowTitle,
806
+ spawnSync,
807
+ controlOptions,
808
+ buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
809
+ );
810
+ const profile = applyGjcTmuxProfile(buildProfileInputs());
811
+ // If the @gjc-profile ownership write failed, the cause can be
812
+ // either (a) a real psmux persistence-tag rejection (e.g.
813
+ // unsupported option on this server), or (b) the same new-session
814
+ // registration race above — psmux returned 0 but the server died
815
+ // before registering, so the follow-up set-option failed with
816
+ // "can't find session". Distinguish the two: re-probe; if the
817
+ // session is genuinely missing, retry new-session and re-apply the
818
+ // profile. Otherwise, surface the persistence-tag failure.
550
819
  const ownershipFailure = profile.failures.find(item => item.command.args.includes("@gjc-profile"));
551
820
  if (ownershipFailure) {
552
- cleanupCreatedTmuxSession(plan, spawnSync, options);
553
- (context.diagnosticWriter ?? safeStderrWrite)(
554
- formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
555
- );
556
- return true;
821
+ const probeAfterOwnership = probeHasSession();
822
+ if (probeAfterOwnership.exitCode === 0) {
823
+ // Session is present; tagging failed for a real reason.
824
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
825
+ (context.diagnosticWriter ?? safeStderrWrite)(
826
+ formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
827
+ );
828
+ return true;
829
+ }
830
+ // Session is missing — retry.
831
+ const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
832
+ const retryProbe = probeHasSession();
833
+ if (retry.exitCode !== 0 || retryProbe.exitCode !== 0) {
834
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
835
+ (context.diagnosticWriter ?? safeStderrWrite)(
836
+ formatTmuxLaunchDiagnostic(
837
+ "new-session retry failed after ownership failure",
838
+ retry.stderr ?? retryProbe.stderr ?? ownershipFailure.stderr,
839
+ ),
840
+ );
841
+ return true;
842
+ }
843
+ const retryProfile = applyGjcTmuxProfile(buildProfileInputs());
844
+ const retryOwnershipFailure = retryProfile.failures.find(item => item.command.args.includes("@gjc-profile"));
845
+ if (retryOwnershipFailure) {
846
+ cleanupCreatedTmuxSession(plan, spawnSync, options);
847
+ (context.diagnosticWriter ?? safeStderrWrite)(
848
+ formatTmuxLaunchDiagnostic("profile tagging failed after retry", retryOwnershipFailure.stderr),
849
+ );
850
+ return true;
851
+ }
852
+ // Recovery succeeded via retry — fall through to attach-session below.
557
853
  }
854
+ resizeCreatedTmuxWindowToCallerTerminalSize(plan, spawnSync, controlOptions);
855
+ applyGjcTmuxRootTerminalTitleProfile({
856
+ tmuxCommand: plan.tmuxCommand,
857
+ target: plan.sessionName,
858
+ title: rootTerminalTitle,
859
+ spawnSync,
860
+ options,
861
+ });
558
862
  }
559
- if (created.exitCode !== 0) return false;
863
+ const probeWarning = detectCorruptedGjcWrapper();
864
+ if (created.exitCode !== 0) {
865
+ // The new-session spawn failed. Surface the captured stderr so the
866
+ // user sees the actual psmux rejection (e.g. "cannot create session:
867
+ // server is shutting down") instead of a silent exit. The wrapper
868
+ // probe gives the user a deterministic hint when the silent-exit
869
+ // symptom is actually caused by a corrupted gjc.cmd / gjc.bat on
870
+ // PATH (a 194MB PE-binary at the wrapper path produces cmd.exe
871
+ // hangs that look like a tmux/psmux failure from the user's seat).
872
+ const stderr = created.stderr;
873
+ const suffix = probeWarning ? ` Wrapper warning: ${probeWarning}` : "";
874
+ (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("new-session failed", stderr) + suffix);
875
+ return false;
876
+ }
877
+ // attach-session needs PTY inherit for the user-facing attach; keep it unchanged.
560
878
  const attached = spawnSync(
561
879
  plan.tmuxCommand,
562
880
  ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
563
- options,
881
+ attachOptions,
564
882
  );
565
883
  if (attached.exitCode === 0) return true;
566
884
  if (isTmuxAttachDisconnectError(attached)) {