@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.
- package/CHANGELOG.md +40 -0
- package/README.md +8 -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 +12 -3
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +19 -3
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
- package/dist/types/modes/components/hook-input.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -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 +41 -7
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/notifications/daemon-paths.d.ts +9 -0
- package/dist/types/notifications/html-format.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +5 -1
- package/dist/types/notifications/telegram-daemon-cli.d.ts +8 -2
- package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
- package/dist/types/notifications/telegram-daemon.d.ts +1 -10
- package/dist/types/notifications/telegram-reference.d.ts +4 -0
- package/dist/types/notifications/threaded-render.d.ts +1 -0
- package/dist/types/tools/ask.d.ts +26 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
- package/dist/types/utils/cmux-workspace.d.ts +23 -0
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +5 -0
- package/dist/types/web/search/render.d.ts +1 -0
- package/package.json +7 -7
- package/src/cli/notify-cli.ts +1 -1
- package/src/cli/web-search-cli.ts +3 -2
- 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 +14 -3
- 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 +40 -8
- package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
- package/src/gjc-runtime/launch-tmux.ts +366 -48
- package/src/gjc-runtime/team-runtime.ts +69 -4
- package/src/gjc-runtime/ultragoal-guard.ts +150 -24
- package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
- package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
- package/src/gjc-runtime/workflow-manifest.ts +0 -1
- package/src/hooks/skill-state.ts +33 -73
- package/src/internal-urls/docs-index.generated.ts +8 -7
- 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/hook-selector.ts +21 -3
- package/src/modes/components/model-selector.ts +84 -13
- package/src/modes/components/settings-selector.ts +83 -24
- package/src/modes/components/status-line/presets.ts +11 -0
- 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 +20 -2
- 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 +64 -13
- package/src/modes/types.ts +6 -1
- package/src/notifications/daemon-paths.ts +22 -0
- package/src/notifications/html-format.ts +75 -1
- package/src/notifications/index.ts +12 -1
- package/src/notifications/recent-activity.ts +32 -8
- package/src/notifications/telegram-daemon-cli.ts +13 -4
- package/src/notifications/telegram-daemon-control.ts +9 -0
- package/src/notifications/telegram-daemon.ts +69 -45
- package/src/notifications/telegram-reference.ts +36 -14
- package/src/notifications/threaded-render.ts +14 -6
- package/src/prompts/system/system-prompt.md +10 -1
- package/src/sdk.ts +13 -6
- package/src/session/agent-session.ts +10 -1
- package/src/tools/ask.ts +147 -17
- package/src/tools/eval.ts +2 -2
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/ultragoal-ask-guard.ts +39 -5
- package/src/utils/cmux-workspace.ts +106 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/search/index.ts +22 -10
- package/src/web/search/provider.ts +15 -0
- 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
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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 = `&
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
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 =
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
343
|
-
const fullTitle = `${
|
|
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(
|
|
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
|
-
|
|
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 ?? {
|
|
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: [
|
|
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
|
-
|
|
498
|
-
stdout: options.stdout,
|
|
499
|
-
stderr: options.stderr,
|
|
682
|
+
...stdio,
|
|
500
683
|
});
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
553
|
-
(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
881
|
+
attachOptions,
|
|
564
882
|
);
|
|
565
883
|
if (attached.exitCode === 0) return true;
|
|
566
884
|
if (isTmuxAttachDisconnectError(attached)) {
|