@gajae-code/coding-agent 0.7.4 → 0.7.6

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.6] - 2026-06-28
6
+
7
+ ### Fixed
8
+
9
+ - Kept Codex `reasoning.encrypted_content` replay strings out of the resident large-text externalizer so resumed sessions preserve the provider's opaque encrypted reasoning payload instead of degrading it to a resident blob object.
10
+ ## [0.7.5] - 2026-06-27
11
+
12
+ ### Fixed
13
+
14
+ - Guarded `parentId` session-tree walks against cycles to stop resume from exhausting memory (OOM) on self-referential or cyclic parent chains (#1193).
15
+ - Guarded `getTree` against child cycles so cyclic child references can no longer drive unbounded traversal (#1195).
16
+ - Elided runaway thinking-token loops in the assistant message renderer so repeated thinking output no longer grows without bound (#1196).
17
+ - Made `gjc session` create/list work on psmux-backed multiplexers (#1192).
18
+ - Sanitized dot-prefixed cwd window titles so tmux window names render correctly (#1198).
19
+
5
20
  ## [0.7.4] - 2026-06-27
6
21
 
7
22
  ### 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;
@@ -1,16 +1,3 @@
1
- /**
2
- * Paired-chat /session_* command grammar (G009).
3
- *
4
- * Pure parser + shared target validator for the Telegram session-lifecycle
5
- * commands. The daemon parses an inbound paired-chat message here, then attaches
6
- * transport identity (chatId/updateId/token/requestId) and routes the resulting
7
- * frame to the orchestrator. Keeping this pure makes the grammar, the MVP
8
- * prompt-rejection, and target validation unit-testable without the daemon.
9
- *
10
- * MVP scope: an initial prompt (`-- <prompt>`) is REJECTED with usage text — no
11
- * prompt text ever enters a frame, audit, log, or response until daemon-owned
12
- * 0600 prompt refs are designed.
13
- */
14
1
  import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
15
2
  export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
16
3
  /** A parsed, validated lifecycle command (transport identity added by caller). */
@@ -62,6 +49,7 @@ export declare function validateLifecycleTarget(verb: LifecycleCommandVerb, targ
62
49
  reason: "invalid_target";
63
50
  message: string;
64
51
  };
52
+ export declare function normalizeLifecyclePath(value: string): string | undefined;
65
53
  /**
66
54
  * Map a lifecycle response/error to a user-facing Telegram message (G010).
67
55
  *
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.6",
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.6",
56
+ "@gajae-code/agent-core": "0.7.6",
57
+ "@gajae-code/ai": "0.7.6",
58
+ "@gajae-code/natives": "0.7.6",
59
+ "@gajae-code/tui": "0.7.6",
60
+ "@gajae-code/utils": "0.7.6",
61
61
  "@puppeteer/browsers": "^2.13.0",
62
62
  "@types/turndown": "5.0.6",
63
63
  "@xterm/headless": "^6.0.0",
@@ -1,7 +1,35 @@
1
1
  import { APP_NAME, CONFIG_DIR_NAME } from "@gajae-code/utils/dirs";
2
2
 
3
3
  export function getExtraHelpText(): string {
4
- return `Environment Variables:
4
+ return `Commands:
5
+ ${APP_NAME} [prompt] - Start an interactive coding session (default launch command)
6
+ ${APP_NAME} launch - Start an explicit launch/session workflow
7
+ ${APP_NAME} setup - Install GJC defaults or optional dependencies
8
+ ${APP_NAME} session - List, inspect, create, remove, or attach sessions
9
+ ${APP_NAME} state - Inspect or manage persisted GJC state
10
+ ${APP_NAME} harness - Run harness control-plane commands
11
+ ${APP_NAME} coordinator - Manage coordinator/runtime coordination helpers
12
+ ${APP_NAME} team - Run tmux-backed coordinated execution
13
+ ${APP_NAME} ultragoal - Run durable goal execution workflow
14
+ ${APP_NAME} ralplan - Run consensus planning workflow
15
+ ${APP_NAME} deep-interview - Run requirements interview workflow
16
+ ${APP_NAME} skills - List/read embedded workflow skills
17
+ ${APP_NAME} config - List, get, and set configuration values
18
+ ${APP_NAME} notify - Send or test notifications
19
+ ${APP_NAME} daemon - Manage background daemon helpers
20
+ ${APP_NAME} mcp - Manage MCP server registrations
21
+ ${APP_NAME} mcp-serve - Serve the MCP integration endpoint
22
+ ${APP_NAME} contribute-pr - Prepare contribution/PR workflow artifacts
23
+ ${APP_NAME} migrate - Run migration helpers
24
+ ${APP_NAME} rlm - Run RLM helpers
25
+ ${APP_NAME} update - Update GJC installation artifacts
26
+ ${APP_NAME} plugin - Install, remove, and list plugins
27
+ ${APP_NAME} web-search - Search the web from the CLI (alias: q)
28
+ ${APP_NAME} codex-native-hook - Run Codex native hook integration
29
+ ${APP_NAME} gc - Run garbage-collection/cleanup helpers
30
+ ${APP_NAME} <command> --help - Show command-specific help
31
+
32
+ Environment Variables:
5
33
  # Core Providers
6
34
  ANTHROPIC_API_KEY - Anthropic Claude models
7
35
  ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth (takes precedence over API key)
@@ -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,20 +306,33 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
324
306
  return `…${result}`;
325
307
  }
326
308
 
309
+ const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
310
+
311
+ function sanitizeTmuxWindowTitleSegment(value: string): string {
312
+ return value.replace(/:+/g, "-");
313
+ }
314
+
315
+ function sanitizeTmuxWindowProjectName(project: string): string {
316
+ const trimmed = project.trim();
317
+ if (!trimmed || /^\.+$/.test(trimmed)) return "gjc";
318
+ if (trimmed.startsWith(".")) return sanitizeTmuxWindowTitleSegment(`dot-${trimmed.replace(/^\.+/, "")}`);
319
+ return sanitizeTmuxWindowTitleSegment(trimmed);
320
+ }
321
+
327
322
  export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
328
- const project = path.basename(path.resolve(cwd)) || "gjc";
329
- const trimmedBranch = branch?.trim();
323
+ const project = sanitizeTmuxWindowProjectName(path.basename(path.resolve(cwd)) || "gjc");
324
+ const trimmedBranch = sanitizeTmuxWindowTitleSegment(branch?.trim() ?? "");
330
325
  if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
331
326
 
332
- const separatorWidth = visibleWidth(":");
327
+ const separatorWidth = visibleWidth(GJC_TMUX_WINDOW_BRANCH_SEPARATOR);
333
328
  const projectWidth = visibleWidth(project);
334
- const fullTitle = `${project}:${trimmedBranch}`;
329
+ const fullTitle = `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
335
330
  if (visibleWidth(fullTitle) <= GJC_TMUX_WINDOW_LABEL_MAX_WIDTH) return fullTitle;
336
331
 
337
332
  const remainingBranchWidth = GJC_TMUX_WINDOW_LABEL_MAX_WIDTH - projectWidth - separatorWidth;
338
333
  if (remainingBranchWidth <= 0) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
339
334
 
340
- return `${project}:${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
335
+ return `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
341
336
  }
342
337
 
343
338
  function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
@@ -361,8 +356,8 @@ function renameExistingTmuxWindowIfNeeded(context: TmuxLaunchContext): void {
361
356
 
362
357
  // Note: Windows is intentionally allowed here. Psmux supports
363
358
  // `rename-window` and we want the leader window to inherit the
364
- // project:branch title even on native Windows, where gjc --tmux runs
365
- // through PowerShell to a psmux backend.
359
+ // sanitized project-branch title even on native Windows, where
360
+ // gjc --tmux runs through PowerShell to a psmux backend.
366
361
 
367
362
  const tty = context.tty ?? { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY) };
368
363
  if (!isInteractiveRootLaunch(context.parsed, tty)) return;
@@ -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
  }
@@ -45,7 +45,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
45
45
  "natives-text-search-pipeline.md": "# Natives Text/Search Pipeline\n\nThis document maps the `@gajae-code/natives` text/search/code surface from generated JS/TS exports to Rust N-API modules and back to JS result objects.\n\nTerminology follows `docs/natives-architecture.md`:\n\n- **Generated binding**: public API in `packages/natives/native/index.d.ts`.\n- **Rust module layer**: N-API exports in `crates/pi-natives/src/*`.\n- **Shared scan cache**: `fs_cache`-backed directory-entry cache used by discovery/search flows.\n\n## Implementation files\n\n- `packages/natives/native/index.d.ts`\n- `crates/pi-natives/src/grep.rs`\n- `crates/pi-natives/src/glob.rs`\n- `crates/pi-natives/src/glob_util.rs`\n- `crates/pi-natives/src/fs_cache.rs`\n- `crates/pi-natives/src/fd.rs`\n- `crates/pi-natives/src/ast.rs`\n- `crates/pi-natives/src/text.rs`\n- `crates/pi-natives/src/highlight.rs`\n- `crates/pi-natives/src/tokens.rs`\n\n## JS API ↔ Rust export mapping\n\n| JS API | Rust export (`#[napi]`, snake_case -> camelCase) | Rust module |\n| ------------------------------------------------------------------------------- | ------------------------------------------------ | -------------- |\n| `grep(options, onMatch?)` | `grep` | `grep.rs` |\n| `search(content, options)` | `search` | `grep.rs` |\n| `hasMatch(content, pattern, ignoreCase?, multiline?)` | `hasMatch` | `grep.rs` |\n| `fuzzyFind(options)` | `fuzzyFind` | `fd.rs` |\n| `glob(options, onMatch?)` | `glob` | `glob.rs` |\n| `invalidateFsScanCache(path?)` | `invalidateFsScanCache` | `fs_cache.rs` |\n| `astGrep(options)` | `astGrep` | `ast.rs` |\n| `astEdit(options)` | `astEdit` | `ast.rs` |\n| `wrapTextWithAnsi(text, width, tabWidth)` | `wrapTextWithAnsi` | `text.rs` |\n| `truncateToWidth(text, maxWidth, ellipsis, pad, tabWidth)` | `truncateToWidth` | `text.rs` |\n| `sliceWithWidth(line, startCol, length, strict, tabWidth)` | `sliceWithWidth` | `text.rs` |\n| `extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, tabWidth)` | `extractSegments` | `text.rs` |\n| `visibleWidth(text, tabWidth)` | `visibleWidth` | `text.rs` |\n| `highlightCode(code, lang, colors)` | `highlightCode` | `highlight.rs` |\n| `supportsLanguage(lang)` | `supportsLanguage` | `highlight.rs` |\n| `getSupportedLanguages()` | `getSupportedLanguages` | `highlight.rs` |\n| `countTokens(input, encoding?)` | `countTokens` | `tokens.rs` |\n\n## Pipeline overview by subsystem\n\n## 1) Regex search (`grep`, `search`, `hasMatch`)\n\n### Input/options flow\n\n1. Callers invoke generated native exports directly; there is no package-local TS wrapper that renames `search` to `searchContent`.\n2. Rust option structs in `grep.rs` deserialize camelCase fields (`ignoreCase`, `maxCount`, `contextBefore`, `contextAfter`, `maxColumns`, `timeoutMs`).\n3. `grep` creates `CancelToken` from `timeoutMs` + `AbortSignal` and runs inside `task::blocking(\"grep\", ...)`.\n4. `search` and `hasMatch` operate on provided string/`Uint8Array` content and do not scan the filesystem.\n\n### Execution branches\n\n- **In-memory branch**\n - `search` -> `search_sync` / search helpers over provided content bytes.\n - `hasMatch` compiles/checks pattern against provided content and returns a boolean.\n - No filesystem scan, no `fs_cache`.\n- **Single-file branch**\n - `grep` resolves path, checks metadata is file, and searches that file.\n- **Directory branch**\n - Optional cache lookup via `fs_cache::get_or_scan` when `cache: true`.\n - Fresh scan via `fs_cache::force_rescan` when `cache: false`.\n - Optional empty-result recheck when cached results are older than the empty-result recheck threshold.\n - Entry filtering: file-only + optional glob filter (`glob_util`) + optional type filter mapping (`js`, `ts`, `rust`, etc.).\n\n### Search/collection semantics\n\n- Regex engine: `grep_regex::RegexMatcherBuilder` with `ignoreCase` and `multiline`.\n- Context resolution:\n - `contextBefore/contextAfter` override legacy `context`.\n - Non-content modes do not collect context.\n- Output modes:\n - `content` -> one `GrepMatch` per hit.\n - `count` and `filesWithMatches` map to count-style entries (`lineNumber=0`, `line=\"\"`, `matchCount` set).\n- Limits:\n - Global `offset` and `maxCount` apply across files.\n - Parallel path is used only when `maxCount` is unset and `offset == 0`; otherwise sequential path preserves deterministic global offset/limit semantics.\n\n### Result shaping back to JS\n\n- Rust `SearchResult`/`GrepResult` fields map to TS interfaces via N-API object conversion.\n- Counters are clamped before crossing N-API where needed.\n- `GrepResult.limitReached` is optional and emitted when true.\n- Streaming callback receives each shaped `GrepMatch` for content or count-style entries.\n\n### Failure behavior\n\n- `search` returns `SearchResult.error` for regex/search failures instead of throwing.\n- `grep` rejects on hard errors such as invalid path, invalid glob/regex, or cancellation timeout/abort.\n- `hasMatch` returns a boolean on success and throws on invalid pattern/UTF-8 conversion errors.\n- File open/search errors in multi-file scans are skipped per-file; scan continues.\n\n### Malformed regex handling\n\n`grep.rs` sanitizes braces before regex compile:\n\n- Invalid repetition-like braces are escaped (`{`/`}` -> `\\{`/`\\}`) when they cannot form `{N}`, `{N,}`, `{N,M}`.\n- This prevents common literal-template fragments (for example `${platform}`) from failing as malformed repetition.\n- Remaining invalid regex syntax still returns a regex error.\n\n## 2) File discovery (`glob`) and fuzzy path search (`fuzzyFind`)\n\n`glob` and `fuzzyFind` share `fs_cache` scans; matching logic differs.\n\n### `glob` flow\n\n1. Caller passes `GlobOptions` directly. `pattern` and `path` are required in the generated type.\n2. Rust resolves the search path and compiles pattern via `glob_util::compile_glob`.\n3. Entry source:\n - `cache=true` -> `get_or_scan` + optional stale-empty `force_rescan`.\n - `cache=false` -> `force_rescan(..., store=false)` (fresh only).\n4. Filtering:\n - skip `.git` always;\n - skip `node_modules` unless requested (`includeNodeModules`) or pattern mentions `node_modules`;\n - apply glob match;\n - apply file-type filter; symlink `file`/`dir` filters resolve target metadata.\n5. Optional sort by mtime descending (`sortByMtime`) before truncating to `maxResults`.\n\n### `fuzzyFind` flow\n\n1. Rust implementation lives in `fd.rs`; generated export is `fuzzyFind`.\n2. Shared scan source from `fs_cache` with the same cache/no-cache split and stale-empty recheck policy.\n3. Scoring:\n - exact / starts-with / contains / subsequence-based fuzzy score;\n - separator/punctuation-normalized scoring path;\n - directory bonus and deterministic tie-break (`score desc`, then `path asc`).\n4. Symlink entries are excluded from fuzzy results.\n\n### Failure behavior\n\n- Invalid glob pattern returns an error from `glob_util::compile_glob`.\n- Search root must resolve to an existing directory for directory discovery flows.\n- Cancellation/timeouts propagate as abort errors via `CancelToken::heartbeat()` checks in loops.\n\n### Malformed glob handling\n\n`glob_util::build_glob_pattern` is tolerant:\n\n- normalizes `\\` to `/`,\n- auto-prefixes simple recursive patterns with `**/` when `recursive=true`,\n- auto-closes unbalanced `{...` alternation groups before compile.\n\n## 3) AST search/edit (`astGrep`, `astEdit`)\n\n`ast.rs` exposes syntax-aware code search and rewrite operations.\n\n- `astGrep(options)` returns matches with byte/line/column coordinates and optional metavariable bindings.\n- `astEdit(options)` returns replacement changes, per-file counts, searched/touched file counts, parse errors, and whether edits were applied.\n- `dryRun` defaults to true for edit options in the generated documentation.\n- Options include language override, path/glob/selector, strictness, limits, parse-error policy, `signal`, and `timeoutMs`.\n\nThese exports are direct native APIs used by tooling; they are not mediated by a TS wrapper in `packages/natives`.\n\n## 4) Shared scan/cache lifecycle (`fs_cache`)\n\n`fs_cache` stores scan results as normalized relative entries (`path`, `fileType`, optional `mtime`) keyed by:\n\n- canonical search root,\n- `include_hidden`,\n- `use_gitignore`.\n\n### Cache state transitions\n\n1. **Miss / disabled**\n - TTL is `0` or key absent/expired -> fresh collection.\n2. **Hit**\n - Entry age is within TTL -> return cached entries + `cache_age_ms`.\n3. **Stale-empty recheck**\n - If query yields zero matches and cache age exceeds the empty-result threshold, force one rescan.\n4. **Invalidation**\n - `invalidateFsScanCache(path?)`:\n - no arg: clear all keys;\n - path arg: remove keys for roots affected by that path.\n\n### Stale-result tradeoff\n\n- Cache favors low-latency repeated scans over immediate consistency.\n- TTL window can return stale positives/negatives.\n- Empty-result recheck reduces stale negatives for older cached scans at the cost of one extra scan.\n- Explicit invalidation is the intended correctness hook after file mutations.\n\n## 5) ANSI text utilities (`text`)\n\nThese are pure, in-memory utilities.\n\n### Boundaries and responsibilities\n\n- `text.rs` owns terminal-cell semantics:\n - ANSI sequence parsing,\n - grapheme-aware width and slicing,\n - wrap/truncate/sanitize behavior,\n - explicit tab-width parameter on width-sensitive APIs.\n- `grep.rs` line truncation (`maxColumns`) is separate:\n - simple character-boundary truncation of matched lines with `...`,\n - not ANSI-state-preserving and not terminal-cell width aware.\n\n### Key behaviors\n\n- `wrapTextWithAnsi`: wraps by visible width, carries active SGR codes across wrapped lines.\n- `truncateToWidth`: visible-cell truncation with ellipsis policy (`Unicode`, `Ascii`, `Omit`), optional right padding.\n- `sliceWithWidth`: column slicing with optional strict width enforcement.\n- `extractSegments`: extracts before/after segments around an overlay while restoring ANSI state for the `after` segment.\n- `sanitizeText` (ANSI/control/surrogate stripping with line-ending normalization) no longer lives in `text.rs`; it moved to `@gajae-code/utils` as a pure-JS implementation in `packages/utils/src/sanitize-text.ts`. The native binding was removed in the same change because the JS version was competitive on the benchmarked workloads, and keeping a Rust copy forced every caller (including `pi-utils`) to pull in `@gajae-code/natives`.\n- `visibleWidth`: counts visible terminal cells using caller-supplied tab width.\n\n### Failure behavior\n\nText functions generally return deterministic transformed output; errors are limited to N-API argument/string conversion boundaries.\n\n## 6) Syntax highlighting (`highlight`)\n\n`highlight.rs` is pure transformation; it does not use the filesystem scan cache.\n\n### Flow\n\n1. Caller passes `code`, optional `lang`, and ANSI color palette.\n2. Rust resolves syntax by token/name lookup, extension lookup, alias table fallback, then plain-text fallback.\n3. Each line is parsed with syntect `ParseState` and scope stack.\n4. Scopes map to semantic color categories and ANSI color codes are injected/reset.\n\n### Failure behavior\n\n- Per-line parse failure does not fail the call: that line is appended unhighlighted and processing continues.\n- Unknown/unsupported language falls back to plain text syntax.\n\n## 7) Token counting (`tokens`)\n\n`countTokens(input, encoding?)` is an in-memory utility.\n\n- `input` may be a single string or an array of strings.\n- Arrays return one aggregate count and are encoded in parallel in Rust.\n- Default encoding is `O200kBase`; `Cl100kBase` remains available as a compatibility alias routing to `o200k_base` in default builds.\n- The implementation uses ordinary tokenization, not special-token handling.\n\n## Pure utility vs filesystem-dependent flows\n\n| Flow | Filesystem access | Shared cache | Notes |\n| ---------------------------- | ----------------- | -------------------- | --------------------------------------------- |\n| `search` / `hasMatch` | No | No | regex on provided bytes/string only |\n| `text` module functions | No | No | ANSI/width/sanitization only |\n| `highlight` module functions | No | No | syntax + ANSI coloring only |\n| `countTokens` | No | No | tokenization only |\n| `astGrep` / `astEdit` | Yes | No | syntax-aware file search/edit |\n| `glob` | Yes | Optional | directory scans + glob filtering |\n| `fuzzyFind` | Yes | Optional | directory scans + fuzzy scoring |\n| `grep` (file/dir path) | Yes | Optional in dir mode | ripgrep over files, optional filters/callback |\n\n## End-to-end lifecycle summary\n\n1. Caller invokes generated native export with typed options.\n2. Rust validates/normalizes options and builds matcher/search config.\n3. For filesystem flows, entries are scanned (cache hit/miss/rescan where applicable) then filtered/scored/searched.\n4. Worker loops periodically call cancel heartbeat; timeout/abort can terminate execution.\n5. Rust shapes outputs into N-API objects (`lineNumber`, `matchCount`, `limitReached`, etc.).\n6. Generated bindings return typed JS objects and optional per-match callbacks for `grep`/`glob`.\n",
46
46
  "non-compaction-retry-policy.md": "# Non-compaction auto-retry policy\n\nThis document describes the standard API-error retry path in `AgentSession`.\n\nIt explicitly excludes context-overflow recovery via auto-compaction. Overflow is handled by compaction logic and is documented separately in [`compaction.md`](../docs/compaction.md).\n\n## Implementation files\n\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/config/settings-schema.ts`](../packages/coding-agent/src/config/settings-schema.ts)\n- [`../src/modes/controllers/event-controller.ts`](../packages/coding-agent/src/modes/controllers/event-controller.ts)\n- [`../src/modes/rpc/rpc-mode.ts`](../packages/coding-agent/src/modes/rpc/rpc-mode.ts)\n- [`../src/modes/rpc/rpc-client.ts`](../packages/coding-agent/src/modes/rpc/rpc-client.ts)\n- [`../src/modes/rpc/rpc-types.ts`](../packages/coding-agent/src/modes/rpc/rpc-types.ts)\n\n## Scope boundary vs compaction\n\nRetry and compaction are checked from the same `agent_end` path, but they are intentionally separated:\n\n1. `agent_end` inspects the last assistant message.\n2. `#isRetryableError(...)` runs first.\n3. If retry is initiated, compaction checks are skipped for that turn.\n4. Context-overflow errors are hard-excluded from retry classification (`isContextOverflow(...)` short-circuits retry).\n5. Overflow therefore falls through to `#checkCompaction(...)` instead of standard retry.\n\nSo: overload/rate/server/network-style failures use this retry policy; context-window overflow uses compaction recovery.\n\n## Retry classification\n\n`#isRetryableError(...)` requires all of the following:\n\n- assistant `stopReason === \"error\"`\n- `errorMessage` exists\n- message is **not** context overflow\n- `errorMessage` matches transient transport/envelope patterns or `isUsageLimitError(...)`\n\nCurrent retryable inputs are regex/string-classified:\n\n- transient transport/envelope failures, including Anthropic stream-envelope failures before `message_start`\n- overloaded/provider-returned-error wording\n- rate limit / usage limit / too many requests\n- HTTP-like server classes: 429, 500, 502, 503, 504\n- service unavailable / server/internal error\n- provider-suggested retry wording, including OpenAI `retry your request` failures\n- network/connection/socket failures, refused/closed connections, upstream connect/reset-before-headers, socket hang up, timeout/timed out, fetch failed, terminated, retry delay wording, and unexpected socket close messages\n\nThis is string-pattern classification, not typed provider error codes.\n\n## Retry lifecycle and state transitions\n\nSession state used by retry:\n\n- `#retryAttempt: number` (`0` means idle)\n- `#retryPromise: Promise<void> | undefined` (tracks in-progress retry lifecycle)\n- `#retryResolve: (() => void) | undefined` (resolves `#retryPromise`)\n- `#retryAbortController: AbortController | undefined` (cancels backoff sleep)\n\nFlow (`#handleRetryableError`):\n\n1. Read `retry` settings group.\n2. If `retry.enabled === false`, stop immediately (`false`, no retry started).\n3. Increment `#retryAttempt`.\n4. Create `#retryPromise` once (first attempt in a chain).\n5. If attempt exceeded `retry.maxRetries`, emit final failure event and stop.\n6. Compute base delay: `retry.baseDelayMs * 2^(attempt-1)`.\n7. For usage-limit errors, parse retry hints and call auth storage (`markUsageLimitReached(...)`); if credential switching succeeds, force delay to `0`, otherwise use a larger retry-after/backoff hint when present.\n8. If no credential switch occurred, suppress the current model selector for cooldown, try configured retry model fallback chains, and force delay to `0` on model switch.\n9. Emit `auto_retry_start`.\n10. Remove the trailing assistant error message from agent runtime state (kept in persisted session history).\n11. Sleep with abort support.\n12. Schedule `agent.continue()` through the post-prompt task scheduler (`delayMs: 1`) for the same prompt generation.\n\n### What resets retry counters\n\n`#retryAttempt` resets to `0` in these cases:\n\n- first successful non-error, non-aborted assistant message after retries started (emits `auto_retry_end { success: true }`)\n- retry cancellation during backoff sleep\n- max retries exceeded path\n\n`#retryPromise` resolves/clears when retry chain ends (success, cancellation, or max-exceeded), via `#resolveRetry()`.\n\n## Backoff and max-attempt semantics\n\nSettings:\n\n- `retry.enabled` (default `true`)\n- `retry.maxRetries` (default `3`)\n- `retry.baseDelayMs` (default `2000`)\n- `retry.maxDelayMs` (default `300000`)\n- `retry.requestMaxRetries` (default `5`) — provider request retries before a stream is established; counts retries, not the initial request\n- `retry.streamMaxRetries` (default `5`) — provider stream replay retries for replay-safe transient stream failures; counts retries, not the initial stream attempt\n\nAttempt numbering:\n\n- attempt counter is incremented before max-check\n- start events use current attempt (1-based)\n- max-exceeded end event reports `attempt: this.#retryAttempt - 1` (last attempted retry count)\n\nBackoff sequence with default settings:\n\n- attempt 1: 2000 ms\n- attempt 2: 4000 ms\n- attempt 3: 8000 ms\n\nDelay override inputs can come from parsed retry headers (`retry-after-ms`, `retry-after`, `x-ratelimit-reset-ms`, `x-ratelimit-reset`) or usage-limit backoff. Credential/model fallback switches set delay to `0`; otherwise parsed hints can extend the exponential local delay.\n\n## Abort mechanics\n\n### Explicit retry abort\n\n`abortRetry()`:\n\n- aborts `#retryAbortController` (if present)\n- resolves retry promise (`#resolveRetry()`) so awaiters are unblocked\n\nIf abort hits while sleeping, catch path emits:\n\n- `auto_retry_end { success: false, finalError: \"Retry cancelled\" }`\n- resets attempt/controller\n\n### Global operation abort interaction\n\n`abort()` calls `abortRetry()` before aborting the active agent stream. This guarantees retry backoff is cancelled when user issues a general abort.\n\n### TUI interaction\n\nOn `auto_retry_start`, EventController:\n\n- swaps `Esc` handler to `session.abortRetry()`\n- renders loader text: `Retrying (attempt/maxAttempts) in Ns… (esc to cancel)`\n\nOn `auto_retry_end`, it restores prior `Esc` handler and clears loader state.\n\n## Streaming and prompt completion behavior\n\n`prompt()` ultimately waits on `#waitForRetry()` after `agent.prompt(...)` returns.\n\nEffect:\n\n- a prompt call does not fully resolve until any started retry chain finishes (success/failure/cancel)\n- retry lifecycle is part of one logical prompt execution boundary\n\nThis prevents callers from treating a retrying turn as complete too early.\n\n## Controls: settings and RPC\n\n### Configuration knobs\n\nDefined in settings schema under retry group:\n\n- `retry.enabled`\n- `retry.maxRetries`\n- `retry.baseDelayMs`\n- `retry.fallbackChains`\n- `retry.fallbackRevertPolicy` (`\"cooldown-expiry\"` by default; `\"never\"` disables automatic restoration)\n\nProgrammatic toggles in session:\n\n- `setAutoRetryEnabled(enabled)` writes `retry.enabled`\n- `autoRetryEnabled` reads `retry.enabled`\n- `isRetrying` reports whether retry lifecycle promise is active\n\n### RPC controls\n\nRPC command surface:\n\n- `set_auto_retry` → `session.setAutoRetryEnabled(command.enabled)`\n- `abort_retry` → `session.abortRetry()`\n\nClient helpers:\n\n- `RpcClient.setAutoRetry(enabled)`\n- `RpcClient.abortRetry()`\n\nBoth commands return success responses; retry progress/failure details come from streamed session events, not command response payloads.\n\n## Event emission and failure surfacing\n\nSession-level retry events:\n\n- `auto_retry_start { attempt, maxAttempts, delayMs, errorMessage }`\n- `auto_retry_end { success, attempt, finalError? }`\n- `retry_fallback_applied { from, to, role }`\n- `retry_fallback_succeeded { model, role }`\n\nPropagation:\n\n- emitted through `AgentSession.subscribe(...)`\n- forwarded to extension runner as extension events\n- in RPC mode, forwarded directly as JSON event objects (`session.subscribe(event => output(event))`)\n- in TUI, consumed by `EventController` for loader/error UI\n\nFinal failure surfacing:\n\n- On max-exceeded or cancellation, `auto_retry_end.success === false`\n- TUI shows: `Retry failed after N attempts: <finalError>`\n- Extensions/hooks receive `auto_retry_end` with same fields\n- RPC consumers receive same event object on stdout stream\n\n## Permanent stop conditions\n\nRetry stops and will not auto-continue when any of these occur:\n\n- `retry.enabled` is false\n- error is not retry-classified\n- error is context overflow (delegated to compaction path)\n- max retries exceeded\n- user cancels retry (`abort_retry` or `Esc` during retry loader)\n- global abort (`abort`) cancels retry first\n\nA new retry chain can still start later on a future retryable error after counters reset.\n\n## Operational caveats\n\n- Classification is regex text matching; provider-specific structured errors are not used here.\n- Retry strips the failing assistant error from **runtime context** before re-continue, but session history still keeps that error entry.\n- `RpcSessionState` currently exposes `autoCompactionEnabled` but not an `autoRetryEnabled` field; RPC callers must track their own toggle state or query settings through other APIs.\n- Model fallback changes append temporary `model_change` entries and may later restore the primary model when its cooldown expires, depending on `retry.fallbackRevertPolicy`.\n\n## Provider request/stream retry budgets\n\nThe provider budgets are deliberately separate from session auto-retry:\n\n```yaml\nretry:\n requestMaxRetries: 4\n streamMaxRetries: 100\n```\n\n`requestMaxRetries` maps to provider SDK/fetch retry counts for request setup failures such as retryable 5xx/408/429/network errors. `streamMaxRetries` maps to provider-specific stream replay loops that are safe to repeat without duplicating visible assistant output. Providers that cannot safely replay a stream continue to surface the terminal error so the session-level auto-retry layer can decide whether to retry the turn.\n\nFail-fast cases stay fail-fast: invalid credentials (after any credential-refresh path is exhausted), unsupported model/provider configuration, malformed requests, context overflow, explicit user aborts, and permanent quota failures are not treated as transient provider budget candidates.\n",
47
47
  "notebook-tool-runtime.md": "# Notebook tool runtime internals\n\nThis document describes the current `notebook` tool implementation and its relationship to the kernel-backed Python runtime.\n\nThe critical distinction: **`notebook` is a JSON notebook editor, not a notebook executor**. It edits `.ipynb` cell sources directly; it does not start or talk to a Python kernel.\n\n## Implementation files\n\n- [`src/tools/notebook.ts`](../packages/coding-agent/src/tools/notebook.ts)\n- [`src/eval/py/executor.ts`](../packages/coding-agent/src/eval/py/executor.ts)\n- [`src/eval/py/kernel.ts`](../packages/coding-agent/src/eval/py/kernel.ts)\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts)\n- [`src/tools/eval.ts`](../packages/coding-agent/src/tools/eval.ts)\n\n## 1) Runtime boundary: editing vs executing\n\n## `notebook` tool (`src/tools/notebook.ts`)\n\n- Supports `action: edit | insert | delete` on a `.ipynb` file.\n- Resolves path relative to session CWD (`resolveToCwd`).\n- Loads notebook JSON, validates `cells` array, validates `cell_index` bounds.\n- Applies source edits in-memory and writes full notebook JSON back with `JSON.stringify(notebook, null, 1)`.\n- Returns textual summary + structured `details` (`action`, `cellIndex`, `cellType`, `totalCells`, `cellSource`).\n\nNo kernel lifecycle exists in this tool:\n\n- no gateway acquisition\n- no kernel session ID\n- no `execute_request`\n- no stream chunks from kernel channels\n- no rich display capture (`image/png`, JSON display, status MIME)\n\n## Notebook-like execution path (`src/tools/eval.ts` + `src/eval/py/*`)\n\nWhen the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`eval` tool** with `language: \"python\"`, not `notebook`.\n\nThat path is where kernel modes, restart/cancel behavior, chunk streaming, and output artifact truncation live.\n\n## 2) Notebook cell handling semantics (`notebook` tool)\n\n## Source normalization\n\n`content` is split into `source: string[]` with newline preservation:\n\n- each non-final line keeps trailing `\\n`\n- final line has no forced trailing newline\n\nThis mirrors notebook JSON conventions and avoids accidental line concatenation on later edits.\n\n## Action behavior\n\n- `edit`\n - replaces `cells[cell_index].source`\n - preserves existing `cell_type`\n- `insert`\n - inserts at `[0..cellCount]`\n - `cell_type` defaults to `code`\n - code cells initialize `execution_count: null` and `outputs: []`\n - markdown cells initialize only `metadata` + `source`\n- `delete`\n - removes `cells[cell_index]`\n - returns removed `source` in details for renderer preview\n\n## Error surfaces\n\nHard failures are thrown for:\n\n- missing notebook file\n- invalid JSON\n- missing/non-array `cells`\n- out-of-range index (insert and non-insert have different valid ranges)\n- missing `content` for `edit`/`insert`\n\nThese become `Error:` tool responses upstream; renderer uses notebook path + formatted error text.\n\n## 3) Kernel session semantics (where they actually exist)\n\nKernel semantics are implemented in `executePython` / `PythonKernel` and apply to the Python backend of the `eval` tool.\n\n## Modes\n\n`PythonKernelMode`:\n\n- `session` (default)\n - kernels cached in `kernelSessions` map\n - max 4 sessions; oldest evicted on overflow\n - idle/dead cleanup every 30s, timeout after 5 minutes\n - per-session queue serializes execution (`session.queue`)\n- `per-call`\n - creates kernel for request\n - executes\n - always shuts down kernel in `finally`\n\n## Reset behavior\n\n`eval` passes `reset` only for the first cell in a multi-cell Python call; later cells always run with `reset: false`.\n\n## Kernel death / restart / retry\n\nIn session mode (`withKernelSession`):\n\n- dead kernel detected by heartbeat (`kernel.isAlive()` check every 5s) or execute failure.\n- pre-run dead state triggers `restartKernelSession`.\n- execute-time crash path retries once: restart kernel, rerun handler.\n- `restartCount > 1` in same session throws `Python kernel restarted too many times in this session`.\n\nStartup retry behavior:\n\n- shared gateway kernel creation retries once on `SharedGatewayCreateError` with HTTP 5xx.\n\nResource exhaustion recovery:\n\n- detects `EMFILE`/`ENFILE`/\"Too many open files\" style failures\n- clears tracked sessions\n- calls `shutdownSharedGateway()`\n- retries kernel session creation once\n\n## 4) Environment/session variable injection\n\nKernel startup receives the optional session file path from executor:\n\n- `GJC_SESSION_FILE` (session state file path)\n\n`PythonKernel.#initializeKernelEnvironment(...)` then runs init script inside kernel to:\n\n- `os.chdir(cwd)`\n- inject env entries into `os.environ`\n- prepend cwd to `sys.path` if missing\n\nImplication:\n\n- prelude helpers that read session context rely on this env var in Python process state.\n\n## 5) Streaming/chunk and display handling (kernel-backed path)\n\nThe kernel client processes Jupyter protocol messages per execution:\n\n- `stream` -> text chunk to `onChunk`\n- `execute_result` / `display_data` ->\n - display text chosen by MIME precedence: `text/markdown` > `text/plain` > converted `text/html`\n - structured outputs captured separately:\n - `application/json` -> `{ type: \"json\" }`\n - `image/png` -> `{ type: \"image\" }`\n - `application/x-gjc-status` -> `{ type: \"status\" }` (no text emission)\n- `error` -> traceback text pushed to chunk stream + structured error metadata\n- `input_request` -> emits stdin warning text, sends empty `input_reply`, marks stdin requested\n- completion waits for both `execute_reply` and kernel `status=idle`\n\nCancellation/timeout:\n\n- abort signal triggers `interrupt()` (REST `/interrupt` + control-channel `interrupt_request`)\n- result marks `cancelled=true`\n- timeout path annotates output with `Command timed out after <n> seconds`\n\n## 6) Truncation and artifact behavior\n\n`OutputSink` in `src/session/streaming-output.ts` is used by kernel execution paths (`executeWithKernel`):\n\n- sanitizes every chunk (`sanitizeText`)\n- tracks total/output lines and bytes\n- optional artifact spill file (`artifactPath`, `artifactId`)\n- when in-memory buffer exceeds threshold (`DEFAULT_MAX_BYTES` unless overridden):\n - marks truncated\n - keeps tail bytes in memory (UTF-8 safe boundary)\n - can spill full stream to artifact sink\n\n`dump()` returns:\n\n- visible output text (possibly tail-truncated)\n- truncation flag + counts\n- artifact ID (for `artifact://<id>` references)\n\n`eval` converts this metadata into result truncation notices and TUI warnings.\n\n`notebook` tool does **not** use `OutputSink`; it has no stream/artifact truncation pipeline because it does not execute code.\n\n## 7) Renderer assumptions and formatting\n\n## Notebook renderer (`notebookToolRenderer`)\n\n- call view: status line with action + notebook path + cell/type metadata\n- result view:\n - success summary derived from `details`\n - `cellSource` rendered via `renderCodeCell`\n - markdown cells set language hint `markdown`; other cells have no explicit language override\n - collapsed code preview limit is `PREVIEW_LIMITS.COLLAPSED_LINES * 2`\n - supports expanded mode via shared render options\n - uses render cache keyed by width + expanded state\n\nError rendering assumption:\n\n- if first text content starts with `Error:`, renderer formats as notebook error block.\n\n## Python renderer (for actual execution output)\n\nKernel-backed execution rendering expects:\n\n- per-cell status transitions (`pending/running/complete/error`)\n- optional structured status event section\n- optional JSON output trees\n- truncation warnings + optional `artifact://<id>` pointer\n\nThis renderer behavior is unrelated to `notebook` JSON editing results except that both reuse shared TUI primitives.\n\n## 8) Divergence from eval Python backend behavior\n\nIf \"plain Python execution\" means the `eval` tool with `language: \"python\"`:\n\n- `eval` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.\n- `notebook` performs deterministic notebook JSON mutations only; no execution, no kernel state, no chunk stream, no display outputs, no artifact pipeline.\n\nIf a workflow needs both:\n\n1. edit notebook source with `notebook`\n2. execute code cells via `eval` with `language: \"python\"` (manually passing code), not through `notebook`\n\nCurrent implementation does not provide a single tool that both mutates `.ipynb` and executes notebook cells through kernel context.\n",
48
- "notifications-sdk.md": "# Notifications SDK\n\n<p align=\"center\">\n <img src=\"../assets/telegram-mobile-hero.png\" alt=\"Gajae Code 0.7.0 mobile answers for coding agents hero illustration\" width=\"100%\" />\n</p>\n\nA small, transport-agnostic way to get **action-needed** signals out of a GJC\nsession and deliver **replies** back — without scraping the terminal and without\nthe depth of the RPC / Coordinator / Bridge surfaces.\n\nThe stable contract is deliberately generic: every running session exposes one\nloopback WebSocket endpoint, and integrations are user-written clients that\nconnect to that endpoint. Telegram, Discord, Slack, mobile apps, and local tools\nall use the same JSON protocol. No upstream Rust, N-API, or wire-protocol change\nis required for a new integration.\n\n> Status: the Rust core (`crates/gjc-notifications`) provides the wire protocol,\n> action lifecycle, loopback WebSocket server, and endpoint discovery file. The\n> bundled Telegram daemon is a reference client layered on top of this SDK; it is\n> not the upstream topology.\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per session.** Each session runs its own loopback WebSocket\n server. Upstream does not maintain a shared daemon, singleton, or\n chat-to-session registry; multiplexing many sessions into one integration is a\n client-side concern.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-notifications` or the JSON protocol.\n- **Off unless configured.** No endpoint exists unless notifications are enabled\n and a token is present.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n<repo>/.gjc/state/notifications/<sessionId>.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"<per-session token>\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:<port>/?token=<token>\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"wg_run_stage_1\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `kind: \"ask\"` is answerable in both interactive/TUI and unattended/RPC modes.\n The `id` is the real workflow-gate id.\n- `kind: \"idle\"` is notify-only and ephemeral (not replayed to clients that\n connect later).\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"wg_run_stage_1\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (answered in the CLI/TUI), `client` (a remote reply won),\nor `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"wg_run_stage_1\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"wg_run_stage_1\", \"answer\": 0, \"token\": \"<token>\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\n\n## Answer semantics\n\nA remote reply answers a pending ask in **both** modes — RPC is not required:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). If you tap a button in the client, the\n ask resolves with that option; if you answer locally, the client receives\n `action_resolved` (`resolvedBy: \"local\"`) and the action becomes non-repliable.\n- **Unattended / RPC mode:** the reply resolves the real workflow-gate, driving\n the session the same way a local answer would.\n\nIn both modes the first valid reply wins; later replies get `already_answered`.\nIdle pings are notify-only.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/notifications/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\n\n## Managed notification adapters\n\nFor the exact user setup flow (`gjc notify setup`, BotFather token, private-chat pairing, status, and troubleshooting), see [Telegram notification onboarding](./telegram-onboarding.md).\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\n### Setup and auto-connect\n\nRun the setup command once:\n\n```sh\ngjc notify setup\n```\n\nThe wizard validates the bot token with Telegram, verifies private-chat Threaded\nMode capability via `getMe.has_topics_enabled`, waits for a private DM to the bot,\nand writes canonical global Settings under `config.yml` in the GJC agent\ndirectory. It enables:\n\n- `notifications.enabled`\n- `notifications.telegram.botToken`\n- `notifications.telegram.chatId`\n- `notifications.redact` (optional; default false)\n- `notifications.discord.botToken` / `notifications.discord.channelId` (optional Discord adapter)\n- `notifications.slack.botToken` / `notifications.slack.channelId` (optional Slack adapter)\n\nAfter setup, sessions auto-connect when notifications are enabled. Each session\nstill publishes its own loopback endpoint; the daemon is only the Telegram-side\nmultiplexer.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time `turn on threaded mode from\nbotfather miniapp to receive gjc notification!` nudge. Pairing is private-only,\nso flat delivery stays within the user's own private DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose`, `/lean`, `/verbosity <lean|verbose>`, and\n`/redact <on|off>`. The legacy `/answer <session-tag> <answer>` command is\nremoved — replies are routed by the topic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working,\nbut free-text replies and `/verbose`/`/lean`/`/verbosity`/`/redact` commands are\nthread-native and require topic routing. Do not pair a group, supergroup, or\nchannel to work around a missing BotFather menu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"<local Discord bot token>\"\n channelId: \"<Discord channel id>\"\n slack:\n botToken: \"<local Slack bot token>\"\n channelId: \"<Slack channel id>\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. When\nredaction is disabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/notifications/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/notifications/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `<repo>/.gjc/state/notifications/<sessionId>.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `<agentDir>/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth:** loopback-only bind (a non-loopback bind is refused). The WebSocket\n upgrade requires `?token=<control-token>` (HTTP `401` otherwise), and every\n lifecycle frame's `token` is re-checked (`unauthorized` on mismatch). The Rust\n ingress authenticates and forwards; it never spawns or applies policy.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- <prompt>`) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- <prompt>` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat send `/session_create path <repo-dir>` (or\n `/session_create worktree <repo> <branch>`, or `/session_create dir <newdir>`).\n The bot replies once the tmux launch is requested; the session shows up in\n `/session_recent` once it is ready. (Inline prompts via `-- <text>` are\n rejected for now with usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close <sessionId>` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume <sessionId|prefix>` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n",
48
+ "notifications-sdk.md": "# Notifications SDK\n\n<p align=\"center\">\n <img src=\"../assets/telegram-mobile-hero.png\" alt=\"Gajae Code 0.7.0 mobile answers for coding agents hero illustration\" width=\"100%\" />\n</p>\n\nA small, transport-agnostic way to get **action-needed** signals out of a GJC\nsession and deliver **replies** back — without scraping the terminal and without\nthe depth of the RPC / Coordinator / Bridge surfaces.\n\nThe stable contract is deliberately generic: every running session exposes one\nloopback WebSocket endpoint, and integrations are user-written clients that\nconnect to that endpoint. Telegram, Discord, Slack, mobile apps, and local tools\nall use the same JSON protocol. No upstream Rust, N-API, or wire-protocol change\nis required for a new integration.\n\n> Status: the Rust core (`crates/gjc-notifications`) provides the wire protocol,\n> action lifecycle, loopback WebSocket server, and endpoint discovery file. The\n> bundled Telegram daemon is a reference client layered on top of this SDK; it is\n> not the upstream topology.\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per session.** Each session runs its own loopback WebSocket\n server. Upstream does not maintain a shared daemon, singleton, or\n chat-to-session registry; multiplexing many sessions into one integration is a\n client-side concern.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-notifications` or the JSON protocol.\n- **Off unless configured.** No endpoint exists unless notifications are enabled\n and a token is present.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n<repo>/.gjc/state/notifications/<sessionId>.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"<per-session token>\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:<port>/?token=<token>\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"wg_run_stage_1\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `kind: \"ask\"` is answerable in both interactive/TUI and unattended/RPC modes.\n The `id` is the real workflow-gate id.\n- `kind: \"idle\"` is notify-only and ephemeral (not replayed to clients that\n connect later).\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"wg_run_stage_1\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (answered in the CLI/TUI), `client` (a remote reply won),\nor `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"wg_run_stage_1\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"wg_run_stage_1\", \"answer\": 0, \"token\": \"<token>\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\n\n## Answer semantics\n\nA remote reply answers a pending ask in **both** modes — RPC is not required:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). If you tap a button in the client, the\n ask resolves with that option; if you answer locally, the client receives\n `action_resolved` (`resolvedBy: \"local\"`) and the action becomes non-repliable.\n- **Unattended / RPC mode:** the reply resolves the real workflow-gate, driving\n the session the same way a local answer would.\n\nIn both modes the first valid reply wins; later replies get `already_answered`.\nIdle pings are notify-only.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/notifications/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\n\n## Managed notification adapters\n\nFor the exact user setup flow (`gjc notify setup`, BotFather token, private-chat pairing, status, and troubleshooting), see [Telegram notification onboarding](./telegram-onboarding.md).\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\n### Setup and auto-connect\n\nRun the setup command once:\n\n```sh\ngjc notify setup\n```\n\nThe wizard validates the bot token with Telegram, verifies private-chat Threaded\nMode capability via `getMe.has_topics_enabled`, waits for a private DM to the bot,\nand writes canonical global Settings under `config.yml` in the GJC agent\ndirectory. It enables:\n\n- `notifications.enabled`\n- `notifications.telegram.botToken`\n- `notifications.telegram.chatId`\n- `notifications.redact` (optional; default false)\n- `notifications.discord.botToken` / `notifications.discord.channelId` (optional Discord adapter)\n- `notifications.slack.botToken` / `notifications.slack.channelId` (optional Slack adapter)\n\nAfter setup, sessions auto-connect when notifications are enabled. Each session\nstill publishes its own loopback endpoint; the daemon is only the Telegram-side\nmultiplexer.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time `turn on threaded mode from\nbotfather miniapp to receive gjc notification!` nudge. Pairing is private-only,\nso flat delivery stays within the user's own private DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose`, `/lean`, `/verbosity <lean|verbose>`, and\n`/redact <on|off>`. The legacy `/answer <session-tag> <answer>` command is\nremoved — replies are routed by the topic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working,\nbut free-text replies and `/verbose`/`/lean`/`/verbosity`/`/redact` commands are\nthread-native and require topic routing. Do not pair a group, supergroup, or\nchannel to work around a missing BotFather menu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"<local Discord bot token>\"\n channelId: \"<Discord channel id>\"\n slack:\n botToken: \"<local Slack bot token>\"\n channelId: \"<Slack channel id>\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. When\nredaction is disabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/notifications/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/notifications/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `<repo>/.gjc/state/notifications/<sessionId>.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `<agentDir>/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth:** loopback-only bind (a non-loopback bind is refused). The WebSocket\n upgrade requires `?token=<control-token>` (HTTP `401` otherwise), and every\n lifecycle frame's `token` is re-checked (`unauthorized` on mismatch). The Rust\n ingress authenticates and forwards; it never spawns or applies policy.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- <prompt>`) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- <prompt>` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat send `/session_create path <repo-dir>` (or\n `/session_create worktree <repo> <branch>`, or `/session_create dir <newdir>`).\n `<repo-dir>`, `<repo>`, and `<newdir>` may use `~`/`~/...` for your own home\n directory; named-user forms such as `~alice/repo` are rejected. The bot replies\n once the tmux launch is requested; the session shows up in `/session_recent`\n once it is ready. (Inline prompts via `-- <text>` are rejected for now with\n usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close <sessionId>` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume <sessionId|prefix>` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n",
49
49
  "onboarding-packet.md": "# Gajae-Code Onboarding Packet\n\nThis packet is a docs-only, public-safe context seed for the `gajae-code` repository as inspected on 2026-06-01. It is intentionally a no-new-skill artifact: not a new workflow skill, command, agent, configuration surface, issue template, or runtime behavior.\n\n## Purpose in one paragraph\n\nGajae-Code is the `gjc` coding-agent CLI and supporting monorepo. The product centers on a small public workflow loop: clarify with `deep-interview`, plan with `ralplan`, execute and verify through `ultragoal`, and use `team` only when parallel tmux workers are useful. The main product package is `packages/coding-agent/`; supporting packages provide LLM/provider access, agent runtime, TUI rendering, native helpers, stats, utilities, benchmarks, and Python RPC/bot integrations.\n\n## Fixed public surface\n\nKeep this invariant front-and-center when onboarding to the repo:\n\n- Default workflow skills: `deep-interview`, `ralplan`, `team`, `ultragoal`.\n- Public role agents: `executor`, `architect`, `planner`, `critic`.\n- Bundled default workflow skill sources live under `packages/coding-agent/src/defaults/gjc/skills/`.\n- Bundled role-agent prompt sources live under `packages/coding-agent/src/prompts/agents/`.\n- Runtime state, specs, plans, goals, team state, and local overrides belong under `.gjc/` for the product and `.omx/` only for this agent-run orchestration.\n\nDo not add a fifth default skill, fifth public role agent, new command, new config surface, or feature-intake behavior unless that product decision has already been made and the default-surface gates are updated.\n\n## Primary entrypoints\n\n| Area | Repo-relative path | Why it matters |\n| ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |\n| CLI bootstrap | `packages/coding-agent/src/cli.ts` | Registers top-level CLI commands and routes default launch behavior. |\n| Session launch | `packages/coding-agent/src/main.ts` | Converts CLI/runtime settings into agent-session creation and mode dispatch. |\n| Agent assembly | `packages/coding-agent/src/sdk.ts` | Loads settings, default skills, rules, tools, auth/model state, system prompt, and agent runtime. |\n| Built-in tools | `packages/coding-agent/src/tools/index.ts` | Registers file, shell, edit, search, browser, task/subagent, and related public coding-harness tools. Memory backends are private integrations, not public tools. |\n| Default skills | `packages/coding-agent/src/defaults/gjc-defaults.ts` | Embeds and installs the four default workflow skills plus deep-interview fragments. |\n| Role agents | `packages/coding-agent/src/task/agents.ts` | Embeds bundled task-agent prompts; tests enforce public role-agent expectations. |\n| Product overview | `README.md` | Explains installation, product story, fixed workflow surface, and development entry commands. |\n| Architecture map | `docs/codebase-overview.md` | Public package map and runtime-flow reference. |\n\n## Package map\n\n- `packages/coding-agent/` — main `gjc` CLI, workflows, session runtime, tool registry, discovery, settings, prompts, and tests.\n- `packages/ai/` — provider/model boundary, streaming, auth, model registry, retries, and provider integrations.\n- `packages/agent/` — stateful agent loop and append-only context runtime.\n- `packages/tui/` — terminal UI framework and rendering primitives.\n- `packages/natives/` plus `crates/*` — native helpers, Rust/N-API bindings, shell/PTY, text search, AST, filesystem, and media utilities.\n- `packages/utils/` — shared TypeScript utilities, logging, formatting, process helpers, JSON/frontmatter, and sanitization.\n- `packages/stats/` — local observability dashboard and session/model usage aggregation.\n- `packages/typescript-edit-benchmark/` — TypeScript edit benchmark tooling.\n- `python/gjc-rpc/` — Python client for `gjc --mode rpc`.\n- `python/robogjc/` — GitHub triage/fix bot that drives `gjc --mode rpc`; this subtree has its own `AGENTS.md`.\n\n## Build, test, and validation commands\n\nPrefer targeted checks first, then broader checks when code changes justify them. For this docs-only packet, lightweight validation is enough.\n\n| Command | Scope | When to use |\n| --------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- |\n| `bun install` | Workspace dependencies | Initial local setup. |\n| `bun run install:defaults` | Local default install | Installs source-bundled default workflow definitions for local development. |\n| `bun packages/coding-agent/src/cli.ts --help` | CLI smoke/discovery | Fast source checkout CLI sanity check. |\n| `bun run check:ts` | Type/lint/default UI checks | Broad TypeScript validation; heavier than docs-only changes. |\n| `bun run test` | Full TS + Rust tests | Broad regression check; use for runtime/product changes. |\n| `bun run ci:test:smoke` | CLI version/help/stats worker smoke | Useful before release/install changes. |\n| `bun scripts/check-visible-definitions.ts` | Default surface gate | Required after workflow-definition changes. |\n| `bun scripts/verify-g002-gates.ts` | Rebrand/default-surface gate | Required after workflow-definition or public-surface changes. |\n| `bun scripts/rebrand-inventory.ts --strict` | Rebrand inventory gate | Required after workflow-definition or public-surface changes. |\n| `bun test packages/coding-agent/test/default-gjc-definitions.test.ts` | Four-skills/four-agents contract | Required after default workflow/agent surface changes. |\n\nRepository rule: do not run `tsc` or `npx tsc`; use the Bun scripts above.\n\n## Danger zones\n\n- **Default surface expansion:** `packages/coding-agent/src/defaults/gjc/skills/`, `packages/coding-agent/src/defaults/gjc-defaults.ts`, `packages/coding-agent/src/prompts/agents/`, and model-assignment tests are contract-heavy. Changes here can accidentally alter the fixed four-skills/four-agents shape.\n- **CLI commands:** `packages/coding-agent/src/cli.ts` and `packages/coding-agent/src/commands/` define visible behavior. Adding commands or aliases is a product-surface change.\n- **Runtime/session assembly:** `packages/coding-agent/src/main.ts`, `packages/coding-agent/src/sdk.ts`, discovery, settings, tools, and system-prompt paths can affect every session.\n- **TUI/logging:** Avoid `console.log`, `console.warn`, or `console.error` inside `packages/coding-agent/`; use the centralized logger to avoid corrupting TUI rendering.\n- **Secrets/auth/config:** Keep `docs/secrets.md`, auth broker/gateway code, settings, and environment-variable docs public-safe. Do not expose tokens or private infrastructure.\n- **Native/Rust build:** `packages/natives/` and `crates/*` can require platform-specific toolchains and CI artifact behavior.\n- **Python bot subtree:** `python/robogjc/` has its own local instructions and trust boundaries.\n- **Generated model data:** Do not edit `packages/ai/src/models.json` directly; update generators/descriptors/resolvers and regenerate with `bun --cwd=packages/ai run generate-models`.\n\n## Unknowns worth preserving\n\n- Which onboarding packet shape will be most useful for future `gjc` context ingestion is still an experiment, not a product contract.\n- Public issue #158 / `gajae-deep-onboarding` context is summarized only from the user-provided prompt in this run; this packet does not add issue intake or feature workflow behavior.\n- Full CI may depend on runner/system dependencies and native artifacts; docs-only changes usually do not need the full matrix locally.\n- Some packages contain internal or hidden utility prompts/agents beyond the four public role agents. Public-facing docs should keep the four-role contract clear.\n\n## First safe tasks for a new contributor or agent\n\n1. Read `README.md`, `docs/codebase-overview.md`, and this packet.\n2. Run `bun packages/coding-agent/src/cli.ts --help` for a fast CLI surface check after dependencies are installed.\n3. For docs-only edits, run formatting/check commands that do not mutate runtime behavior.\n4. For default-surface edits, run the four required gates listed in the command table before claiming completion.\n5. For package code edits, start with the nearest package test, then escalate to `bun run check:ts` or `bun run test` as risk increases.\n6. Before changing `packages/coding-agent/src/defaults/gjc/skills/`, `packages/coding-agent/src/prompts/agents/`, `packages/coding-agent/src/commands/`, or config/settings paths, write down whether the change alters public surface area.\n\n## Context seed checklist\n\nA future agent can use this packet as context if it preserves these constraints:\n\n- Keep changes public-safe and repo-relative.\n- Prefer docs and tests over new runtime abstractions for onboarding experiments.\n- Treat the fixed four-skills/four-agents shape as a product constraint.\n- Verify claims with repo files before summarizing them.\n- Report validation evidence and caveats instead of implying hidden automation.\n",
50
50
  "onboarding-receipt.md": "# Onboarding Packet Receipt\n\n- Date: 2026-06-01\n- Scope: docs-only no-new-skill onboarding packet experiment for this repository.\n- Output files:\n - `docs/onboarding-packet.md`\n - `docs/onboarding-receipt.md`\n- Public-safe boundary: no secrets, tokens, hidden prompts, private infrastructure, internal ops, or private paths beyond repo-relative paths.\n- Product boundary: no new skill, command, agent slot, issue, config, or runtime behavior.\n\n## Evidence inspected\n\n- `README.md`\n- `docs/codebase-overview.md`\n- `package.json`\n- `packages/coding-agent/package.json`\n- `packages/coding-agent/src/cli.ts`\n- `packages/coding-agent/src/main.ts`\n- `packages/coding-agent/src/sdk.ts`\n- `packages/coding-agent/src/defaults/gjc-defaults.ts`\n- `packages/coding-agent/src/task/agents.ts`\n- `packages/coding-agent/test/default-gjc-definitions.test.ts`\n- `.github/workflows/ci.yml`\n- `.github/workflows/dev-ci.yml`\n\n## Result\n\nThe packet records repo purpose, package layout, main entrypoints, build/test commands, danger zones, unknowns, and first safe tasks without changing the product surface. It is suitable as a public context seed for future onboarding experiments, not as a feature intake mechanism.\n\n## Caveats\n\n- The attempted `omx question --input '<json>' --json` interview round failed before user input because the runtime reported no attached tmux client; no human answer was inferred from that failed call.\n- Public issue context is limited to the user-provided prompt summary for this run.\n- Full CI was not required for the docs-only artifact unless later code/runtime files change.\n",
51
51
  "ooo-bridge-extension-contract.md": "# Ouroboros `ooo` bridge extension contract\n\nGJC exposes the `ooo` bridge through the existing extension input-event surface. It is not a default workflow skill, hook, slash command, or built-in agent.\n\n## Interception surface\n\nExtensions register an `input` handler:\n\n```ts\nimport { createOuroborosOooBridge } from \"@gajae-code/coding-agent/extensibility/extensions\";\n\nexport default function activate(gjc) {\n gjc.on(\"input\", createOuroborosOooBridge());\n}\n```\n\nThe handler matches only the bare exact prefix:\n\n- `ooo`\n- `ooo ...`\n\nIt does not match embedded or longer-token text such as `please ooo status`, `oooo`, or `/ooo`.\n\nThe extension runner already treats `InputEventResult.handled === true` as terminal: the input is not sent through normal model flow. An empty result (`{}`) means continue/pass-through, preserving existing chained input handlers and normal prompt handling.\n\n## Dispatch and result semantics\n\n`createOuroborosOooBridge()` is a small specialization of `createExactPrefixCommandBridge()`:\n\n- command: `ouroboros`\n- arguments: `dispatch`, then the full submitted input text\n- recursion guard variable: `_OUROBOROS_GJC_BRIDGE_DEPTH`\n- continue/pass-through exit code: `78`\n\nExit-code mapping:\n\n| Dispatch result | GJC input result |\n| --- | --- |\n| `0` | `{ handled: true }`; do not send input to the model. |\n| `78` | `{}`; continue/pass-through so GJC processes the input normally. |\n| any other non-zero | Surface an extension error notification using stderr, then stdout, then a generic exit-code message, and return `{ handled: true }`; the failed `ooo` command is terminal and is not sent to the model. |\n\n## Recursion guard\n\nBefore dispatch, the helper sets `_OUROBOROS_GJC_BRIDGE_DEPTH` to the next numeric depth and restores the previous value after dispatch finishes. A current numeric depth of `0` or `1` is dispatchable, which preserves concurrent independent interactive inputs while marking child dispatcher processes with depth `1`. A current numeric depth greater than `1`, or any non-empty non-numeric value, returns `{}` without dispatching.\n\nThis means the bridge allows exactly one inherited bridge-marked dispatcher level and blocks recursive re-entry from deeper bridge-marked children. The guard also passes through `event.source === \"extension\"` to avoid extension-originated messages re-entering the bridge.\n\n## Installation and discovery\n\nThe canonical install location is the agent extensions directory discovered by the native GJC provider:\n\n- user-level: `${GJC_CODING_AGENT_DIR:-$HOME/.gjc/agent}/extensions`\n- project-level: `<cwd>/${GJC_CONFIG_DIR:-.gjc}/extensions`\n\nFor native discovery, install one of:\n\n- `extensions/<name>.ts` or `extensions/<name>.js`\n- `extensions/<name>/index.ts` or `extensions/<name>/index.js`\n- `extensions/<name>/package.json` declaring extension entries\n\nThe loader scans one level under each `extensions` directory. Complex packages should use a package manifest instead of relying on recursive discovery.\n\n`GJC_CONFIG_DIR` selects the project config directory name. `GJC_CODING_AGENT_DIR` selects the user agent directory name under `$HOME`. The native provider resolves those locations before loading extension modules, skills, rules, hooks, and related capabilities.\n\nHooks are not the input bridge surface: `packages/coding-agent/src/capability/hook.ts` defines pre/post tool hooks only.\n",
@@ -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,
@@ -11,6 +11,7 @@
11
11
  * prompt text ever enters a frame, audit, log, or response until daemon-owned
12
12
  * 0600 prompt refs are designed.
13
13
  */
14
+ import * as os from "node:os";
14
15
  import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
15
16
 
16
17
  export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
@@ -94,22 +95,21 @@ export function parseLifecycleCommand(text: string | undefined): ParsedLifecycle
94
95
  const kind = args[0];
95
96
  if (kind === "path") {
96
97
  if (args.length !== 2) return { kind: "usage", message: USAGE };
97
- const p = args[1]!;
98
- if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
98
+ const p = normalizeLifecyclePath(args[1]!);
99
+ if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
99
100
  return { kind: "create", target: { kind: "existing_path", path: p } };
100
101
  }
101
102
  if (kind === "dir") {
102
103
  if (args.length !== 2) return { kind: "usage", message: USAGE };
103
- const p = args[1]!;
104
- if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
104
+ const p = normalizeLifecyclePath(args[1]!);
105
+ if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
105
106
  return { kind: "create", target: { kind: "plain_dir", path: p } };
106
107
  }
107
108
  if (kind === "worktree") {
108
109
  if (args.length !== 3) return { kind: "usage", message: USAGE };
109
- const repo = args[1]!;
110
+ const repo = normalizeLifecyclePath(args[1]!);
110
111
  const branch = args[2]!;
111
- if (!isSafePath(repo))
112
- return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
112
+ if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
113
113
  if (!isSafeBranch(branch)) {
114
114
  return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
115
115
  }
@@ -137,10 +137,10 @@ export function validateLifecycleTarget(
137
137
  if (verb === "session_create") {
138
138
  const t = target as SessionCreateTarget;
139
139
  if (t.kind === "existing_path" || t.kind === "plain_dir") {
140
- return isSafePath(t.path) ? { ok: true } : bad("invalid path");
140
+ return normalizeLifecyclePath(t.path) ? { ok: true } : bad("invalid path");
141
141
  }
142
142
  if (t.kind === "worktree") {
143
- if (!isSafePath(t.repo)) return bad("invalid repo path");
143
+ if (!normalizeLifecyclePath(t.repo)) return bad("invalid repo path");
144
144
  return isSafeBranch(t.branch) ? { ok: true } : bad("invalid branch");
145
145
  }
146
146
  return bad("unknown create target");
@@ -156,6 +156,16 @@ export function validateLifecycleTarget(
156
156
  // --- Safety primitives (defensive; the full-trust paired chat is accepted, but
157
157
  // we still reject obviously malformed/injection-shaped inputs early). ---
158
158
 
159
+ export function normalizeLifecyclePath(value: string): string | undefined {
160
+ if (!isSafePath(value)) return undefined;
161
+ if (value === "~") return os.homedir() || undefined;
162
+ if (value.startsWith("~/")) {
163
+ const home = os.homedir();
164
+ return home ? `${home}${value.slice(1)}` : undefined;
165
+ }
166
+ if (value.startsWith("~")) return undefined;
167
+ return value;
168
+ }
159
169
  function isSafeIdentifier(value: string): boolean {
160
170
  return /^[A-Za-z0-9._-]{1,128}$/.test(value);
161
171
  }
@@ -22,6 +22,7 @@ import {
22
22
  statusGjcTmuxSession,
23
23
  } from "../gjc-runtime/tmux-sessions";
24
24
  import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
25
+ import { normalizeLifecyclePath } from "./lifecycle-commands";
25
26
  import {
26
27
  type AuditEvent,
27
28
  type CreateEffectResult,
@@ -132,12 +133,16 @@ export function buildCreateArgv(
132
133
  _ids: { intendedSessionId: string; startupPromptRef?: string },
133
134
  ): { cwd: string; args: string[] } {
134
135
  if (frame.target.kind === "worktree") {
136
+ const cwd = normalizeLifecyclePath(frame.target.repo);
137
+ if (!cwd) throw new Error("invalid_lifecycle_repo_path");
135
138
  // Use the `--worktree=<branch>` form so the branch is a single argv token:
136
139
  // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
137
140
  // launcher flag / detached-mode trigger.
138
- return { cwd: frame.target.repo, args: [`--worktree=${frame.target.branch}`] };
141
+ return { cwd, args: [`--worktree=${frame.target.branch}`] };
139
142
  }
140
- return { cwd: frame.target.path, args: [] };
143
+ const cwd = normalizeLifecyclePath(frame.target.path);
144
+ if (!cwd) throw new Error("invalid_lifecycle_path");
145
+ return { cwd, args: [] };
141
146
  }
142
147
 
143
148
  /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + GJC tags. */
@@ -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
  }
@@ -1321,6 +1324,8 @@ const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
1321
1324
  "toolName",
1322
1325
  "targetId",
1323
1326
  "firstKeptEntryId",
1327
+ "encrypted_content",
1328
+ "reasoning_encrypted_content",
1324
1329
  ]);
1325
1330
 
1326
1331
  function shouldExternalizeResidentString(key: string | undefined): boolean {
@@ -1512,6 +1517,40 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
1512
1517
  return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
1513
1518
  }
1514
1519
 
1520
+ function materializeProviderVisibleEntrySync(entry: SessionEntry, stores: ResidentBlobStores): SessionEntry {
1521
+ if (entry.type === "compaction") {
1522
+ const cache = new Map<string, string>();
1523
+ const summary = materializeResidentValueSync(entry.summary, stores, "summary", cache);
1524
+ const shortSummary = materializeResidentValueSync(entry.shortSummary, stores, "shortSummary", cache);
1525
+ const remote = entry.preserveData?.openaiRemoteCompaction;
1526
+ const remoteRecord = isRecord(remote) ? remote : undefined;
1527
+ const replacementHistory = remoteRecord
1528
+ ? materializeResidentValueSync(remoteRecord.replacementHistory, stores, "replacementHistory", cache)
1529
+ : undefined;
1530
+ const preserveData =
1531
+ remoteRecord && replacementHistory !== undefined && replacementHistory !== remoteRecord.replacementHistory
1532
+ ? {
1533
+ ...entry.preserveData,
1534
+ openaiRemoteCompaction: {
1535
+ ...remoteRecord,
1536
+ replacementHistory,
1537
+ },
1538
+ }
1539
+ : entry.preserveData;
1540
+ return {
1541
+ ...entry,
1542
+ summary: typeof summary === "string" ? summary : entry.summary,
1543
+ shortSummary: typeof shortSummary === "string" ? shortSummary : entry.shortSummary,
1544
+ preserveData,
1545
+ };
1546
+ }
1547
+ if (entry.type === "branch_summary") {
1548
+ const summary = materializeResidentValueSync(entry.summary, stores, "summary", new Map<string, string>());
1549
+ return typeof summary === "string" ? { ...entry, summary } : { ...entry };
1550
+ }
1551
+ return cloneSessionEntry(entry);
1552
+ }
1553
+
1515
1554
  const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
1516
1555
  const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__gjcColdSpillArguments";
1517
1556
  const COLD_SPILL_MIN_CHARS = 1024;
@@ -1530,19 +1569,6 @@ type ColdSpillResidentPromotion = {
1530
1569
  function isRecord(value: unknown): value is Record<string, unknown> {
1531
1570
  return typeof value === "object" && value !== null && !Array.isArray(value);
1532
1571
  }
1533
-
1534
- function isColdSpillRef(value: unknown): value is ColdSpillRef {
1535
- return (
1536
- isRecord(value) &&
1537
- value.kind === "cold_spill" &&
1538
- typeof value.ref === "string" &&
1539
- (value.encoding === "utf8" || value.encoding === "json") &&
1540
- typeof value.originalChars === "number" &&
1541
- typeof value.sha256 === "string" &&
1542
- typeof value.bytes === "number"
1543
- );
1544
- }
1545
-
1546
1572
  function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
1547
1573
  return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
1548
1574
  }
@@ -3988,8 +4014,11 @@ export class SessionManager {
3988
4014
  * Returns undefined if no model change has been recorded.
3989
4015
  */
3990
4016
  getLastModelChangeRole(): string | undefined {
4017
+ const visited = new Set<string>();
3991
4018
  let current = this.getLeafEntry();
3992
4019
  while (current) {
4020
+ if (visited.has(current.id)) break;
4021
+ visited.add(current.id);
3993
4022
  if (current.type === "model_change") {
3994
4023
  return current.role ?? "default";
3995
4024
  }
@@ -4002,11 +4031,13 @@ export class SessionManager {
4002
4031
  const firstKept = this.#byId.get(firstKeptEntryId);
4003
4032
  const compaction = this.#byId.get(compactionEntryId);
4004
4033
  if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
4005
- if (!compaction || compaction.type !== "compaction")
4006
- throw new Error(`Compaction entry ${compactionEntryId} not found`);
4034
+ if (compaction?.type !== "compaction") throw new Error(`Compaction entry ${compactionEntryId} not found`);
4007
4035
  const ids: string[] = [];
4036
+ const visited = new Set<string>();
4008
4037
  let current: SessionEntry | undefined = compaction;
4009
4038
  while (current) {
4039
+ if (visited.has(current.id)) break;
4040
+ visited.add(current.id);
4010
4041
  ids.push(current.id);
4011
4042
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4012
4043
  }
@@ -4155,8 +4186,11 @@ export class SessionManager {
4155
4186
  getBranchForFidelity(fromId?: string): SessionEntry[] {
4156
4187
  const cache = new Map<string, string>();
4157
4188
  const path: SessionEntry[] = [];
4189
+ const visited = new Set<string>();
4158
4190
  let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4159
4191
  while (current) {
4192
+ if (visited.has(current.id)) break;
4193
+ visited.add(current.id);
4160
4194
  path.push(
4161
4195
  rehydrateColdSpillEntry(
4162
4196
  materializeResidentEntrySync(current, this.#residentBlobStores(), cache),
@@ -4172,8 +4206,11 @@ export class SessionManager {
4172
4206
 
4173
4207
  #getCanonicalBranchClones(fromId?: string): SessionEntry[] {
4174
4208
  const path: SessionEntry[] = [];
4209
+ const visited = new Set<string>();
4175
4210
  let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
4176
4211
  while (current) {
4212
+ if (visited.has(current.id)) break;
4213
+ visited.add(current.id);
4177
4214
  path.push(cloneSessionEntry(current));
4178
4215
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4179
4216
  }
@@ -4267,9 +4304,12 @@ export class SessionManager {
4267
4304
  this.#getBranchMaterializerCallCount++;
4268
4305
  const cache = new Map<string, string>();
4269
4306
  const path: SessionEntry[] = [];
4307
+ const visited = new Set<string>();
4270
4308
  const startId = fromId ?? this.#leafId;
4271
4309
  let current = startId ? this.#byId.get(startId) : undefined;
4272
4310
  while (current) {
4311
+ if (visited.has(current.id)) break;
4312
+ visited.add(current.id);
4273
4313
  path.push(materializeResidentEntrySync(current, this.#residentBlobStores(), cache));
4274
4314
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4275
4315
  }
@@ -4303,8 +4343,11 @@ export class SessionManager {
4303
4343
  #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
4304
4344
  if (fromId === null || (fromId === undefined && this.#leafId === null)) return [];
4305
4345
  const ids: string[] = [];
4346
+ const visited = new Set<string>();
4306
4347
  let current = this.#byId.get(fromId ?? this.#leafId ?? "");
4307
4348
  while (current) {
4349
+ if (visited.has(current.id)) break;
4350
+ visited.add(current.id);
4308
4351
  ids.push(current.id);
4309
4352
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
4310
4353
  }
@@ -4330,7 +4373,8 @@ export class SessionManager {
4330
4373
  if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
4331
4374
  return cloneSessionEntry(entry);
4332
4375
  }
4333
- if (entry.type !== "message" && entry.type !== "custom_message") return cloneSessionEntry(entry);
4376
+ if (entry.type !== "message" && entry.type !== "custom_message")
4377
+ return materializeProviderVisibleEntrySync(entry, this.#residentBlobStores());
4334
4378
  const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
4335
4379
  const rehydrated = rehydrateColdSpillEntry(
4336
4380
  materialized,
@@ -4418,18 +4462,48 @@ export class SessionManager {
4418
4462
  nodeMap.set(entry.id, { entry, children: [], label });
4419
4463
  }
4420
4464
 
4421
- // Build tree
4465
+ const addRoot = (node: SessionTreeNode): void => {
4466
+ if (!roots.includes(node)) {
4467
+ roots.push(node);
4468
+ }
4469
+ };
4470
+ const removeRoot = (node: SessionTreeNode): void => {
4471
+ const index = roots.indexOf(node);
4472
+ if (index !== -1) {
4473
+ roots.splice(index, 1);
4474
+ }
4475
+ };
4476
+ const wouldCreateChildCycle = (parent: SessionTreeNode, child: SessionTreeNode): boolean => {
4477
+ const stack: SessionTreeNode[] = [child];
4478
+ const visited = new Set<SessionTreeNode>();
4479
+ while (stack.length > 0) {
4480
+ const current = stack.pop()!;
4481
+ if (current === parent) {
4482
+ return true;
4483
+ }
4484
+ if (visited.has(current)) {
4485
+ continue;
4486
+ }
4487
+ visited.add(current);
4488
+ stack.push(...current.children);
4489
+ }
4490
+ return false;
4491
+ };
4492
+
4493
+ // Build tree. Corrupt session files can contain duplicate IDs or parentId
4494
+ // cycles; reject only the edge that would make the returned tree cyclic.
4422
4495
  for (const entry of entries) {
4423
4496
  const node = nodeMap.get(entry.id)!;
4424
4497
  if (entry.parentId === null || entry.parentId === entry.id) {
4425
- roots.push(node);
4498
+ addRoot(node);
4426
4499
  } else {
4427
4500
  const parent = nodeMap.get(entry.parentId);
4428
- if (parent) {
4501
+ if (parent && !wouldCreateChildCycle(parent, node)) {
4429
4502
  parent.children.push(node);
4503
+ removeRoot(node);
4430
4504
  } else {
4431
- // Orphan - treat as root
4432
- roots.push(node);
4505
+ // Orphan or cycle-closing edge - treat as root
4506
+ addRoot(node);
4433
4507
  }
4434
4508
  }
4435
4509
  }
@@ -4437,8 +4511,13 @@ export class SessionManager {
4437
4511
  // Sort children by timestamp (oldest first, newest at bottom)
4438
4512
  // Use iterative approach to avoid stack overflow on deep trees
4439
4513
  const stack: SessionTreeNode[] = [...roots];
4514
+ const sorted = new Set<SessionTreeNode>();
4440
4515
  while (stack.length > 0) {
4441
4516
  const node = stack.pop()!;
4517
+ if (sorted.has(node)) {
4518
+ continue;
4519
+ }
4520
+ sorted.add(node);
4442
4521
  node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
4443
4522
  stack.push(...node.children);
4444
4523
  }