@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 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): 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",
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.4",
56
- "@gajae-code/agent-core": "0.7.4",
57
- "@gajae-code/ai": "0.7.4",
58
- "@gajae-code/natives": "0.7.4",
59
- "@gajae-code/tui": "0.7.4",
60
- "@gajae-code/utils": "0.7.4",
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
- let commands = buildGjcTmuxProfileCommands(context.target, env, {
236
- branch: context.branch ?? null,
237
- branchSlug,
238
- project: context.project ?? null,
239
- sessionId: context.sessionId ?? env[GJC_COORDINATOR_SESSION_ID_ENV] ?? null,
240
- sessionStateFile: context.sessionStateFile ?? env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] ?? null,
241
- version: context.version ?? null,
242
- });
243
- if (psmuxProfileCommandsShouldDropUx(env, context.tmuxCommand)) {
244
- // Keep the ownership-tag round-trip (required for `gjc session` and
245
- // `gjc team`); drop only the UX profile commands whose option keys
246
- // historically do not round-trip cleanly on psmux.
247
- const dropArgs = new Set(["mouse", "set-clipboard", "mode-style"]);
248
- commands = commands.filter(command => {
249
- const flag = command.args[0];
250
- const key = command.args[command.args.length - 2];
251
- return !(dropArgs.has(String(key)) && (flag === "set-option" || flag === "set-window-option"));
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(sessionName: string): string {
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
- return output
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
- for (const profileCommand of buildGjcTmuxProfileCommands(sessionName, env)) {
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
- return runTmux(
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
- return (
258
- runTmux(["show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), option], env).trim() ||
259
- undefined
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
- // Build tree
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
- roots.push(node);
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
- roots.push(node);
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
  }