@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.
- package/CHANGELOG.md +18 -0
- package/README.md +6 -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 +4 -0
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/gjc-runtime/launch-tmux.d.ts +12 -3
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
- package/dist/types/modes/components/hook-input.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line.d.ts +3 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/notifications/daemon-paths.d.ts +9 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +8 -2
- 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/notify-cli.ts +1 -1
- package/src/cli.ts +1 -0
- 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 +81 -5
- package/src/config/settings-schema.ts +3 -2
- package/src/coordinator-mcp/server.ts +4 -2
- 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 +303 -46
- package/src/gjc-runtime/team-runtime.ts +69 -4
- package/src/gjc-runtime/ultragoal-guard.ts +11 -5
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/modes/components/custom-model-preset-wizard.ts +2 -4
- 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 +84 -13
- package/src/modes/components/settings-selector.ts +36 -21
- package/src/modes/components/status-line/segments.ts +10 -2
- package/src/modes/components/status-line.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/selector-controller.ts +130 -7
- package/src/modes/interactive-mode.ts +7 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
- package/src/modes/types.ts +6 -1
- package/src/notifications/daemon-paths.ts +22 -0
- package/src/notifications/telegram-daemon-cli.ts +13 -4
- package/src/notifications/telegram-daemon.ts +2 -21
- package/src/prompts/system/system-prompt.md +5 -0
- package/src/sdk.ts +10 -1
- package/src/session/agent-session.ts +10 -1
- 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,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
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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 = `&
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
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 =
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
343
|
-
const fullTitle = `${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
stdout: options.stdout,
|
|
499
|
-
stderr: options.stderr,
|
|
622
|
+
...stdio,
|
|
500
623
|
});
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
553
|
-
(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1955
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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)}`,
|