@gajae-code/coding-agent 0.5.0 → 0.5.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 (125) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/async/job-manager.d.ts +26 -0
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/list-models.d.ts +6 -0
  5. package/dist/types/commands/gc.d.ts +26 -0
  6. package/dist/types/config/file-lock-gc.d.ts +5 -0
  7. package/dist/types/config/file-lock.d.ts +7 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  10. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  11. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  12. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  13. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  14. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  15. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  19. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  22. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  23. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  25. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  26. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  27. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  28. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  29. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  30. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
  31. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  32. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  33. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  34. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  35. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  36. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  37. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  38. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  40. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  41. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  42. package/dist/types/session/agent-session.d.ts +1 -1
  43. package/dist/types/session/blob-store.d.ts +39 -3
  44. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  45. package/dist/types/tools/ask.d.ts +15 -1
  46. package/dist/types/tools/subagent.d.ts +6 -0
  47. package/package.json +7 -7
  48. package/src/async/job-manager.ts +52 -0
  49. package/src/cli/args.ts +3 -0
  50. package/src/cli/auth-broker-cli.ts +1 -0
  51. package/src/cli/list-models.ts +13 -1
  52. package/src/cli.ts +1 -0
  53. package/src/commands/gc.ts +22 -0
  54. package/src/commands/harness.ts +7 -3
  55. package/src/config/file-lock-gc.ts +181 -0
  56. package/src/config/file-lock.ts +14 -0
  57. package/src/config/model-profiles.ts +24 -15
  58. package/src/coordinator/contract.ts +1 -0
  59. package/src/coordinator-mcp/server.ts +459 -3
  60. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  61. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  62. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  63. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  66. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  67. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  68. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  69. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  70. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  71. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  72. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  75. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  76. package/src/defaults/gjc-defaults.ts +7 -0
  77. package/src/defaults/gjc-grok-cli.ts +22 -0
  78. package/src/extensibility/extensions/index.ts +1 -0
  79. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  80. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  81. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  82. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  83. package/src/gjc-runtime/gc-render.ts +70 -0
  84. package/src/gjc-runtime/gc-runtime.ts +403 -0
  85. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  86. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  87. package/src/gjc-runtime/state-renderer.ts +12 -3
  88. package/src/gjc-runtime/state-runtime.ts +46 -29
  89. package/src/gjc-runtime/team-gc.ts +49 -0
  90. package/src/gjc-runtime/team-runtime.ts +179 -2
  91. package/src/gjc-runtime/tmux-common.ts +14 -0
  92. package/src/gjc-runtime/tmux-gc.ts +176 -0
  93. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  94. package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
  95. package/src/harness-control-plane/gc-adapter.ts +184 -0
  96. package/src/harness-control-plane/owner.ts +11 -0
  97. package/src/harness-control-plane/storage.ts +70 -0
  98. package/src/internal-urls/docs-index.generated.ts +14 -8
  99. package/src/main.ts +7 -2
  100. package/src/modes/components/hook-selector.ts +19 -0
  101. package/src/modes/components/model-selector.ts +25 -8
  102. package/src/modes/components/status-line/segments.ts +1 -1
  103. package/src/modes/controllers/command-controller.ts +25 -6
  104. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  105. package/src/modes/controllers/selector-controller.ts +1 -0
  106. package/src/modes/rpc/rpc-mode.ts +151 -33
  107. package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
  108. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  109. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  110. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  111. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  112. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  113. package/src/sdk.ts +17 -3
  114. package/src/session/agent-session.ts +77 -8
  115. package/src/session/blob-store.ts +59 -3
  116. package/src/session/session-manager.ts +4 -4
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  118. package/src/skill-state/workflow-hud.ts +106 -10
  119. package/src/slash-commands/builtin-registry.ts +3 -2
  120. package/src/task/executor.ts +9 -0
  121. package/src/tools/ask.ts +56 -1
  122. package/src/tools/job.ts +3 -2
  123. package/src/tools/monitor.ts +36 -1
  124. package/src/tools/subagent-render.ts +9 -0
  125. package/src/tools/subagent.ts +26 -2
@@ -11,10 +11,10 @@ import {
11
11
  } from "../skill-state/active-state";
12
12
  import { initialPhaseForSkill } from "../skill-state/initial-phase";
13
13
  import {
14
- buildDeepInterviewHudSummary,
15
14
  buildRalplanHudSummary,
16
15
  buildTeamHudSummary,
17
16
  buildUltragoalHudSummary,
17
+ deriveDeepInterviewHud,
18
18
  } from "../skill-state/workflow-hud";
19
19
  import {
20
20
  type AuditEntry,
@@ -26,6 +26,7 @@ import {
26
26
  type WorkflowStateReceipt,
27
27
  } from "../skill-state/workflow-state-contract";
28
28
  import { renderCliWriteReceipt } from "./cli-write-receipt";
29
+ import { mergeDeepInterviewEnvelope, normalizeDeepInterviewEnvelope } from "./deep-interview-state";
29
30
  import { renderStateGraph, type StateGraphFormat } from "./state-graph";
30
31
  import { migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
31
32
  import {
@@ -833,31 +834,9 @@ function buildHudForMode(
833
834
  ): WorkflowHudSummary | undefined {
834
835
  const updatedAt = new Date().toISOString();
835
836
  const phase = typeof payload.current_phase === "string" ? payload.current_phase : undefined;
836
- const stateField = isPlainObject(payload.state) ? (payload.state as Record<string, unknown>) : {};
837
837
  switch (mode) {
838
- case "deep-interview": {
839
- const pick = <T>(key: string, guard: (value: unknown) => value is T): T | undefined => {
840
- const v = (stateField as Record<string, unknown>)[key] ?? (payload as Record<string, unknown>)[key];
841
- return guard(v) ? v : undefined;
842
- };
843
- const isNumber = (v: unknown): v is number => typeof v === "number";
844
- const isString = (v: unknown): v is string => typeof v === "string";
845
- const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
846
- const ambiguity = pick("current_ambiguity", isNumber);
847
- const threshold = pick("threshold", isNumber);
848
- const rounds = pick("rounds", isArray);
849
- const targetComponent = pick("last_targeted_component_id", isString);
850
- const weakestDimension = pick("weakest_dimension", isString);
851
- return buildDeepInterviewHudSummary({
852
- phase,
853
- ambiguity,
854
- threshold,
855
- roundCount: rounds?.length,
856
- targetComponent,
857
- weakestDimension,
858
- updatedAt,
859
- });
860
- }
838
+ case "deep-interview":
839
+ return deriveDeepInterviewHud(payload, { updatedAt });
861
840
  case "ralplan": {
862
841
  const stage =
863
842
  typeof payload.current_phase === "string"
@@ -888,6 +867,24 @@ function buildHudForMode(
888
867
  counts[status] = (counts[status] ?? 0) + 1;
889
868
  }
890
869
  const currentGoalRaw = goals.find(g => g.status === "active") ?? goals.find(g => g.status === "pending");
870
+ const rawLedger = payload.latestLedgerEvent;
871
+ const latestLedgerEvent =
872
+ rawLedger && typeof rawLedger === "object" && !Array.isArray(rawLedger)
873
+ ? {
874
+ event:
875
+ typeof (rawLedger as Record<string, unknown>).event === "string"
876
+ ? ((rawLedger as Record<string, unknown>).event as string)
877
+ : undefined,
878
+ goalId:
879
+ typeof (rawLedger as Record<string, unknown>).goalId === "string"
880
+ ? ((rawLedger as Record<string, unknown>).goalId as string)
881
+ : undefined,
882
+ timestamp:
883
+ typeof (rawLedger as Record<string, unknown>).timestamp === "string"
884
+ ? ((rawLedger as Record<string, unknown>).timestamp as string)
885
+ : undefined,
886
+ }
887
+ : undefined;
891
888
  const status = typeof payload.status === "string" ? (payload.status as string) : (phase ?? "pending");
892
889
  return buildUltragoalHudSummary({
893
890
  status,
@@ -900,6 +897,7 @@ function buildHudForMode(
900
897
  : undefined,
901
898
  counts,
902
899
  goals: goals.map(g => ({ id: g.id as string, title: g.title as string, status: g.status as string })),
900
+ latestLedgerEvent,
903
901
  updatedAt,
904
902
  });
905
903
  }
@@ -1009,7 +1007,10 @@ export async function reconcileWorkflowSkillState(options: {
1009
1007
  receipt.from_phase = fromPhase;
1010
1008
  receipt.to_phase = trimmedPhase;
1011
1009
 
1012
- const merged = mergeWithNullDelete(existingPayload, payload);
1010
+ const merged =
1011
+ mode === "deep-interview"
1012
+ ? (mergeDeepInterviewEnvelope(existingPayload, payload) as Record<string, unknown>)
1013
+ : mergeWithNullDelete(existingPayload, payload);
1013
1014
  merged.skill = mode;
1014
1015
  merged.current_phase = trimmedPhase;
1015
1016
  merged.active = active;
@@ -1173,7 +1174,11 @@ async function handleWrite(
1173
1174
  ? (innerState.current_phase as string).trim()
1174
1175
  : undefined;
1175
1176
  let merged: Record<string, unknown>;
1176
- if (hasFlag(args, "--replace")) {
1177
+ if (mode === "deep-interview") {
1178
+ // Deep-interview keeps interview data nested under `state` and merges rounds
1179
+ // losslessly by durable key; never flatten or delete `state` (that drops recorder history).
1180
+ merged = mergeDeepInterviewEnvelope(existingPayload, payload, { replace: hasFlag(args, "--replace") });
1181
+ } else if (hasFlag(args, "--replace")) {
1177
1182
  merged = { ...payload };
1178
1183
  } else {
1179
1184
  merged = mergeWithNullDelete(existingPayload, payload);
@@ -1423,8 +1428,20 @@ async function handleHandoff(
1423
1428
  });
1424
1429
 
1425
1430
  const calleeInitial = initialPhaseForSkill(callee);
1426
- const normalizedCaller = migrateWorkflowState(existingCaller, caller).state;
1427
- const normalizedCallee = migrateWorkflowState(existingCallee, callee).state;
1431
+ const normalizedCaller =
1432
+ caller === "deep-interview"
1433
+ ? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCaller, caller).state) as Record<
1434
+ string,
1435
+ unknown
1436
+ >)
1437
+ : migrateWorkflowState(existingCaller, caller).state;
1438
+ const normalizedCallee =
1439
+ callee === "deep-interview"
1440
+ ? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCallee, callee).state) as Record<
1441
+ string,
1442
+ unknown
1443
+ >)
1444
+ : migrateWorkflowState(existingCallee, callee).state;
1428
1445
  const mergedCalleeState: Record<string, unknown> = {
1429
1446
  ...normalizedCallee,
1430
1447
  skill: callee,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GC adapter for team workers (`.gjc/state/team/<name>/workers/<id>/` heartbeat
3
+ * + lifecycle). Liveness-only: numeric PID status dominates lifecycle/heartbeat
4
+ * signals.
5
+ */
6
+
7
+ import * as path from "node:path";
8
+ import { listHarnessRootRegistriesForGc } from "../harness-control-plane/storage";
9
+ import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
10
+ import { listTeamWorkerGcRecords, pruneTeamWorkerGcRecord } from "./team-runtime";
11
+
12
+ function uniqueTeamRootsFromHarnessRoots(roots: string[]): string[] {
13
+ return [...new Set(roots.map(root => path.join(path.dirname(root), "team")))].sort();
14
+ }
15
+
16
+ export const teamWorkersGcAdapter: GcStoreAdapter = {
17
+ store: "team_workers",
18
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
19
+ const records: GcRecord[] = [];
20
+ const errors: GcCollectResult["errors"] = [];
21
+ const registries = await listHarnessRootRegistriesForGc(ctx.env);
22
+ for (const registry of registries) {
23
+ if (registry.error) errors.push({ store: "team_workers", scope: registry.file, message: registry.error });
24
+ }
25
+
26
+ const teamRoots = uniqueTeamRootsFromHarnessRoots(
27
+ registries.flatMap(registry => registry.roots.map(entry => entry.root)),
28
+ );
29
+ for (const teamRoot of teamRoots) {
30
+ try {
31
+ records.push(...(await listTeamWorkerGcRecords(teamRoot, ctx.probe)));
32
+ } catch (error) {
33
+ const code = (error as NodeJS.ErrnoException).code;
34
+ if (code === "ENOENT") continue;
35
+ errors.push({ store: "team_workers", scope: teamRoot, message: (error as Error).message });
36
+ }
37
+ }
38
+
39
+ return { records, errors };
40
+ },
41
+ async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
42
+ try {
43
+ const removed = await pruneTeamWorkerGcRecord(record, ctx.probe);
44
+ return removed ? { removed: true } : { removed: false, skipped: "worker_no_longer_dead" };
45
+ } catch (error) {
46
+ return { removed: false, error: (error as Error).message };
47
+ }
48
+ },
49
+ };
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import type { WorkflowHudSummary } from "../skill-state/active-state";
5
5
  import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
6
6
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
7
+ import type { GcPidProbe, GcRecord } from "./gc-runtime";
7
8
 
8
9
  import { applyGjcTmuxProfile, GJC_TMUX_LAUNCHED_ENV } from "./launch-tmux";
9
10
  import {
@@ -18,6 +19,7 @@ import {
18
19
  writeWorkflowEnvelopeAtomic,
19
20
  } from "./state-writer";
20
21
  import {
22
+ buildGjcTmuxExactOptionTarget,
21
23
  buildGjcTmuxUntaggedSessionHint,
22
24
  GJC_TMUX_PROFILE_OPTION,
23
25
  GJC_TMUX_PROFILE_VALUE,
@@ -677,6 +679,174 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
677
679
  throw error;
678
680
  }
679
681
  }
682
+ function isPositivePid(value: unknown): value is number {
683
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
684
+ }
685
+
686
+ function collectTeamGcWorkerPids(
687
+ heartbeat: WorkerHeartbeatFile | null,
688
+ lifecycle: GjcTeamWorkerLifecycle | null,
689
+ ): number[] {
690
+ const pids: number[] = [];
691
+ if (isPositivePid(heartbeat?.pid)) pids.push(heartbeat.pid);
692
+ if (isPositivePid(lifecycle?.pid) && !pids.includes(lifecycle.pid)) pids.push(lifecycle.pid);
693
+ return pids;
694
+ }
695
+
696
+ interface TeamGcPidClassification {
697
+ removable: boolean;
698
+ pidStatus: "dead" | "alive" | "eperm" | "unknown" | "none";
699
+ pid?: number;
700
+ }
701
+
702
+ /**
703
+ * Liveness-only, fail-closed: a worker is removable ONLY when it has at least
704
+ * one authoritative pid and EVERY candidate pid probes dead (ESRCH). Any alive,
705
+ * EPERM, or unknown candidate (heartbeat OR lifecycle) keeps the worker, so a
706
+ * dead heartbeat pid can never override a live lifecycle pid.
707
+ */
708
+ function classifyTeamGcWorkerPids(pids: number[], probe: GcPidProbe): TeamGcPidClassification {
709
+ if (pids.length === 0) return { removable: false, pidStatus: "none" };
710
+ const statuses = pids.map(pid => ({ pid, status: gcProbeStatus(probe, pid) }));
711
+ const kept = statuses.find(entry => entry.status !== "dead");
712
+ if (kept) return { removable: false, pidStatus: kept.status, pid: kept.pid };
713
+ return { removable: true, pidStatus: "dead", pid: statuses[0]?.pid };
714
+ }
715
+
716
+ function gcProbeStatus(probe: GcPidProbe, pid: number): "dead" | "alive" | "eperm" | "unknown" {
717
+ const result = probe(pid);
718
+ if (result.status === "dead") return "dead";
719
+ return result.reason ?? "unknown";
720
+ }
721
+
722
+ function teamGcRecordDetail(heartbeat: WorkerHeartbeatFile | null, lifecycle: GjcTeamWorkerLifecycle | null): string {
723
+ return [
724
+ `heartbeat=${heartbeat ? "present" : "missing"}`,
725
+ ...(heartbeat ? [`heartbeat_alive=${heartbeat.alive}`, `last_turn_at=${heartbeat.last_turn_at}`] : []),
726
+ `lifecycle=${lifecycle?.lifecycle_state ?? "missing"}`,
727
+ ...(lifecycle?.pane_id ? [`pane_id=${lifecycle.pane_id}`] : []),
728
+ ...(lifecycle?.stop_reason ? [`stop_reason=${lifecycle.stop_reason}`] : []),
729
+ ].join(" ");
730
+ }
731
+
732
+ /** @internal */
733
+ export async function listTeamWorkerGcRecords(teamRoot: string, probe: GcPidProbe): Promise<GcRecord[]> {
734
+ const teamEntries = await fs.readdir(teamRoot, { withFileTypes: true });
735
+ const records: GcRecord[] = [];
736
+ for (const teamEntry of teamEntries) {
737
+ if (!teamEntry.isDirectory()) continue;
738
+ const teamName = teamEntry.name;
739
+ const teamDirPath = path.join(teamRoot, teamName);
740
+ let workerEntries: import("node:fs").Dirent[];
741
+ try {
742
+ workerEntries = await fs.readdir(path.join(teamDirPath, "workers"), { withFileTypes: true });
743
+ } catch (error) {
744
+ if (isEnoent(error)) continue;
745
+ throw error;
746
+ }
747
+
748
+ for (const workerEntry of workerEntries) {
749
+ if (!workerEntry.isDirectory()) continue;
750
+ const workerId = workerEntry.name;
751
+ const dir = path.join(teamDirPath, "workers", workerId);
752
+ let heartbeat: WorkerHeartbeatFile | null = null;
753
+ let lifecycle: GjcTeamWorkerLifecycle | null = null;
754
+ try {
755
+ heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(dir, "heartbeat.json"));
756
+ lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(dir, "lifecycle.json"));
757
+ } catch (error) {
758
+ records.push({
759
+ store: "team_workers",
760
+ id: `${teamName}/${workerId}`,
761
+ root: teamRoot,
762
+ path: dir,
763
+ pid_status: "none",
764
+ status: "malformed",
765
+ stale: false,
766
+ removable: false,
767
+ action: "none",
768
+ reason: "worker_state_malformed_kept",
769
+ error: error instanceof Error ? error.message : String(error),
770
+ });
771
+ continue;
772
+ }
773
+ const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
774
+ const { removable, pidStatus, pid } = classifyTeamGcWorkerPids(pids, probe);
775
+ const terminalLifecycle = lifecycle?.lifecycle_state === "failed" || lifecycle?.lifecycle_state === "stopped";
776
+ const status = removable
777
+ ? "dead"
778
+ : pidStatus === "none" && terminalLifecycle
779
+ ? "terminal_lifecycle"
780
+ : pidStatus === "none"
781
+ ? "no_pid"
782
+ : pidStatus;
783
+ records.push({
784
+ store: "team_workers",
785
+ id: `${teamName}/${workerId}`,
786
+ root: teamRoot,
787
+ path: dir,
788
+ pid,
789
+ pid_status: pidStatus,
790
+ status,
791
+ stale: removable,
792
+ removable,
793
+ action: "none",
794
+ reason: removable
795
+ ? "worker_all_pids_dead"
796
+ : pidStatus === "none" && terminalLifecycle
797
+ ? "terminal_lifecycle_without_pid_kept"
798
+ : pidStatus === "none"
799
+ ? "worker_pid_missing_kept"
800
+ : `worker_pid_${pidStatus}_kept`,
801
+ detail: teamGcRecordDetail(heartbeat, lifecycle),
802
+ });
803
+ }
804
+ }
805
+ return records;
806
+ }
807
+
808
+ /** @internal */
809
+ export async function pruneTeamWorkerGcRecord(record: GcRecord, probe: GcPidProbe): Promise<boolean> {
810
+ if (!record.path || !record.id.includes("/")) return false;
811
+ const [teamName, workerId] = record.id.split("/", 2);
812
+ if (!teamName || !workerId) return false;
813
+ const teamDirPath = path.dirname(path.dirname(record.path));
814
+ const heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(record.path, "heartbeat.json"));
815
+ const lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(record.path, "lifecycle.json"));
816
+ const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
817
+ if (!classifyTeamGcWorkerPids(pids, probe).removable) return false;
818
+
819
+ const claimDir = path.join(teamDirPath, "claims");
820
+ try {
821
+ for (const entry of await fs.readdir(claimDir, { withFileTypes: true })) {
822
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
823
+ const claimPath = path.join(claimDir, entry.name);
824
+ const claim = readClaimRecord(await readJsonFile<unknown>(claimPath));
825
+ if (claim?.owner !== workerId) continue;
826
+ await removeFileAudited(claimPath, stateWriterOptions(claimPath, "prune", "gc-team-worker"));
827
+ }
828
+ } catch (error) {
829
+ if (!isEnoent(error)) throw error;
830
+ }
831
+
832
+ for (const task of await readTasks(teamDirPath)) {
833
+ if (task.claim?.owner !== workerId && task.assignee !== workerId) continue;
834
+ if (task.status === "completed" || task.status === "failed") continue;
835
+ await writeTask(teamDirPath, {
836
+ ...task,
837
+ status: "pending",
838
+ assignee: undefined,
839
+ claim: undefined,
840
+ version: task.version + 1,
841
+ updated_at: now(),
842
+ });
843
+ }
844
+
845
+ // Remove the stale worker record dir itself so a removable record always
846
+ // results in an observable removal, even when it owns no claims/tasks.
847
+ await fs.rm(record.path, { recursive: true, force: true });
848
+ return true;
849
+ }
680
850
  function stateCategoryForJsonPath(filePath: string): "state" | "ledger" {
681
851
  return filePath.endsWith(".jsonl") || filePath.includes(`${path.sep}telemetry${path.sep}`) ? "ledger" : "state";
682
852
  }
@@ -1633,7 +1803,7 @@ function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
1633
1803
  }
1634
1804
  function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): string {
1635
1805
  const result = Bun.spawnSync(
1636
- [tmuxCommand, "show-options", "-qv", "-t", `=${sessionName}`, GJC_TMUX_PROFILE_OPTION],
1806
+ [tmuxCommand, "show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
1637
1807
  {
1638
1808
  stdout: "pipe",
1639
1809
  stderr: "pipe",
@@ -1645,7 +1815,14 @@ function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): stri
1645
1815
 
1646
1816
  function retagGjcLaunchedTmuxSession(tmuxCommand: string, sessionName: string): boolean {
1647
1817
  const result = Bun.spawnSync(
1648
- [tmuxCommand, "set-option", "-t", `=${sessionName}`, GJC_TMUX_PROFILE_OPTION, GJC_TMUX_PROFILE_VALUE],
1818
+ [
1819
+ tmuxCommand,
1820
+ "set-option",
1821
+ "-t",
1822
+ buildGjcTmuxExactOptionTarget(sessionName),
1823
+ GJC_TMUX_PROFILE_OPTION,
1824
+ GJC_TMUX_PROFILE_VALUE,
1825
+ ],
1649
1826
  {
1650
1827
  stdout: "pipe",
1651
1828
  stderr: "pipe",
@@ -32,6 +32,20 @@ export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): str
32
32
  return env[GJC_TMUX_COMMAND_ENV]?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
33
33
  }
34
34
 
35
+ /**
36
+ * Build the exact-session target for tmux *option* commands
37
+ * (`show-options` / `set-option`) and `display-message -t`.
38
+ *
39
+ * Session-scoped commands such as `kill-session` / `attach-session` resolve a
40
+ * bare exact target (`=NAME`), but tmux 3.6a refuses to resolve a bare `=NAME`
41
+ * for option/display commands. Appending the empty window separator (`=NAME:`)
42
+ * keeps the exact-session match while giving tmux the window-qualified target
43
+ * those commands require. See gajae-code#580.
44
+ */
45
+ export function buildGjcTmuxExactOptionTarget(sessionName: string): string {
46
+ return `=${sessionName}:`;
47
+ }
48
+
35
49
  export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
36
50
 
37
51
  export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {
@@ -0,0 +1,176 @@
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.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+
9
+ import { worktree } from "../utils/git";
10
+ import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
11
+ import { GJC_TMUX_PROFILE_VALUE } from "./tmux-common";
12
+ import {
13
+ type GjcTmuxSessionStatus,
14
+ listTmuxSessionsForGc,
15
+ readTmuxSessionTagsForGc,
16
+ removeGjcTmuxSession,
17
+ } from "./tmux-sessions";
18
+
19
+ const STORE = "tmux_sessions" as const;
20
+ const TOCTOU_SKIP = "tmux_revalidation_failed_or_became_live";
21
+
22
+ function pathExists(path: string): boolean {
23
+ try {
24
+ return fs.existsSync(path);
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function detail(project?: string, branch?: string): string | undefined {
31
+ const parts = [];
32
+ if (project) parts.push(`project=${project}`);
33
+ if (branch) parts.push(`branch=${branch}`);
34
+ return parts.length > 0 ? parts.join(" ") : undefined;
35
+ }
36
+
37
+ function unclassifiedRecord(id: string, reason: string, project?: string, branch?: string): GcRecord {
38
+ return {
39
+ store: STORE,
40
+ id,
41
+ path: project,
42
+ root: project,
43
+ pid_status: "none",
44
+ status: "unclassified",
45
+ stale: false,
46
+ removable: false,
47
+ action: "none",
48
+ reason,
49
+ detail: detail(project, branch),
50
+ };
51
+ }
52
+
53
+ function branchMatches(candidate: string | undefined, branch: string): boolean {
54
+ if (!candidate) return false;
55
+ const branchNames = new Set([
56
+ branch,
57
+ branch.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : `refs/heads/${branch}`,
58
+ ]);
59
+ return branchNames.has(candidate);
60
+ }
61
+
62
+ async function hasLiveWorktreeForBranch(project: string, branch: string): Promise<boolean> {
63
+ const entries = await worktree.list(project);
64
+ return entries.some(entry => branchMatches(entry.branch, branch));
65
+ }
66
+
67
+ async function classifyTaggedSession(session: GjcTmuxSessionStatus): Promise<GcRecord> {
68
+ const { name, project, branch } = session;
69
+ if (!project || !branch) return unclassifiedRecord(name, "missing_project_or_branch_tag", project, branch);
70
+ if (!pathExists(project)) {
71
+ return {
72
+ store: STORE,
73
+ id: name,
74
+ path: project,
75
+ root: project,
76
+ pid_status: "none",
77
+ status: "stale",
78
+ stale: true,
79
+ removable: true,
80
+ action: "none",
81
+ reason: "project_missing",
82
+ detail: detail(project, branch),
83
+ };
84
+ }
85
+ if (!(await hasLiveWorktreeForBranch(project, branch))) {
86
+ return {
87
+ store: STORE,
88
+ id: name,
89
+ path: project,
90
+ root: project,
91
+ pid_status: "none",
92
+ status: "stale",
93
+ stale: true,
94
+ removable: true,
95
+ action: "none",
96
+ reason: "branch_no_worktree",
97
+ detail: detail(project, branch),
98
+ };
99
+ }
100
+ return {
101
+ store: STORE,
102
+ id: name,
103
+ path: project,
104
+ root: project,
105
+ pid_status: "none",
106
+ status: "live",
107
+ stale: false,
108
+ removable: false,
109
+ action: "none",
110
+ reason: "project_and_branch_worktree_present",
111
+ detail: detail(project, branch),
112
+ };
113
+ }
114
+
115
+ async function revalidateRemovable(name: string, env: NodeJS.ProcessEnv): Promise<boolean> {
116
+ const tags = readTmuxSessionTagsForGc(name, env);
117
+ if (tags.profile !== GJC_TMUX_PROFILE_VALUE || !tags.project || !tags.branch) return false;
118
+ if (!pathExists(tags.project)) return true;
119
+ return !(await hasLiveWorktreeForBranch(tags.project, tags.branch));
120
+ }
121
+
122
+ export const tmuxSessionsGcAdapter: GcStoreAdapter = {
123
+ store: STORE,
124
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
125
+ const records: GcRecord[] = [];
126
+ const errors: GcCollectResult["errors"] = [];
127
+ let sessions: ReturnType<typeof listTmuxSessionsForGc>;
128
+ try {
129
+ sessions = listTmuxSessionsForGc(ctx.env);
130
+ } catch (error) {
131
+ return {
132
+ records,
133
+ errors: [
134
+ {
135
+ store: STORE,
136
+ scope: "list_sessions",
137
+ message: error instanceof Error ? error.message : String(error),
138
+ },
139
+ ],
140
+ };
141
+ }
142
+
143
+ for (const session of sessions.tagged) {
144
+ try {
145
+ records.push(await classifyTaggedSession(session));
146
+ } catch (error) {
147
+ errors.push({
148
+ store: STORE,
149
+ scope: session.name,
150
+ message: error instanceof Error ? error.message : String(error),
151
+ });
152
+ records.push(unclassifiedRecord(session.name, "worktree_list_failed", session.project, session.branch));
153
+ }
154
+ }
155
+
156
+ for (const name of sessions.untagged) {
157
+ records.push(unclassifiedRecord(name, "untagged_tmux_session"));
158
+ }
159
+
160
+ return { records, errors };
161
+ },
162
+ async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
163
+ if (record.store !== STORE || record.status !== "stale" || !record.removable) {
164
+ return { removed: false, skipped: "not_removable_tmux_session" };
165
+ }
166
+ try {
167
+ if (!(await revalidateRemovable(record.id, ctx.env))) {
168
+ return { removed: false, skipped: TOCTOU_SKIP };
169
+ }
170
+ removeGjcTmuxSession(record.id, ctx.env);
171
+ return { removed: true };
172
+ } catch (error) {
173
+ return { removed: false, error: error instanceof Error ? error.message : String(error) };
174
+ }
175
+ },
176
+ };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ buildGjcTmuxExactOptionTarget,
2
3
  buildGjcTmuxProfileCommands,
3
4
  buildGjcTmuxSessionName,
4
5
  buildGjcTmuxUntaggedSessionError,
@@ -23,6 +24,17 @@ export interface GjcTmuxSessionStatus {
23
24
  project?: string;
24
25
  }
25
26
 
27
+ export interface GjcTmuxSessionTagsForGc {
28
+ profile?: string;
29
+ project?: string;
30
+ branch?: string;
31
+ }
32
+
33
+ export interface GjcTmuxSessionsForGc {
34
+ tagged: GjcTmuxSessionStatus[];
35
+ untagged: string[];
36
+ }
37
+
26
38
  function runTmux(args: string[], env: NodeJS.ProcessEnv = process.env): string {
27
39
  const tmuxCommand = resolveGjcTmuxCommand(env);
28
40
  const result = Bun.spawnSync([tmuxCommand, ...args], { stdout: "pipe", stderr: "pipe", env });
@@ -107,6 +119,16 @@ export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTm
107
119
  .sort((a, b) => a.name.localeCompare(b.name));
108
120
  }
109
121
 
122
+ /** @internal */
123
+ export function listTmuxSessionsForGc(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionsForGc {
124
+ const tagged = listGjcTmuxSessions(env);
125
+ const taggedNames = new Set(tagged.map(session => session.name));
126
+ const untagged = listRawTmuxSessionNames(env)
127
+ .filter(name => !taggedNames.has(name))
128
+ .sort((a, b) => a.localeCompare(b));
129
+ return { tagged, untagged };
130
+ }
131
+
110
132
  export function findGjcTmuxSessionByBranch(
111
133
  branch: string,
112
134
  env: NodeJS.ProcessEnv = process.env,
@@ -148,7 +170,33 @@ export function createGjcTmuxSession(env: NodeJS.ProcessEnv = process.env): GjcT
148
170
  }
149
171
 
150
172
  function readProfileForExactTarget(sessionName: string, env: NodeJS.ProcessEnv): string {
151
- return runTmux(["show-options", "-qv", "-t", `=${sessionName}`, GJC_TMUX_PROFILE_OPTION], env).trim();
173
+ return runTmux(
174
+ ["show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
175
+ env,
176
+ ).trim();
177
+ }
178
+
179
+ function readExactOptionForGc(sessionName: string, option: string, env: NodeJS.ProcessEnv): string | undefined {
180
+ try {
181
+ return (
182
+ runTmux(["show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), option], env).trim() ||
183
+ undefined
184
+ );
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ /** @internal */
191
+ export function readTmuxSessionTagsForGc(
192
+ sessionName: string,
193
+ env: NodeJS.ProcessEnv = process.env,
194
+ ): GjcTmuxSessionTagsForGc {
195
+ return {
196
+ profile: readExactOptionForGc(sessionName, GJC_TMUX_PROFILE_OPTION, env),
197
+ project: readExactOptionForGc(sessionName, GJC_TMUX_PROJECT_OPTION, env),
198
+ branch: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_OPTION, env),
199
+ };
152
200
  }
153
201
 
154
202
  export function removeGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionStatus {