@gajae-code/coding-agent 0.5.4 → 0.6.1

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.
Files changed (155) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/web-search-cli.d.ts +12 -0
  3. package/dist/types/commands/rlm.d.ts +10 -0
  4. package/dist/types/commands/web-search.d.ts +54 -0
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-profiles.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +3 -0
  8. package/dist/types/config/models-config-schema.d.ts +3 -0
  9. package/dist/types/config/settings-schema.d.ts +61 -3
  10. package/dist/types/edit/notebook.d.ts +3 -0
  11. package/dist/types/eval/py/executor.d.ts +3 -0
  12. package/dist/types/eval/py/kernel.d.ts +3 -1
  13. package/dist/types/eval/py/runtime.d.ts +9 -1
  14. package/dist/types/exec/bash-executor.d.ts +4 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  16. package/dist/types/extensibility/custom-tools/wrapper.d.ts +1 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  18. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -0
  19. package/dist/types/gjc-runtime/launch-tmux.d.ts +6 -0
  20. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +14 -0
  21. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  22. package/dist/types/gjc-runtime/tmux-gc.d.ts +3 -3
  23. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -0
  24. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +18 -0
  25. package/dist/types/goals/state.d.ts +1 -1
  26. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  27. package/dist/types/main.d.ts +11 -0
  28. package/dist/types/modes/components/custom-editor.d.ts +4 -2
  29. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +12 -0
  30. package/dist/types/modes/components/model-selector.d.ts +5 -2
  31. package/dist/types/modes/components/status-line.d.ts +4 -1
  32. package/dist/types/modes/controllers/input-controller.d.ts +3 -0
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  34. package/dist/types/modes/print-mode.d.ts +6 -0
  35. package/dist/types/modes/rpc/rpc-client.d.ts +21 -0
  36. package/dist/types/modes/rpc/rpc-socket-security.d.ts +7 -0
  37. package/dist/types/modes/rpc/rpc-types.d.ts +13 -0
  38. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +2 -0
  39. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +1 -0
  40. package/dist/types/rlm/artifacts.d.ts +9 -0
  41. package/dist/types/rlm/complete-research-tool.d.ts +35 -0
  42. package/dist/types/rlm/data-context.d.ts +6 -0
  43. package/dist/types/rlm/index.d.ts +35 -0
  44. package/dist/types/rlm/notebook.d.ts +12 -0
  45. package/dist/types/rlm/preset.d.ts +23 -0
  46. package/dist/types/rlm/python-tool.d.ts +16 -0
  47. package/dist/types/rlm/report.d.ts +14 -0
  48. package/dist/types/rlm/types.d.ts +37 -0
  49. package/dist/types/sdk.d.ts +7 -0
  50. package/dist/types/session/agent-session.d.ts +21 -0
  51. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -1
  52. package/dist/types/tools/browser/attach.d.ts +19 -3
  53. package/dist/types/tools/browser/registry.d.ts +15 -0
  54. package/dist/types/tools/browser/render.d.ts +3 -0
  55. package/dist/types/tools/browser.d.ts +18 -1
  56. package/dist/types/tools/computer/render.d.ts +17 -0
  57. package/dist/types/tools/computer.d.ts +465 -0
  58. package/dist/types/tools/index.d.ts +24 -1
  59. package/dist/types/tools/job.d.ts +13 -0
  60. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  61. package/dist/types/web/search/index.d.ts +32 -2
  62. package/dist/types/web/search/providers/base.d.ts +22 -0
  63. package/dist/types/web/search/providers/xai.d.ts +64 -0
  64. package/dist/types/web/search/types.d.ts +11 -3
  65. package/package.json +7 -7
  66. package/src/cli/web-search-cli.ts +123 -8
  67. package/src/cli.ts +2 -0
  68. package/src/commands/rlm.ts +19 -0
  69. package/src/commands/web-search.ts +66 -0
  70. package/src/config/keybindings.ts +11 -0
  71. package/src/config/model-profiles.ts +11 -3
  72. package/src/config/model-registry.ts +55 -1
  73. package/src/config/models-config-schema.ts +1 -0
  74. package/src/config/settings-schema.ts +67 -1
  75. package/src/edit/notebook.ts +6 -2
  76. package/src/eval/py/executor.ts +8 -1
  77. package/src/eval/py/kernel.ts +9 -4
  78. package/src/eval/py/runtime.ts +153 -32
  79. package/src/exec/bash-executor.ts +10 -4
  80. package/src/extensibility/custom-tools/types.ts +2 -0
  81. package/src/extensibility/custom-tools/wrapper.ts +2 -0
  82. package/src/extensibility/extensions/types.ts +2 -0
  83. package/src/extensibility/extensions/wrapper.ts +1 -0
  84. package/src/gjc-runtime/launch-tmux.ts +129 -1
  85. package/src/gjc-runtime/session-state-sidecar.ts +61 -1
  86. package/src/gjc-runtime/tmux-common.ts +26 -2
  87. package/src/gjc-runtime/tmux-gc.ts +40 -27
  88. package/src/gjc-runtime/tmux-sessions.ts +13 -1
  89. package/src/gjc-runtime/ultragoal-runtime.ts +340 -18
  90. package/src/goals/runtime.ts +4 -3
  91. package/src/goals/state.ts +1 -1
  92. package/src/goals/tools/goal-tool.ts +16 -3
  93. package/src/internal-urls/docs-index.generated.ts +13 -9
  94. package/src/main.ts +28 -3
  95. package/src/modes/components/custom-editor.ts +13 -4
  96. package/src/modes/components/custom-model-preset-wizard.ts +293 -0
  97. package/src/modes/components/hook-selector.ts +1 -1
  98. package/src/modes/components/model-selector.ts +72 -29
  99. package/src/modes/components/skill-message.ts +62 -8
  100. package/src/modes/components/status-line.ts +13 -1
  101. package/src/modes/controllers/input-controller.ts +60 -11
  102. package/src/modes/controllers/selector-controller.ts +39 -0
  103. package/src/modes/interactive-mode.ts +1 -1
  104. package/src/modes/print-mode.ts +14 -4
  105. package/src/modes/rpc/rpc-client.ts +250 -80
  106. package/src/modes/rpc/rpc-mode.ts +6 -12
  107. package/src/modes/rpc/rpc-socket-security.ts +103 -0
  108. package/src/modes/rpc/rpc-types.ts +10 -0
  109. package/src/modes/shared/agent-wire/command-dispatch.ts +7 -0
  110. package/src/modes/shared/agent-wire/command-validation.ts +1 -0
  111. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  112. package/src/modes/shared/agent-wire/unattended-session.ts +9 -0
  113. package/src/modes/utils/hotkeys-markdown.ts +4 -2
  114. package/src/modes/utils/ui-helpers.ts +2 -2
  115. package/src/prompts/goals/goal-continuation.md +1 -0
  116. package/src/prompts/goals/goal-mode-active.md +1 -0
  117. package/src/prompts/system/rlm-report-command.md +1 -0
  118. package/src/prompts/system/rlm-research.md +23 -0
  119. package/src/prompts/tools/bash.md +23 -2
  120. package/src/prompts/tools/browser.md +7 -3
  121. package/src/prompts/tools/computer.md +74 -0
  122. package/src/prompts/tools/goal.md +3 -0
  123. package/src/prompts/tools/job.md +9 -1
  124. package/src/prompts/tools/web-search.md +7 -0
  125. package/src/rlm/artifacts.ts +60 -0
  126. package/src/rlm/complete-research-tool.ts +163 -0
  127. package/src/rlm/data-context.ts +26 -0
  128. package/src/rlm/index.ts +339 -0
  129. package/src/rlm/notebook.ts +108 -0
  130. package/src/rlm/preset.ts +76 -0
  131. package/src/rlm/python-tool.ts +68 -0
  132. package/src/rlm/report.ts +70 -0
  133. package/src/rlm/types.ts +40 -0
  134. package/src/sdk.ts +12 -0
  135. package/src/session/agent-session.ts +48 -3
  136. package/src/slash-commands/builtin-registry.ts +17 -0
  137. package/src/tools/bash-allowed-prefixes.ts +84 -1
  138. package/src/tools/bash.ts +80 -13
  139. package/src/tools/browser/attach.ts +103 -3
  140. package/src/tools/browser/registry.ts +176 -2
  141. package/src/tools/browser/render.ts +9 -1
  142. package/src/tools/browser.ts +33 -0
  143. package/src/tools/computer/render.ts +78 -0
  144. package/src/tools/computer.ts +640 -0
  145. package/src/tools/index.ts +41 -1
  146. package/src/tools/job.ts +88 -5
  147. package/src/tools/json-tree.ts +42 -29
  148. package/src/tools/renderers.ts +2 -0
  149. package/src/tools/tool-timeouts.ts +1 -0
  150. package/src/web/search/index.ts +27 -2
  151. package/src/web/search/provider.ts +16 -1
  152. package/src/web/search/providers/base.ts +22 -0
  153. package/src/web/search/providers/xai.ts +511 -0
  154. package/src/web/search/render.ts +7 -0
  155. package/src/web/search/types.ts +11 -1
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import { safeStderrWrite } from "@gajae-code/utils";
3
3
  import type { Args } from "../cli/args";
4
+ import { GJC_COORDINATOR_SESSION_ID_ENV, GJC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
4
5
  import {
5
6
  buildGjcTmuxProfileCommands,
6
7
  buildGjcTmuxSessionName,
@@ -26,6 +27,7 @@ export {
26
27
 
27
28
  export const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
28
29
  export const GJC_LAUNCH_POLICY_ENV = "GJC_LAUNCH_POLICY";
30
+ export const GJC_TMUX_WINDOW_LABEL_MAX_WIDTH = 48;
29
31
 
30
32
  type LaunchPolicy = "direct" | "tmux";
31
33
 
@@ -77,6 +79,8 @@ export interface TmuxLaunchPlan {
77
79
  branch?: string | null;
78
80
  attachSessionName?: string;
79
81
  project?: string | null;
82
+ sessionId?: string | null;
83
+ sessionStateFile?: string | null;
80
84
  }
81
85
 
82
86
  export interface GjcTmuxProfileResult {
@@ -94,12 +98,15 @@ export interface GjcTmuxProfileContext {
94
98
  branch?: string | null;
95
99
  branchSlug?: string | null;
96
100
  project?: string | null;
101
+ sessionId?: string | null;
102
+ sessionStateFile?: string | null;
97
103
  }
98
104
 
99
105
  interface CommandResolutionContext {
100
106
  cwd: string;
101
107
  argv: string[];
102
108
  execPath: string;
109
+ extraEnv?: Record<string, string>;
103
110
  }
104
111
 
105
112
  function parseLaunchPolicy(env: NodeJS.ProcessEnv): LaunchPolicy {
@@ -137,6 +144,11 @@ function shellQuote(value: string): string {
137
144
  return `'${value.replace(/'/g, `'\\''`)}'`;
138
145
  }
139
146
 
147
+ function buildEnvAssignments(values: Record<string, string> | undefined): string {
148
+ const entries = Object.entries(values ?? {});
149
+ return entries.length === 0 ? "" : ` ${entries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")}`;
150
+ }
151
+
140
152
  export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProfileResult {
141
153
  const env = context.env ?? process.env;
142
154
  const branchSlug = context.branch ? buildGjcTmuxSessionSlug(context.branch) : (context.branchSlug ?? null);
@@ -144,6 +156,8 @@ export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProf
144
156
  branch: context.branch ?? null,
145
157
  branchSlug,
146
158
  project: context.project ?? null,
159
+ sessionId: context.sessionId ?? env[GJC_COORDINATOR_SESSION_ID_ENV] ?? null,
160
+ sessionStateFile: context.sessionStateFile ?? env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] ?? null,
147
161
  });
148
162
  if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
149
163
  const spawnSync = context.spawnSync ?? defaultSpawnSync;
@@ -173,7 +187,97 @@ function resolveCurrentGjcCommand(context: CommandResolutionContext): string[] {
173
187
  function buildInnerCommand(context: CommandResolutionContext, rawArgs: string[]): string {
174
188
  const command = resolveCurrentGjcCommand(context);
175
189
  const quoted = [...command, ...rawArgs].map(shellQuote).join(" ");
176
- return `exec env ${GJC_TMUX_LAUNCHED_ENV}=1 ${quoted}`;
190
+ return `exec env ${GJC_TMUX_LAUNCHED_ENV}=1${buildEnvAssignments(context.extraEnv)} ${quoted}`;
191
+ }
192
+
193
+ function visibleWidth(value: string): number {
194
+ return Bun.stringWidth(value);
195
+ }
196
+
197
+ function truncateVisible(value: string, maxWidth: number): string {
198
+ if (maxWidth <= 0) return "";
199
+ if (visibleWidth(value) <= maxWidth) return value;
200
+ if (maxWidth === 1) return "…";
201
+
202
+ let result = "";
203
+ for (const char of value) {
204
+ if (visibleWidth(`${result}${char}…`) > maxWidth) break;
205
+ result += char;
206
+ }
207
+
208
+ return `${result}…`;
209
+ }
210
+
211
+ function truncateVisibleTail(value: string, maxWidth: number): string {
212
+ if (maxWidth <= 0) return "";
213
+ if (visibleWidth(value) <= maxWidth) return value;
214
+ if (maxWidth === 1) return "…";
215
+
216
+ let result = "";
217
+ for (const char of Array.from(value).reverse()) {
218
+ if (visibleWidth(`…${char}${result}`) > maxWidth) break;
219
+ result = `${char}${result}`;
220
+ }
221
+
222
+ return `…${result}`;
223
+ }
224
+
225
+ export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
226
+ const project = path.basename(path.resolve(cwd)) || "gjc";
227
+ const trimmedBranch = branch?.trim();
228
+ if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
229
+
230
+ const separatorWidth = visibleWidth(":");
231
+ const projectWidth = visibleWidth(project);
232
+ const fullTitle = `${project}:${trimmedBranch}`;
233
+ if (visibleWidth(fullTitle) <= GJC_TMUX_WINDOW_LABEL_MAX_WIDTH) return fullTitle;
234
+
235
+ const remainingBranchWidth = GJC_TMUX_WINDOW_LABEL_MAX_WIDTH - projectWidth - separatorWidth;
236
+ if (remainingBranchWidth <= 0) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
237
+
238
+ return `${project}:${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
239
+ }
240
+
241
+ function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
242
+ return target ? ["rename-window", "-t", target, "--", title] : ["rename-window", "--", title];
243
+ }
244
+
245
+ function renameTmuxWindow(
246
+ tmuxCommand: string,
247
+ title: string,
248
+ spawnSync: TmuxSpawnSync,
249
+ options: TmuxSpawnOptions,
250
+ target?: string,
251
+ ): void {
252
+ spawnSync(tmuxCommand, buildTmuxRenameWindowArgs(title, target), options);
253
+ }
254
+
255
+ function renameExistingTmuxWindowIfNeeded(context: TmuxLaunchContext): void {
256
+ const env = context.env ?? process.env;
257
+ if (!env.TMUX || env[GJC_TMUX_LAUNCHED_ENV] === "1") return;
258
+ if (parseLaunchPolicy(env) === "direct") return;
259
+
260
+ const platform = context.platform ?? process.platform;
261
+ if (platform === "win32") return;
262
+
263
+ const tty = context.tty ?? { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY) };
264
+ if (!isInteractiveRootLaunch(context.parsed, tty)) return;
265
+
266
+ const tmuxCommand = resolveGjcTmuxCommand(env);
267
+ const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
268
+ if (!tmuxAvailable) return;
269
+
270
+ const cwd = context.cwd ?? process.cwd();
271
+ const branch = context.worktreeBranch ?? context.currentBranch ?? readCurrentBranch(cwd);
272
+ const title = buildGjcTmuxWindowTitle(context.project ?? cwd, branch);
273
+ const spawnSync = context.spawnSync ?? defaultSpawnSync;
274
+ renameTmuxWindow(tmuxCommand, title, spawnSync, {
275
+ cwd,
276
+ env,
277
+ stdin: "inherit",
278
+ stdout: "inherit",
279
+ stderr: "inherit",
280
+ });
177
281
  }
178
282
 
179
283
  function readCurrentBranch(cwd: string): string | null {
@@ -210,6 +314,10 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
210
314
  const project = context.project ?? cwd;
211
315
  const sessionName = buildGjcTmuxSessionName(env, { branch });
212
316
  const tmuxCommand = resolveGjcTmuxCommand(env);
317
+ const sessionId = env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || sessionName;
318
+ const sessionStateFile =
319
+ env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim() ||
320
+ path.join(cwd, ".gjc", "runtime", "tmux-sessions", `${buildGjcTmuxSessionSlug(sessionName)}.json`);
213
321
  const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
214
322
  if (!tmuxAvailable) return undefined;
215
323
  const existingBranchSessionName =
@@ -223,6 +331,10 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
223
331
  cwd,
224
332
  argv: context.argv ?? process.argv,
225
333
  execPath: context.execPath ?? process.execPath,
334
+ extraEnv: {
335
+ [GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
336
+ [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
337
+ },
226
338
  },
227
339
  context.rawArgs,
228
340
  );
@@ -234,6 +346,8 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
234
346
  newSessionArgs: ["new-session", "-d", "-s", sessionName, "-c", cwd, innerCommand],
235
347
  branch,
236
348
  project,
349
+ sessionId,
350
+ sessionStateFile,
237
351
  attachSessionName: existingBranchSessionName,
238
352
  };
239
353
  }
@@ -251,6 +365,8 @@ function defaultSpawnSync(command: string, args: string[], options: TmuxSpawnOpt
251
365
  }
252
366
 
253
367
  export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
368
+ renameExistingTmuxWindowIfNeeded(context);
369
+
254
370
  const plan = buildDefaultTmuxLaunchPlan(context);
255
371
  if (!plan) return false;
256
372
  const env = context.env ?? process.env;
@@ -262,12 +378,22 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
262
378
  stdout: "inherit",
263
379
  stderr: "inherit",
264
380
  };
381
+
265
382
  if (plan.attachSessionName) {
266
383
  const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.attachSessionName}`], options);
267
384
  return attached.exitCode === 0;
268
385
  }
386
+
269
387
  const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
270
388
  if (created.exitCode === 0) {
389
+ renameTmuxWindow(
390
+ plan.tmuxCommand,
391
+ buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
392
+ spawnSync,
393
+ options,
394
+ `=${plan.sessionName}`,
395
+ );
396
+
271
397
  const profile = applyGjcTmuxProfile({
272
398
  tmuxCommand: plan.tmuxCommand,
273
399
  target: plan.sessionName,
@@ -276,6 +402,8 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
276
402
  spawnSync,
277
403
  branch: plan.branch,
278
404
  project: plan.project,
405
+ sessionId: plan.sessionId ?? null,
406
+ sessionStateFile: plan.sessionStateFile ?? null,
279
407
  });
280
408
  if (profile.failures.length > 0) {
281
409
  cleanupCreatedTmuxSession(plan, spawnSync, options);
@@ -6,7 +6,7 @@ import { logger } from "@gajae-code/utils";
6
6
  export const GJC_COORDINATOR_SESSION_STATE_FILE_ENV = "GJC_COORDINATOR_SESSION_STATE_FILE";
7
7
  export const GJC_COORDINATOR_SESSION_ID_ENV = "GJC_COORDINATOR_SESSION_ID";
8
8
 
9
- type RuntimeState = "ready_for_input" | "running" | "needs_user_input" | "completed" | "errored";
9
+ export type RuntimeState = "ready_for_input" | "running" | "needs_user_input" | "completed" | "errored";
10
10
 
11
11
  interface RuntimeStateEvent {
12
12
  type: string;
@@ -19,6 +19,66 @@ interface RuntimeStateContext {
19
19
  sessionFile?: string | null;
20
20
  }
21
21
 
22
+ interface RuntimeStateSidecarPayload {
23
+ schema_version?: unknown;
24
+ session_id?: unknown;
25
+ state?: unknown;
26
+ ready_for_input?: unknown;
27
+ cwd?: unknown;
28
+ session_file?: unknown;
29
+ }
30
+
31
+ export type TerminalRuntimeStateStatus =
32
+ | { terminal: true; state: "completed" | "errored" }
33
+ | {
34
+ terminal: false;
35
+ reason:
36
+ | "missing_state_file"
37
+ | "invalid_json"
38
+ | "session_id_mismatch"
39
+ | "cwd_mismatch"
40
+ | "session_file_mismatch"
41
+ | "non_terminal_state";
42
+ };
43
+
44
+ function sameResolvedPath(left: string, right: string): boolean {
45
+ return path.resolve(left) === path.resolve(right);
46
+ }
47
+
48
+ export async function readTerminalRuntimeStateMarker(input: {
49
+ stateFile?: string | null;
50
+ sessionId?: string | null;
51
+ cwd?: string | null;
52
+ sessionFile?: string | null;
53
+ }): Promise<TerminalRuntimeStateStatus> {
54
+ const stateFile = input.stateFile?.trim();
55
+ const sessionId = input.sessionId?.trim();
56
+ if (!stateFile || !sessionId) return { terminal: false, reason: "missing_state_file" };
57
+ let payload: RuntimeStateSidecarPayload;
58
+ try {
59
+ payload = JSON.parse(await Bun.file(stateFile).text()) as RuntimeStateSidecarPayload;
60
+ } catch (error) {
61
+ const code = (error as { code?: unknown }).code;
62
+ return {
63
+ terminal: false,
64
+ reason: code === "ENOENT" || code === "ENOTDIR" ? "missing_state_file" : "invalid_json",
65
+ };
66
+ }
67
+ if (payload.session_id !== sessionId) return { terminal: false, reason: "session_id_mismatch" };
68
+ if (input.cwd && typeof payload.cwd === "string" && !sameResolvedPath(payload.cwd, input.cwd)) {
69
+ return { terminal: false, reason: "cwd_mismatch" };
70
+ }
71
+ if (
72
+ input.sessionFile &&
73
+ typeof payload.session_file === "string" &&
74
+ !sameResolvedPath(payload.session_file, input.sessionFile)
75
+ ) {
76
+ return { terminal: false, reason: "session_file_mismatch" };
77
+ }
78
+ if (payload.state === "completed" || payload.state === "errored") return { terminal: true, state: payload.state };
79
+ return { terminal: false, reason: "non_terminal_state" };
80
+ }
81
+
22
82
  function lastAssistant(messages: unknown[] | undefined): AssistantMessage | undefined {
23
83
  if (!messages) return undefined;
24
84
  for (let index = messages.length - 1; index >= 0; index--) {
@@ -8,6 +8,8 @@ export const GJC_TMUX_PROFILE_VALUE = "1";
8
8
  export const GJC_TMUX_BRANCH_OPTION = "@gjc-branch";
9
9
  export const GJC_TMUX_BRANCH_SLUG_OPTION = "@gjc-branch-slug";
10
10
  export const GJC_TMUX_PROJECT_OPTION = "@gjc-project";
11
+ export const GJC_TMUX_SESSION_ID_OPTION = "@gjc-session-id";
12
+ export const GJC_TMUX_SESSION_STATE_FILE_OPTION = "@gjc-session-state-file";
11
13
 
12
14
  export interface GjcTmuxProfileCommand {
13
15
  description: string;
@@ -93,7 +95,13 @@ export function buildGjcTmuxSessionName(
93
95
 
94
96
  export function buildGjcTmuxRequiredProfileCommands(
95
97
  target: string,
96
- metadata: { branch?: string | null; branchSlug?: string | null; project?: string | null } = {},
98
+ metadata: {
99
+ branch?: string | null;
100
+ branchSlug?: string | null;
101
+ project?: string | null;
102
+ sessionId?: string | null;
103
+ sessionStateFile?: string | null;
104
+ } = {},
97
105
  ): GjcTmuxProfileCommand[] {
98
106
  const commands: GjcTmuxProfileCommand[] = [
99
107
  {
@@ -116,13 +124,29 @@ export function buildGjcTmuxRequiredProfileCommands(
116
124
  description: "record GJC project identity",
117
125
  args: ["set-option", "-t", target, GJC_TMUX_PROJECT_OPTION, metadata.project],
118
126
  });
127
+ if (metadata.sessionId)
128
+ commands.push({
129
+ description: "record GJC session identity",
130
+ args: ["set-option", "-t", target, GJC_TMUX_SESSION_ID_OPTION, metadata.sessionId],
131
+ });
132
+ if (metadata.sessionStateFile)
133
+ commands.push({
134
+ description: "record GJC session state marker",
135
+ args: ["set-option", "-t", target, GJC_TMUX_SESSION_STATE_FILE_OPTION, metadata.sessionStateFile],
136
+ });
119
137
  return commands;
120
138
  }
121
139
 
122
140
  export function buildGjcTmuxProfileCommands(
123
141
  target: string,
124
142
  env: NodeJS.ProcessEnv = process.env,
125
- metadata: { branch?: string | null; branchSlug?: string | null; project?: string | null } = {},
143
+ metadata: {
144
+ branch?: string | null;
145
+ branchSlug?: string | null;
146
+ project?: string | null;
147
+ sessionId?: string | null;
148
+ sessionStateFile?: string | null;
149
+ } = {},
126
150
  ): GjcTmuxProfileCommand[] {
127
151
  const commands = buildGjcTmuxRequiredProfileCommands(target, metadata);
128
152
  if (envDisabled(env[GJC_TMUX_PROFILE_ENV])) return commands;
@@ -1,13 +1,14 @@
1
1
  /**
2
- * GC adapter for gjc-tagged tmux sessions. Stale iff `@gjc-project` path is gone
3
- * OR `@gjc-branch` has no live git worktree. Removal is a spec-authorized
4
- * destructive `kill-session`, gated by exact-target re-read + revalidation.
2
+ * GC adapter for gjc-tagged tmux sessions. Destructive cleanup is authorized
3
+ * only for detached pane-less sessions whose exact runtime marker revalidates a
4
+ * terminal state. Project/branch/orphan heuristics are discovery signals only.
5
5
  */
6
6
 
7
7
  import * as fs from "node:fs";
8
8
 
9
- import { worktree } from "../utils/git";
9
+ import { GitCommandError, worktree } from "../utils/git";
10
10
  import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
11
+ import { readTerminalRuntimeStateMarker } from "./session-state-sidecar";
11
12
  import { GJC_TMUX_PROFILE_VALUE, GJC_TMUX_SESSION_PREFIX } from "./tmux-common";
12
13
  import {
13
14
  type GjcTmuxSessionStatus,
@@ -60,10 +61,18 @@ function branchMatches(candidate: string | undefined, branch: string): boolean {
60
61
  ]);
61
62
  return branchNames.has(candidate);
62
63
  }
64
+ function isNotGitRepositoryError(error: unknown): boolean {
65
+ return error instanceof GitCommandError && /not a git repository/i.test(error.message);
66
+ }
63
67
 
64
68
  async function hasLiveWorktreeForBranch(project: string, branch: string): Promise<boolean> {
65
- const entries = await worktree.list(project);
66
- return entries.some(entry => branchMatches(entry.branch, branch));
69
+ try {
70
+ const entries = await worktree.list(project);
71
+ return entries.some(entry => branchMatches(entry.branch, branch));
72
+ } catch (error) {
73
+ if (isNotGitRepositoryError(error)) return false;
74
+ throw error;
75
+ }
67
76
  }
68
77
 
69
78
  function isSessionLive(session: Pick<GjcTmuxSessionStatus, "attached" | "panePids">): boolean {
@@ -102,6 +111,19 @@ function staleRecord(session: GjcTmuxSessionStatus, reason: string): GcRecord {
102
111
  };
103
112
  }
104
113
 
114
+ async function hasTerminalRuntimeMarker(input: {
115
+ sessionId?: string | null;
116
+ sessionStateFile?: string | null;
117
+ project?: string | null;
118
+ }): Promise<boolean> {
119
+ const marker = await readTerminalRuntimeStateMarker({
120
+ stateFile: input.sessionStateFile,
121
+ sessionId: input.sessionId,
122
+ cwd: input.project,
123
+ });
124
+ return marker.terminal;
125
+ }
126
+
105
127
  function isOldEnoughForOrphanGc(session: GjcTmuxSessionStatus): boolean {
106
128
  const createdAt = Date.parse(session.createdAt);
107
129
  return Number.isFinite(createdAt) && Date.now() - createdAt >= ORPHAN_MAX_AGE_MS;
@@ -114,14 +136,19 @@ function isGjcOwnedOrphan(session: GjcTmuxSessionStatus): boolean {
114
136
  async function classifyTaggedSession(session: GjcTmuxSessionStatus): Promise<GcRecord> {
115
137
  const { name, project, branch } = session;
116
138
  if (isSessionLive(session)) return liveRecord(session, "tmux_session_attached_or_has_live_panes");
139
+ if (await hasTerminalRuntimeMarker(session))
140
+ return staleRecord(session, "terminal_runtime_marker_detached_idle_session");
117
141
  if (!project || !branch) {
118
- if (isGjcOwnedOrphan(session) && isOldEnoughForOrphanGc(session)) {
119
- return staleRecord(session, "metadata_less_gjc_owned_idle_orphan");
120
- }
121
- return unclassifiedRecord(name, "missing_project_or_branch_tag", project, branch);
142
+ const reason =
143
+ isGjcOwnedOrphan(session) && isOldEnoughForOrphanGc(session)
144
+ ? "metadata_less_gjc_owned_idle_orphan_missing_terminal_marker"
145
+ : "missing_project_or_branch_tag";
146
+ return unclassifiedRecord(name, reason, project, branch);
122
147
  }
123
- if (!pathExists(project)) return staleRecord(session, "project_missing");
124
- if (!(await hasLiveWorktreeForBranch(project, branch))) return staleRecord(session, "branch_no_worktree");
148
+ if (!pathExists(project))
149
+ return unclassifiedRecord(name, "project_missing_without_terminal_marker", project, branch);
150
+ if (!(await hasLiveWorktreeForBranch(project, branch)))
151
+ return unclassifiedRecord(name, "branch_no_worktree_without_terminal_marker", project, branch);
125
152
  return {
126
153
  store: STORE,
127
154
  id: name,
@@ -152,21 +179,7 @@ async function revalidateRemovable(record: GcRecord, env: NodeJS.ProcessEnv): Pr
152
179
  }
153
180
  if (tags.attached || (tags.panePids?.length ?? 0) > 0) return false;
154
181
  if (tags.profile !== GJC_TMUX_PROFILE_VALUE) return false;
155
- if (!tags.project || !tags.branch)
156
- return (
157
- record.reason === "metadata_less_gjc_owned_idle_orphan" &&
158
- isGjcOwnedOrphan({
159
- name: record.id,
160
- attached: false,
161
- windows: 0,
162
- panes: 0,
163
- panePids: [],
164
- bindings: "",
165
- createdAt: tags.createdAt ?? "",
166
- })
167
- );
168
- if (!pathExists(tags.project)) return true;
169
- return !(await hasLiveWorktreeForBranch(tags.project, tags.branch));
182
+ return await hasTerminalRuntimeMarker(tags);
170
183
  }
171
184
 
172
185
  export const tmuxSessionsGcAdapter: GcStoreAdapter = {
@@ -8,6 +8,8 @@ import {
8
8
  GJC_TMUX_PROFILE_OPTION,
9
9
  GJC_TMUX_PROFILE_VALUE,
10
10
  GJC_TMUX_PROJECT_OPTION,
11
+ GJC_TMUX_SESSION_ID_OPTION,
12
+ GJC_TMUX_SESSION_STATE_FILE_OPTION,
11
13
  normalizeTmuxCreatedAt,
12
14
  resolveGjcTmuxCommand,
13
15
  } from "./tmux-common";
@@ -22,6 +24,8 @@ export interface GjcTmuxSessionStatus {
22
24
  branch?: string;
23
25
  branchSlug?: string;
24
26
  project?: string;
27
+ sessionId?: string;
28
+ sessionStateFile?: string;
25
29
  panePids: number[];
26
30
  profile?: string;
27
31
  }
@@ -31,6 +35,8 @@ export interface GjcTmuxSessionTagsForGc {
31
35
  project?: string;
32
36
  branch?: string;
33
37
  branchSlug?: string;
38
+ sessionId?: string;
39
+ sessionStateFile?: string;
34
40
  createdAt?: string;
35
41
  attached?: boolean;
36
42
  panePids?: number[];
@@ -78,6 +84,8 @@ function parseSessionLine(line: string): GjcTmuxSessionStatus | null {
78
84
  branch = "",
79
85
  branchSlug = "",
80
86
  project = "",
87
+ sessionId = "",
88
+ sessionStateFile = "",
81
89
  ] = line.split("\t");
82
90
  if (!name) return null;
83
91
  return {
@@ -95,6 +103,8 @@ function parseSessionLine(line: string): GjcTmuxSessionStatus | null {
95
103
  branchSlug: branchSlug || undefined,
96
104
  project: project || undefined,
97
105
  profile: profile || undefined,
106
+ sessionId: sessionId || undefined,
107
+ sessionStateFile: sessionStateFile || undefined,
98
108
  };
99
109
  }
100
110
 
@@ -115,7 +125,7 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
115
125
 
116
126
  function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
117
127
  return runListSessions(
118
- `#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}\t#{${GJC_TMUX_PROFILE_OPTION}}\t#{session_key_table}\t#{session_panes}\t#{pane_pid}\t#{${GJC_TMUX_BRANCH_OPTION}}\t#{${GJC_TMUX_BRANCH_SLUG_OPTION}}\t#{${GJC_TMUX_PROJECT_OPTION}}`,
128
+ `#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}\t#{${GJC_TMUX_PROFILE_OPTION}}\t#{session_key_table}\t#{session_panes}\t#{pane_pid}\t#{${GJC_TMUX_BRANCH_OPTION}}\t#{${GJC_TMUX_BRANCH_SLUG_OPTION}}\t#{${GJC_TMUX_PROJECT_OPTION}}\t#{${GJC_TMUX_SESSION_ID_OPTION}}\t#{${GJC_TMUX_SESSION_STATE_FILE_OPTION}}`,
119
129
  env,
120
130
  );
121
131
  }
@@ -228,6 +238,8 @@ export function readTmuxSessionTagsForGc(
228
238
  project: readExactOptionForGc(sessionName, GJC_TMUX_PROJECT_OPTION, env),
229
239
  branch: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_OPTION, env),
230
240
  branchSlug: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_SLUG_OPTION, env),
241
+ sessionId: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_ID_OPTION, env),
242
+ sessionStateFile: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_STATE_FILE_OPTION, env),
231
243
  createdAt: session?.createdAt,
232
244
  attached: session?.attached,
233
245
  panePids: session?.panePids,