@gajae-code/coding-agent 0.7.4 → 0.7.5
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 +10 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +10 -1
- package/package.json +7 -7
- package/src/gjc-runtime/launch-tmux.ts +28 -39
- package/src/gjc-runtime/tmux-common.ts +58 -1
- package/src/gjc-runtime/tmux-sessions.ts +60 -8
- package/src/modes/components/assistant-message.ts +49 -1
- package/src/session/session-manager.ts +61 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.5] - 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Guarded `parentId` session-tree walks against cycles to stop resume from exhausting memory (OOM) on self-referential or cyclic parent chains (#1193).
|
|
10
|
+
- Guarded `getTree` against child cycles so cyclic child references can no longer drive unbounded traversal (#1195).
|
|
11
|
+
- Elided runaway thinking-token loops in the assistant message renderer so repeated thinking output no longer grows without bound (#1196).
|
|
12
|
+
- Made `gjc session` create/list work on psmux-backed multiplexers (#1192).
|
|
13
|
+
- Sanitized dot-prefixed cwd window titles so tmux window names render correctly (#1198).
|
|
14
|
+
|
|
5
15
|
## [0.7.4] - 2026-06-27
|
|
6
16
|
|
|
7
17
|
### Added
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ResolvedTmuxBinary } from "./psmux-detect";
|
|
1
2
|
export declare const GJC_DEFAULT_TMUX_SESSION = "gajae_code";
|
|
2
3
|
export declare const GJC_TMUX_SESSION_PREFIX = "gajae_code_";
|
|
3
4
|
export declare const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
|
|
@@ -11,6 +12,7 @@ export declare const GJC_TMUX_PROJECT_OPTION = "@gjc-project";
|
|
|
11
12
|
export declare const GJC_TMUX_SESSION_ID_OPTION = "@gjc-session-id";
|
|
12
13
|
export declare const GJC_TMUX_SESSION_STATE_FILE_OPTION = "@gjc-session-state-file";
|
|
13
14
|
export declare const GJC_TMUX_VERSION_OPTION = "@gjc-version";
|
|
15
|
+
export declare const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE";
|
|
14
16
|
export interface GjcTmuxProfileCommand {
|
|
15
17
|
description: string;
|
|
16
18
|
args: string[];
|
|
@@ -53,7 +55,11 @@ export { clearPsmuxDetectionCache, detectPsmux, probePsmux, resolveGjcTmuxBinary
|
|
|
53
55
|
* keeps the exact-session match while giving tmux the window-qualified target
|
|
54
56
|
* those commands require. See gajae-code#580.
|
|
55
57
|
*/
|
|
56
|
-
export declare function buildGjcTmuxExactOptionTarget(sessionName: string
|
|
58
|
+
export declare function buildGjcTmuxExactOptionTarget(sessionName: string, opts?: {
|
|
59
|
+
env?: NodeJS.ProcessEnv;
|
|
60
|
+
platform?: NodeJS.Platform;
|
|
61
|
+
binary?: ResolvedTmuxBinary;
|
|
62
|
+
}): string;
|
|
57
63
|
export declare const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
|
|
58
64
|
export declare function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string;
|
|
59
65
|
export declare function buildGjcTmuxUntaggedSessionError(sessionName: string, tmuxCommand: string): string;
|
|
@@ -79,5 +85,8 @@ export declare function buildGjcTmuxProfileCommands(target: string, env?: NodeJS
|
|
|
79
85
|
sessionId?: string | null;
|
|
80
86
|
sessionStateFile?: string | null;
|
|
81
87
|
version?: string | null;
|
|
88
|
+
}, opts?: {
|
|
89
|
+
platform?: NodeJS.Platform;
|
|
90
|
+
tmuxCommand?: string;
|
|
82
91
|
}): GjcTmuxProfileCommand[];
|
|
83
92
|
export declare function normalizeTmuxCreatedAt(raw: string): string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/coding-agent",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.5",
|
|
5
5
|
"description": "Gajae Code CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -52,12 +52,12 @@
|
|
|
52
52
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
53
53
|
"@babel/parser": "^7.29.3",
|
|
54
54
|
"@mozilla/readability": "^0.6.0",
|
|
55
|
-
"@gajae-code/stats": "0.7.
|
|
56
|
-
"@gajae-code/agent-core": "0.7.
|
|
57
|
-
"@gajae-code/ai": "0.7.
|
|
58
|
-
"@gajae-code/natives": "0.7.
|
|
59
|
-
"@gajae-code/tui": "0.7.
|
|
60
|
-
"@gajae-code/utils": "0.7.
|
|
55
|
+
"@gajae-code/stats": "0.7.5",
|
|
56
|
+
"@gajae-code/agent-core": "0.7.5",
|
|
57
|
+
"@gajae-code/ai": "0.7.5",
|
|
58
|
+
"@gajae-code/natives": "0.7.5",
|
|
59
|
+
"@gajae-code/tui": "0.7.5",
|
|
60
|
+
"@gajae-code/utils": "0.7.5",
|
|
61
61
|
"@puppeteer/browsers": "^2.13.0",
|
|
62
62
|
"@types/turndown": "5.0.6",
|
|
63
63
|
"@xterm/headless": "^6.0.0",
|
|
@@ -34,25 +34,6 @@ export const GJC_LAUNCH_POLICY_ENV = "GJC_LAUNCH_POLICY";
|
|
|
34
34
|
export const GJC_TMUX_WINDOW_LABEL_MAX_WIDTH = 48;
|
|
35
35
|
export const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE";
|
|
36
36
|
|
|
37
|
-
function envFlagDisabled(value: string | undefined): boolean {
|
|
38
|
-
const normalized = value?.trim().toLowerCase();
|
|
39
|
-
return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Decide whether the mouse / clipboard / mode-style UX profile commands should
|
|
44
|
-
* be dropped for the active multiplexer. Psmux historically does not
|
|
45
|
-
* round-trip every user option perfectly; the ownership-tag round-trip is the
|
|
46
|
-
* only piece gjc session / gjc team actually need, so dropping the rest keeps
|
|
47
|
-
* native Windows `gjc --tmux` bootable when those UX options would otherwise
|
|
48
|
-
* hard-fail.
|
|
49
|
-
*/
|
|
50
|
-
function psmuxProfileCommandsShouldDropUx(env: NodeJS.ProcessEnv, tmuxCommand: string): boolean {
|
|
51
|
-
if (envFlagDisabled(env[GJC_PSMUX_PROFILE_FORCE_ENV])) return false;
|
|
52
|
-
const resolved = resolveGjcTmuxBinary({ env });
|
|
53
|
-
return resolved.command === tmuxCommand && resolved.isPsmux;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
37
|
type LaunchPolicy = "direct" | "tmux";
|
|
57
38
|
|
|
58
39
|
interface TtyState {
|
|
@@ -232,25 +213,26 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
|
|
|
232
213
|
export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProfileResult {
|
|
233
214
|
const env = context.env ?? process.env;
|
|
234
215
|
const branchSlug = context.branch ? buildGjcTmuxSessionSlug(context.branch) : (context.branchSlug ?? null);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
216
|
+
// The psmux UX filter (mouse / set-clipboard / mode-style /
|
|
217
|
+
// set-window-option) now lives in buildGjcTmuxProfileCommands so every
|
|
218
|
+
// caller — gjc --tmux planning, gjc session create, gjc team bootstrap —
|
|
219
|
+
// applies the same drop set when the active multiplexer is psmux. We pass
|
|
220
|
+
// the resolved tmuxCommand through the new opts seam so the filter
|
|
221
|
+
// engages for this exact command, not whatever the resolver returns at
|
|
222
|
+
// profile-build time.
|
|
223
|
+
const commands = buildGjcTmuxProfileCommands(
|
|
224
|
+
context.target,
|
|
225
|
+
env,
|
|
226
|
+
{
|
|
227
|
+
branch: context.branch ?? null,
|
|
228
|
+
branchSlug,
|
|
229
|
+
project: context.project ?? null,
|
|
230
|
+
sessionId: context.sessionId ?? env[GJC_COORDINATOR_SESSION_ID_ENV] ?? null,
|
|
231
|
+
sessionStateFile: context.sessionStateFile ?? env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] ?? null,
|
|
232
|
+
version: context.version ?? null,
|
|
233
|
+
},
|
|
234
|
+
{ tmuxCommand: context.tmuxCommand },
|
|
235
|
+
);
|
|
254
236
|
if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
|
|
255
237
|
const spawnSync = context.spawnSync ?? defaultSpawnSync;
|
|
256
238
|
const cwd = context.cwd ?? process.cwd();
|
|
@@ -324,8 +306,15 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
|
|
|
324
306
|
return `…${result}`;
|
|
325
307
|
}
|
|
326
308
|
|
|
309
|
+
function sanitizeTmuxWindowProjectName(project: string): string {
|
|
310
|
+
const trimmed = project.trim();
|
|
311
|
+
if (!trimmed || /^\.+$/.test(trimmed)) return "gjc";
|
|
312
|
+
if (trimmed.startsWith(".")) return `dot-${trimmed.replace(/^\.+/, "")}`;
|
|
313
|
+
return trimmed;
|
|
314
|
+
}
|
|
315
|
+
|
|
327
316
|
export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
|
|
328
|
-
const project = path.basename(path.resolve(cwd)) || "gjc";
|
|
317
|
+
const project = sanitizeTmuxWindowProjectName(path.basename(path.resolve(cwd)) || "gjc");
|
|
329
318
|
const trimmedBranch = branch?.trim();
|
|
330
319
|
if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
|
|
331
320
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ResolvedTmuxBinary } from "./psmux-detect";
|
|
1
2
|
import { resolveGjcTmuxBinary } from "./psmux-detect";
|
|
2
3
|
|
|
3
4
|
export const GJC_DEFAULT_TMUX_SESSION = "gajae_code";
|
|
@@ -13,6 +14,7 @@ export const GJC_TMUX_PROJECT_OPTION = "@gjc-project";
|
|
|
13
14
|
export const GJC_TMUX_SESSION_ID_OPTION = "@gjc-session-id";
|
|
14
15
|
export const GJC_TMUX_SESSION_STATE_FILE_OPTION = "@gjc-session-state-file";
|
|
15
16
|
export const GJC_TMUX_VERSION_OPTION = "@gjc-version";
|
|
17
|
+
export const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE";
|
|
16
18
|
|
|
17
19
|
export interface GjcTmuxProfileCommand {
|
|
18
20
|
description: string;
|
|
@@ -70,7 +72,17 @@ export { clearPsmuxDetectionCache, detectPsmux, probePsmux, resolveGjcTmuxBinary
|
|
|
70
72
|
* keeps the exact-session match while giving tmux the window-qualified target
|
|
71
73
|
* those commands require. See gajae-code#580.
|
|
72
74
|
*/
|
|
73
|
-
export function buildGjcTmuxExactOptionTarget(
|
|
75
|
+
export function buildGjcTmuxExactOptionTarget(
|
|
76
|
+
sessionName: string,
|
|
77
|
+
opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {},
|
|
78
|
+
): string {
|
|
79
|
+
const binary = opts.binary ?? resolveGjcTmuxBinary({ env: opts.env, platform: opts.platform });
|
|
80
|
+
// psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option
|
|
81
|
+
// commands ("no server running on session '=NAME'"); bare `NAME` and
|
|
82
|
+
// window-qualified `NAME:` both work. tmux 3.6a needs the
|
|
83
|
+
// window-qualified `=NAME:` to resolve the session for option
|
|
84
|
+
// commands (gajae-code#580).
|
|
85
|
+
if (binary.isPsmux) return sessionName;
|
|
74
86
|
return `=${sessionName}:`;
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -171,6 +183,16 @@ export function buildGjcTmuxRequiredProfileCommands(
|
|
|
171
183
|
return commands;
|
|
172
184
|
}
|
|
173
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Keys whose set-option / set-window-option round-trip is unreliable on psmux
|
|
188
|
+
* 3.3.0. psmux does not support the tmux `set-window-option` command at all
|
|
189
|
+
* (it reports "unknown command: set-window-option") and silently drops several
|
|
190
|
+
* `set-option` keys. The list lives here so every code path that tags a tmux
|
|
191
|
+
* session (gjc --tmux planning, gjc session create, gjc team bootstrap)
|
|
192
|
+
* applies the same filter.
|
|
193
|
+
*/
|
|
194
|
+
const PSMUX_UNSUPPORTED_PROFILE_KEYS = new Set(["mouse", "set-clipboard", "mode-style"]);
|
|
195
|
+
|
|
174
196
|
export function buildGjcTmuxProfileCommands(
|
|
175
197
|
target: string,
|
|
176
198
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -182,6 +204,7 @@ export function buildGjcTmuxProfileCommands(
|
|
|
182
204
|
sessionStateFile?: string | null;
|
|
183
205
|
version?: string | null;
|
|
184
206
|
} = {},
|
|
207
|
+
opts: { platform?: NodeJS.Platform; tmuxCommand?: string } = {},
|
|
185
208
|
): GjcTmuxProfileCommand[] {
|
|
186
209
|
const commands = buildGjcTmuxRequiredProfileCommands(target, metadata);
|
|
187
210
|
if (envDisabled(env[GJC_TMUX_PROFILE_ENV])) return commands;
|
|
@@ -197,6 +220,40 @@ export function buildGjcTmuxProfileCommands(
|
|
|
197
220
|
description: "enable tmux mouse scrolling",
|
|
198
221
|
args: ["set-option", "-t", target, "mouse", "on"],
|
|
199
222
|
});
|
|
223
|
+
// psmux does not implement set-window-option and historically drops
|
|
224
|
+
// mouse / set-clipboard / mode-style. Filter the UX profile commands
|
|
225
|
+
// centrally so every code path that tags a session (gjc --tmux planning,
|
|
226
|
+
// gjc session create, gjc team bootstrap) drops the same set. The
|
|
227
|
+
// GJC_PSMUX_PROFILE_FORCE override lets the operator opt back in when
|
|
228
|
+
// running on a psmux build that has caught up. The ownership-tag
|
|
229
|
+
// round-trip (set-option @gjc-*) is never filtered, since gjc session /
|
|
230
|
+
// gjc team rely on it.
|
|
231
|
+
// The filter is opt-in: callers that explicitly pass `opts.tmuxCommand`
|
|
232
|
+
// name a psmux-class multiplexer (psmux / pmux) when they want the UX
|
|
233
|
+
// profile filtered. Auto-detect on Windows hosts where psmux happens
|
|
234
|
+
// to be on PATH would silently change the test output for every caller
|
|
235
|
+
// that does not pin the multiplexer, so we require the caller to opt
|
|
236
|
+
// in by naming the multiplexer. GJC_PSMUX_PROFILE_FORCE re-enables
|
|
237
|
+
// the UX profile commands when a psmux build catches up.
|
|
238
|
+
const tmuxName = (opts.tmuxCommand ?? "").toLowerCase();
|
|
239
|
+
const isPsmuxClass =
|
|
240
|
+
tmuxName === "psmux" ||
|
|
241
|
+
tmuxName === "pmux" ||
|
|
242
|
+
tmuxName.endsWith("/psmux") ||
|
|
243
|
+
tmuxName.endsWith("/pmux") ||
|
|
244
|
+
tmuxName.endsWith("\\psmux") ||
|
|
245
|
+
tmuxName.endsWith("\\pmux");
|
|
246
|
+
const dropUx = isPsmuxClass && !envDisabled(env[GJC_PSMUX_PROFILE_FORCE_ENV]);
|
|
247
|
+
if (dropUx) {
|
|
248
|
+
return commands.filter(command => {
|
|
249
|
+
const flag = command.args[0];
|
|
250
|
+
const key = command.args[command.args.length - 2];
|
|
251
|
+
return !(
|
|
252
|
+
PSMUX_UNSUPPORTED_PROFILE_KEYS.has(String(key)) &&
|
|
253
|
+
(flag === "set-option" || flag === "set-window-option")
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
200
257
|
return commands;
|
|
201
258
|
}
|
|
202
259
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveGjcTmuxBinary } from "./psmux-detect";
|
|
1
2
|
import {
|
|
2
3
|
buildGjcTmuxExactOptionTarget,
|
|
3
4
|
buildGjcTmuxProfileCommands,
|
|
@@ -128,10 +129,29 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
|
|
|
128
129
|
}
|
|
129
130
|
throw error;
|
|
130
131
|
}
|
|
131
|
-
|
|
132
|
+
const lines = output
|
|
132
133
|
.split("\n")
|
|
133
134
|
.map(line => line.trim())
|
|
134
135
|
.filter(Boolean);
|
|
136
|
+
// psmux 3.3.0 silently ignores the tmux `-F` format flag and returns its
|
|
137
|
+
// default `name: N windows (created ...)` shape. Detect that case and
|
|
138
|
+
// synthesize a tab-separated row so downstream parseSessionLine /
|
|
139
|
+
// hydrateSessionFromExactOptions can recover the @gjc-* ownership tags
|
|
140
|
+
// via follow-up show-options calls. Without this fallback gjc session
|
|
141
|
+
// list / status return an empty list on psmux even when sessions exist.
|
|
142
|
+
if (lines.length > 0 && !lines[0].includes("\t")) {
|
|
143
|
+
const binary = resolveGjcTmuxBinary({ env });
|
|
144
|
+
if (binary.isPsmux) {
|
|
145
|
+
return lines.map(line => {
|
|
146
|
+
const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
|
|
147
|
+
if (!match) return line;
|
|
148
|
+
const [, name, windows, created] = match;
|
|
149
|
+
const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
|
|
150
|
+
return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", ""].join("\t");
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return lines;
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
@@ -235,7 +255,14 @@ export function createGjcTmuxSession(env: NodeJS.ProcessEnv = process.env): GjcT
|
|
|
235
255
|
});
|
|
236
256
|
if (created.exitCode !== 0) throw new Error(created.stderr.toString().trim() || "gjc_tmux_session_create_failed");
|
|
237
257
|
try {
|
|
238
|
-
|
|
258
|
+
// psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option
|
|
259
|
+
// commands, so the target is the bare session name on psmux and the
|
|
260
|
+
// window-qualified `=NAME:` on tmux. The ownership-tag round-trip
|
|
261
|
+
// (set-option @gjc-*) is preserved on both; only the UX profile commands
|
|
262
|
+
// (mouse / set-clipboard / mode-style / set-window-option) get filtered
|
|
263
|
+
// by buildGjcTmuxProfileCommands when the active binary is psmux.
|
|
264
|
+
const target = buildGjcTmuxExactOptionTarget(sessionName, { env });
|
|
265
|
+
for (const profileCommand of buildGjcTmuxProfileCommands(target, env, {}, { tmuxCommand })) {
|
|
239
266
|
runTmux(profileCommand.args, env);
|
|
240
267
|
}
|
|
241
268
|
} catch (error) {
|
|
@@ -246,18 +273,43 @@ export function createGjcTmuxSession(env: NodeJS.ProcessEnv = process.env): GjcT
|
|
|
246
273
|
}
|
|
247
274
|
|
|
248
275
|
function readProfileForExactTarget(sessionName: string, env: NodeJS.ProcessEnv): string {
|
|
249
|
-
|
|
250
|
-
["show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
|
|
276
|
+
const raw = runTmux(
|
|
277
|
+
["show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName, { env }), GJC_TMUX_PROFILE_OPTION],
|
|
251
278
|
env,
|
|
252
279
|
).trim();
|
|
280
|
+
// tmux returns just the value; psmux returns `key value`. Strip the
|
|
281
|
+
// leading key on psmux so the GJC_TMUX_PROFILE_VALUE equality check
|
|
282
|
+
// against "1" works the same on both.
|
|
283
|
+
if (raw && resolveGjcTmuxBinary({ env }).isPsmux) {
|
|
284
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
285
|
+
return tokens[tokens.length - 1] ?? raw;
|
|
286
|
+
}
|
|
287
|
+
return raw;
|
|
253
288
|
}
|
|
254
289
|
|
|
255
290
|
function readExactOptionForGc(sessionName: string, option: string, env: NodeJS.ProcessEnv): string | undefined {
|
|
256
291
|
try {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
);
|
|
292
|
+
const raw = runTmux(
|
|
293
|
+
["show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName, { env }), option],
|
|
294
|
+
env,
|
|
295
|
+
).trim();
|
|
296
|
+
if (!raw) return undefined;
|
|
297
|
+
// tmux returns just the option value (e.g. `1` for @gjc-profile).
|
|
298
|
+
// psmux 3.3.0 returns `key value` (or `key "value with space"` for
|
|
299
|
+
// @gjc-branch etc.). On psmux, parse the last token and strip any
|
|
300
|
+
// surrounding double quotes so both shapes resolve to the same value.
|
|
301
|
+
if (resolveGjcTmuxBinary({ env }).isPsmux) {
|
|
302
|
+
// Prefer the last whitespace-separated token. If the value is
|
|
303
|
+
// quoted, find the matching close-quote and slice.
|
|
304
|
+
const lastQuote = raw.lastIndexOf('"');
|
|
305
|
+
if (lastQuote > 0 && raw[lastQuote - 1] !== "\\") {
|
|
306
|
+
const firstQuote = raw.lastIndexOf('"', lastQuote - 1);
|
|
307
|
+
if (firstQuote > 0) return raw.slice(firstQuote + 1, lastQuote);
|
|
308
|
+
}
|
|
309
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
310
|
+
return tokens[tokens.length - 1];
|
|
311
|
+
}
|
|
312
|
+
return raw;
|
|
261
313
|
} catch {
|
|
262
314
|
return undefined;
|
|
263
315
|
}
|
|
@@ -7,6 +7,54 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
|
7
7
|
import { isSilentAbort } from "../../session/messages";
|
|
8
8
|
import { resolveImageOptions } from "../../tools/render-utils";
|
|
9
9
|
|
|
10
|
+
const THINKING_REPETITION_ELIDE_MIN_RUN = 24;
|
|
11
|
+
const THINKING_REPETITION_VISIBLE_TOKENS = 3;
|
|
12
|
+
const THINKING_REPETITION_TOKEN_PATTERN = /[\p{L}\p{N}_'-]{1,32}/gu;
|
|
13
|
+
|
|
14
|
+
interface ThinkingRepetitionToken {
|
|
15
|
+
text: string;
|
|
16
|
+
normalized: string;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function elideRunawayThinkingRepetition(text: string): string {
|
|
22
|
+
const tokens: ThinkingRepetitionToken[] = [];
|
|
23
|
+
for (const match of text.matchAll(THINKING_REPETITION_TOKEN_PATTERN)) {
|
|
24
|
+
if (match.index === undefined) continue;
|
|
25
|
+
tokens.push({
|
|
26
|
+
text: match[0],
|
|
27
|
+
normalized: match[0].toLocaleLowerCase("en-US"),
|
|
28
|
+
start: match.index,
|
|
29
|
+
end: match.index + match[0].length,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let runStart = 0;
|
|
34
|
+
for (let i = 1; i <= tokens.length; i++) {
|
|
35
|
+
const current = tokens[i];
|
|
36
|
+
const previous = tokens[i - 1];
|
|
37
|
+
if (current && previous && current.normalized === previous.normalized) continue;
|
|
38
|
+
|
|
39
|
+
const runLength = i - runStart;
|
|
40
|
+
if (runLength >= THINKING_REPETITION_ELIDE_MIN_RUN) {
|
|
41
|
+
const first = tokens[runStart];
|
|
42
|
+
const last = tokens[i - 1];
|
|
43
|
+
if (!first || !last) return text;
|
|
44
|
+
|
|
45
|
+
const visibleCount = Math.min(THINKING_REPETITION_VISIBLE_TOKENS, runLength);
|
|
46
|
+
const visible = Array.from({ length: visibleCount }, () => first.text).join(" ");
|
|
47
|
+
const omitted = runLength - visibleCount;
|
|
48
|
+
const marker = `${visible} … [thinking loop elided: "${first.text}" repeated ${omitted} more times]`;
|
|
49
|
+
return `${text.slice(0, first.start)}${marker}${text.slice(last.end)}`.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
runStart = i;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
|
|
10
58
|
/**
|
|
11
59
|
* Component that renders a complete assistant message
|
|
12
60
|
*/
|
|
@@ -128,7 +176,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
128
176
|
#renderThinkingBlock(content: { thinking: string }): Markdown {
|
|
129
177
|
const cached = this.#contentBlocksCache.get(content);
|
|
130
178
|
if (cached?.source === content.thinking) return cached.component as Markdown;
|
|
131
|
-
const trimmed = content.thinking.trim();
|
|
179
|
+
const trimmed = elideRunawayThinkingRepetition(content.thinking.trim());
|
|
132
180
|
const component = new Markdown(trimmed, 1, 0, getMarkdownTheme(), {
|
|
133
181
|
color: (text: string) => theme.fg("thinkingText", text),
|
|
134
182
|
italic: true,
|
|
@@ -640,8 +640,11 @@ export function buildSessionContext(
|
|
|
640
640
|
|
|
641
641
|
// Walk from leaf to root, then reverse once to avoid repeated front insertions on long branches.
|
|
642
642
|
const path: SessionEntry[] = [];
|
|
643
|
+
const visited = new Set<string>();
|
|
643
644
|
let current: SessionEntry | undefined = leaf;
|
|
644
645
|
while (current) {
|
|
646
|
+
if (visited.has(current.id)) break;
|
|
647
|
+
visited.add(current.id);
|
|
645
648
|
path.push(current);
|
|
646
649
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
647
650
|
}
|
|
@@ -3988,8 +3991,11 @@ export class SessionManager {
|
|
|
3988
3991
|
* Returns undefined if no model change has been recorded.
|
|
3989
3992
|
*/
|
|
3990
3993
|
getLastModelChangeRole(): string | undefined {
|
|
3994
|
+
const visited = new Set<string>();
|
|
3991
3995
|
let current = this.getLeafEntry();
|
|
3992
3996
|
while (current) {
|
|
3997
|
+
if (visited.has(current.id)) break;
|
|
3998
|
+
visited.add(current.id);
|
|
3993
3999
|
if (current.type === "model_change") {
|
|
3994
4000
|
return current.role ?? "default";
|
|
3995
4001
|
}
|
|
@@ -4005,8 +4011,11 @@ export class SessionManager {
|
|
|
4005
4011
|
if (!compaction || compaction.type !== "compaction")
|
|
4006
4012
|
throw new Error(`Compaction entry ${compactionEntryId} not found`);
|
|
4007
4013
|
const ids: string[] = [];
|
|
4014
|
+
const visited = new Set<string>();
|
|
4008
4015
|
let current: SessionEntry | undefined = compaction;
|
|
4009
4016
|
while (current) {
|
|
4017
|
+
if (visited.has(current.id)) break;
|
|
4018
|
+
visited.add(current.id);
|
|
4010
4019
|
ids.push(current.id);
|
|
4011
4020
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4012
4021
|
}
|
|
@@ -4155,8 +4164,11 @@ export class SessionManager {
|
|
|
4155
4164
|
getBranchForFidelity(fromId?: string): SessionEntry[] {
|
|
4156
4165
|
const cache = new Map<string, string>();
|
|
4157
4166
|
const path: SessionEntry[] = [];
|
|
4167
|
+
const visited = new Set<string>();
|
|
4158
4168
|
let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
|
|
4159
4169
|
while (current) {
|
|
4170
|
+
if (visited.has(current.id)) break;
|
|
4171
|
+
visited.add(current.id);
|
|
4160
4172
|
path.push(
|
|
4161
4173
|
rehydrateColdSpillEntry(
|
|
4162
4174
|
materializeResidentEntrySync(current, this.#residentBlobStores(), cache),
|
|
@@ -4172,8 +4184,11 @@ export class SessionManager {
|
|
|
4172
4184
|
|
|
4173
4185
|
#getCanonicalBranchClones(fromId?: string): SessionEntry[] {
|
|
4174
4186
|
const path: SessionEntry[] = [];
|
|
4187
|
+
const visited = new Set<string>();
|
|
4175
4188
|
let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
|
|
4176
4189
|
while (current) {
|
|
4190
|
+
if (visited.has(current.id)) break;
|
|
4191
|
+
visited.add(current.id);
|
|
4177
4192
|
path.push(cloneSessionEntry(current));
|
|
4178
4193
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4179
4194
|
}
|
|
@@ -4267,9 +4282,12 @@ export class SessionManager {
|
|
|
4267
4282
|
this.#getBranchMaterializerCallCount++;
|
|
4268
4283
|
const cache = new Map<string, string>();
|
|
4269
4284
|
const path: SessionEntry[] = [];
|
|
4285
|
+
const visited = new Set<string>();
|
|
4270
4286
|
const startId = fromId ?? this.#leafId;
|
|
4271
4287
|
let current = startId ? this.#byId.get(startId) : undefined;
|
|
4272
4288
|
while (current) {
|
|
4289
|
+
if (visited.has(current.id)) break;
|
|
4290
|
+
visited.add(current.id);
|
|
4273
4291
|
path.push(materializeResidentEntrySync(current, this.#residentBlobStores(), cache));
|
|
4274
4292
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4275
4293
|
}
|
|
@@ -4303,8 +4321,11 @@ export class SessionManager {
|
|
|
4303
4321
|
#getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
|
|
4304
4322
|
if (fromId === null || (fromId === undefined && this.#leafId === null)) return [];
|
|
4305
4323
|
const ids: string[] = [];
|
|
4324
|
+
const visited = new Set<string>();
|
|
4306
4325
|
let current = this.#byId.get(fromId ?? this.#leafId ?? "");
|
|
4307
4326
|
while (current) {
|
|
4327
|
+
if (visited.has(current.id)) break;
|
|
4328
|
+
visited.add(current.id);
|
|
4308
4329
|
ids.push(current.id);
|
|
4309
4330
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4310
4331
|
}
|
|
@@ -4418,18 +4439,48 @@ export class SessionManager {
|
|
|
4418
4439
|
nodeMap.set(entry.id, { entry, children: [], label });
|
|
4419
4440
|
}
|
|
4420
4441
|
|
|
4421
|
-
|
|
4442
|
+
const addRoot = (node: SessionTreeNode): void => {
|
|
4443
|
+
if (!roots.includes(node)) {
|
|
4444
|
+
roots.push(node);
|
|
4445
|
+
}
|
|
4446
|
+
};
|
|
4447
|
+
const removeRoot = (node: SessionTreeNode): void => {
|
|
4448
|
+
const index = roots.indexOf(node);
|
|
4449
|
+
if (index !== -1) {
|
|
4450
|
+
roots.splice(index, 1);
|
|
4451
|
+
}
|
|
4452
|
+
};
|
|
4453
|
+
const wouldCreateChildCycle = (parent: SessionTreeNode, child: SessionTreeNode): boolean => {
|
|
4454
|
+
const stack: SessionTreeNode[] = [child];
|
|
4455
|
+
const visited = new Set<SessionTreeNode>();
|
|
4456
|
+
while (stack.length > 0) {
|
|
4457
|
+
const current = stack.pop()!;
|
|
4458
|
+
if (current === parent) {
|
|
4459
|
+
return true;
|
|
4460
|
+
}
|
|
4461
|
+
if (visited.has(current)) {
|
|
4462
|
+
continue;
|
|
4463
|
+
}
|
|
4464
|
+
visited.add(current);
|
|
4465
|
+
stack.push(...current.children);
|
|
4466
|
+
}
|
|
4467
|
+
return false;
|
|
4468
|
+
};
|
|
4469
|
+
|
|
4470
|
+
// Build tree. Corrupt session files can contain duplicate IDs or parentId
|
|
4471
|
+
// cycles; reject only the edge that would make the returned tree cyclic.
|
|
4422
4472
|
for (const entry of entries) {
|
|
4423
4473
|
const node = nodeMap.get(entry.id)!;
|
|
4424
4474
|
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
4425
|
-
|
|
4475
|
+
addRoot(node);
|
|
4426
4476
|
} else {
|
|
4427
4477
|
const parent = nodeMap.get(entry.parentId);
|
|
4428
|
-
if (parent) {
|
|
4478
|
+
if (parent && !wouldCreateChildCycle(parent, node)) {
|
|
4429
4479
|
parent.children.push(node);
|
|
4480
|
+
removeRoot(node);
|
|
4430
4481
|
} else {
|
|
4431
|
-
// Orphan - treat as root
|
|
4432
|
-
|
|
4482
|
+
// Orphan or cycle-closing edge - treat as root
|
|
4483
|
+
addRoot(node);
|
|
4433
4484
|
}
|
|
4434
4485
|
}
|
|
4435
4486
|
}
|
|
@@ -4437,8 +4488,13 @@ export class SessionManager {
|
|
|
4437
4488
|
// Sort children by timestamp (oldest first, newest at bottom)
|
|
4438
4489
|
// Use iterative approach to avoid stack overflow on deep trees
|
|
4439
4490
|
const stack: SessionTreeNode[] = [...roots];
|
|
4491
|
+
const sorted = new Set<SessionTreeNode>();
|
|
4440
4492
|
while (stack.length > 0) {
|
|
4441
4493
|
const node = stack.pop()!;
|
|
4494
|
+
if (sorted.has(node)) {
|
|
4495
|
+
continue;
|
|
4496
|
+
}
|
|
4497
|
+
sorted.add(node);
|
|
4442
4498
|
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
4443
4499
|
stack.push(...node.children);
|
|
4444
4500
|
}
|