@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.
- package/CHANGELOG.md +38 -1
- package/README.md +14 -0
- package/dist/types/cli/local-provider-smoke.d.ts +59 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/update-cli.d.ts +24 -1
- package/dist/types/commands/local-provider.d.ts +34 -0
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/model-profile-activation.d.ts +19 -2
- package/dist/types/config/model-profiles.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +9 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +45 -0
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +8 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +14 -5
- package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
- package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
- package/dist/types/modes/components/hook-input.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +11 -0
- package/dist/types/modes/components/settings-selector.d.ts +4 -1
- package/dist/types/modes/components/status-line/types.d.ts +5 -7
- package/dist/types/modes/components/status-line.d.ts +7 -3
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
- package/dist/types/modes/tmux-scroll.d.ts +18 -0
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
- package/dist/types/notifications/daemon-paths.d.ts +9 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
- package/dist/types/notifications/telegram-daemon.d.ts +1 -10
- package/dist/types/tools/ask.d.ts +24 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
- package/package.json +7 -7
- package/src/cli/local-provider-smoke.ts +632 -0
- package/src/cli/notify-cli.ts +5 -3
- package/src/cli/update-cli.ts +155 -13
- package/src/cli.ts +19 -0
- package/src/commands/launch.ts +46 -1
- package/src/commands/local-provider.ts +62 -0
- package/src/commands/notify.ts +1 -1
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profile-activation.ts +92 -11
- package/src/config/model-profiles.ts +15 -2
- package/src/config/model-registry.ts +136 -6
- package/src/config/models-config-schema.ts +9 -0
- package/src/config/settings-schema.ts +48 -0
- package/src/config/settings.ts +11 -0
- package/src/coordinator-mcp/server.ts +280 -36
- package/src/deep-interview/render-middleware.ts +1 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -6
- package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
- package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
- package/src/gjc-runtime/launch-tmux.ts +321 -38
- package/src/gjc-runtime/launch-worktree.ts +16 -2
- package/src/gjc-runtime/team-runtime.ts +87 -7
- package/src/gjc-runtime/tmux-common.ts +16 -0
- package/src/gjc-runtime/tmux-sessions.ts +13 -9
- package/src/gjc-runtime/ultragoal-guard.ts +11 -5
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/modes/components/agent-dashboard.ts +2 -1
- package/src/modes/components/custom-model-preset-wizard.ts +31 -192
- package/src/modes/components/hook-editor.ts +7 -4
- package/src/modes/components/hook-input.ts +4 -0
- package/src/modes/components/model-selector.ts +260 -12
- package/src/modes/components/session-observer-overlay.ts +3 -3
- package/src/modes/components/settings-selector.ts +506 -5
- package/src/modes/components/status-line/segments.ts +16 -19
- package/src/modes/components/status-line/types.ts +6 -2
- package/src/modes/components/status-line.ts +70 -35
- package/src/modes/controllers/event-controller.ts +98 -4
- package/src/modes/controllers/extension-ui-controller.ts +5 -0
- package/src/modes/controllers/input-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +142 -11
- package/src/modes/interactive-mode.ts +7 -2
- package/src/modes/prompt-action-autocomplete.ts +8 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
- package/src/modes/tmux-scroll.ts +92 -0
- package/src/modes/types.ts +6 -1
- package/src/modes/utils/terminal-bell.ts +48 -0
- package/src/notifications/daemon-paths.ts +22 -0
- package/src/notifications/telegram-daemon-cli.ts +125 -13
- package/src/notifications/telegram-daemon.ts +6 -21
- package/src/prompts/system/system-prompt.md +5 -0
- package/src/sdk.ts +10 -1
- package/src/session/agent-session.ts +84 -6
- package/src/slash-commands/builtin-registry.ts +106 -13
- package/src/tools/ask.ts +11 -1
- package/src/tools/eval.ts +2 -2
- package/src/tools/render-utils.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
329
|
-
const fullTitle = `${
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
480
|
-
stdout: options.stdout,
|
|
481
|
-
stderr: options.stderr,
|
|
622
|
+
...stdio,
|
|
482
623
|
});
|
|
483
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
plan.
|
|
510
|
-
|
|
686
|
+
if (plan.attachSessionName) {
|
|
687
|
+
applyGjcTmuxRootTerminalTitleProfile({
|
|
688
|
+
tmuxCommand: plan.tmuxCommand,
|
|
689
|
+
target: plan.attachSessionName,
|
|
690
|
+
title: rootTerminalTitle,
|
|
511
691
|
spawnSync,
|
|
512
692
|
options,
|
|
513
|
-
|
|
693
|
+
});
|
|
694
|
+
const attached = spawnSync(
|
|
695
|
+
plan.tmuxCommand,
|
|
696
|
+
["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
|
|
697
|
+
attachOptions,
|
|
514
698
|
);
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
531
|
-
(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
1903
|
-
if (entrypoint
|
|
1904
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
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",
|