@gajae-code/coding-agent 0.4.5 → 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 (185) hide show
  1. package/CHANGELOG.md +62 -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/commands/harness.d.ts +3 -0
  7. package/dist/types/config/file-lock-gc.d.ts +5 -0
  8. package/dist/types/config/file-lock.d.ts +7 -0
  9. package/dist/types/config/model-profile-activation.d.ts +11 -2
  10. package/dist/types/config/model-profiles.d.ts +7 -0
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/model-resolver.d.ts +2 -0
  13. package/dist/types/config/models-config-schema.d.ts +30 -0
  14. package/dist/types/config/settings-schema.d.ts +4 -3
  15. package/dist/types/coordinator/contract.d.ts +1 -1
  16. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  25. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  26. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  27. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  28. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  29. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  30. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  31. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  32. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  33. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  34. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
  38. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  39. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  40. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  41. package/dist/types/harness-control-plane/owner.d.ts +8 -1
  42. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  43. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/harness-control-plane/types.d.ts +4 -0
  46. package/dist/types/hindsight/mental-models.d.ts +5 -5
  47. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  48. package/dist/types/modes/components/model-selector.d.ts +1 -12
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  51. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  52. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  53. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  54. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  56. package/dist/types/sdk.d.ts +5 -0
  57. package/dist/types/session/agent-session.d.ts +3 -1
  58. package/dist/types/session/blob-store.d.ts +59 -4
  59. package/dist/types/session/session-manager.d.ts +24 -6
  60. package/dist/types/session/streaming-output.d.ts +3 -2
  61. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/receipt.d.ts +1 -0
  64. package/dist/types/task/types.d.ts +7 -0
  65. package/dist/types/thinking-metadata.d.ts +16 -0
  66. package/dist/types/thinking.d.ts +3 -12
  67. package/dist/types/tools/ask.d.ts +15 -1
  68. package/dist/types/tools/index.d.ts +2 -0
  69. package/dist/types/tools/resolve.d.ts +0 -10
  70. package/dist/types/tools/subagent.d.ts +6 -0
  71. package/dist/types/utils/tool-choice.d.ts +14 -1
  72. package/package.json +7 -7
  73. package/src/async/job-manager.ts +52 -0
  74. package/src/cli/args.ts +3 -0
  75. package/src/cli/auth-broker-cli.ts +1 -0
  76. package/src/cli/list-models.ts +13 -1
  77. package/src/cli.ts +9 -4
  78. package/src/commands/gc.ts +22 -0
  79. package/src/commands/harness.ts +43 -5
  80. package/src/commands/launch.ts +2 -2
  81. package/src/commands/session.ts +3 -1
  82. package/src/config/file-lock-gc.ts +181 -0
  83. package/src/config/file-lock.ts +14 -0
  84. package/src/config/model-profile-activation.ts +15 -3
  85. package/src/config/model-profiles.ts +264 -56
  86. package/src/config/model-resolver.ts +9 -6
  87. package/src/config/models-config-schema.ts +1 -0
  88. package/src/config/settings-schema.ts +6 -3
  89. package/src/coordinator/contract.ts +1 -0
  90. package/src/coordinator-mcp/server.ts +513 -26
  91. package/src/cursor.ts +16 -2
  92. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  93. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  94. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  95. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  96. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  97. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  106. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  107. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  108. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  109. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  110. package/src/defaults/gjc-defaults.ts +7 -0
  111. package/src/defaults/gjc-grok-cli.ts +22 -0
  112. package/src/export/html/index.ts +13 -9
  113. package/src/extensibility/extensions/index.ts +1 -0
  114. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  115. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  116. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  117. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  118. package/src/gjc-runtime/gc-render.ts +70 -0
  119. package/src/gjc-runtime/gc-runtime.ts +403 -0
  120. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  121. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  122. package/src/gjc-runtime/state-renderer.ts +12 -3
  123. package/src/gjc-runtime/state-runtime.ts +46 -29
  124. package/src/gjc-runtime/team-gc.ts +49 -0
  125. package/src/gjc-runtime/team-runtime.ts +211 -8
  126. package/src/gjc-runtime/tmux-common.ts +29 -0
  127. package/src/gjc-runtime/tmux-gc.ts +176 -0
  128. package/src/gjc-runtime/tmux-sessions.ts +68 -12
  129. package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
  130. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  131. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  132. package/src/harness-control-plane/gc-adapter.ts +184 -0
  133. package/src/harness-control-plane/owner.ts +89 -27
  134. package/src/harness-control-plane/receipt-spool.ts +128 -0
  135. package/src/harness-control-plane/state-machine.ts +27 -6
  136. package/src/harness-control-plane/storage.ts +93 -0
  137. package/src/harness-control-plane/types.ts +4 -0
  138. package/src/hindsight/mental-models.ts +17 -16
  139. package/src/internal-urls/docs-index.generated.ts +14 -8
  140. package/src/main.ts +7 -2
  141. package/src/modes/components/assistant-message.ts +26 -14
  142. package/src/modes/components/diff.ts +97 -0
  143. package/src/modes/components/hook-selector.ts +19 -0
  144. package/src/modes/components/model-selector.ts +370 -181
  145. package/src/modes/components/status-line/segments.ts +1 -1
  146. package/src/modes/components/tool-execution.ts +30 -13
  147. package/src/modes/controllers/command-controller.ts +25 -6
  148. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  149. package/src/modes/controllers/selector-controller.ts +34 -42
  150. package/src/modes/rpc/rpc-client.ts +3 -2
  151. package/src/modes/rpc/rpc-mode.ts +187 -39
  152. package/src/modes/rpc/rpc-types.ts +5 -2
  153. package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
  154. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  156. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  157. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  158. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  159. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  160. package/src/sdk.ts +46 -5
  161. package/src/secrets/obfuscator.ts +102 -27
  162. package/src/session/agent-session.ts +179 -25
  163. package/src/session/blob-store.ts +148 -6
  164. package/src/session/session-manager.ts +311 -60
  165. package/src/session/streaming-output.ts +185 -122
  166. package/src/session/tool-choice-queue.ts +23 -0
  167. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  168. package/src/skill-state/workflow-hud.ts +106 -10
  169. package/src/slash-commands/builtin-registry.ts +3 -2
  170. package/src/task/executor.ts +78 -6
  171. package/src/task/receipt.ts +5 -0
  172. package/src/task/render.ts +21 -1
  173. package/src/task/types.ts +8 -0
  174. package/src/thinking-metadata.ts +51 -0
  175. package/src/thinking.ts +26 -46
  176. package/src/tools/ask.ts +56 -1
  177. package/src/tools/bash.ts +1 -1
  178. package/src/tools/index.ts +2 -0
  179. package/src/tools/job.ts +3 -2
  180. package/src/tools/monitor.ts +36 -1
  181. package/src/tools/resolve.ts +93 -18
  182. package/src/tools/subagent-render.ts +9 -0
  183. package/src/tools/subagent.ts +26 -2
  184. package/src/utils/edit-mode.ts +1 -1
  185. package/src/utils/tool-choice.ts +45 -16
@@ -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,8 +4,9 @@ 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
- import { applyGjcTmuxProfile } from "./launch-tmux";
9
+ import { applyGjcTmuxProfile, GJC_TMUX_LAUNCHED_ENV } from "./launch-tmux";
9
10
  import {
10
11
  AlreadyExistsError,
11
12
  appendJsonl as appendJsonlAudited,
@@ -17,7 +18,13 @@ import {
17
18
  writeReport,
18
19
  writeWorkflowEnvelopeAtomic,
19
20
  } from "./state-writer";
20
- import { GJC_TMUX_PROFILE_OPTION, GJC_TMUX_PROFILE_VALUE } from "./tmux-common";
21
+ import {
22
+ buildGjcTmuxExactOptionTarget,
23
+ buildGjcTmuxUntaggedSessionHint,
24
+ GJC_TMUX_PROFILE_OPTION,
25
+ GJC_TMUX_PROFILE_VALUE,
26
+ resolveGjcTmuxCommand,
27
+ } from "./tmux-common";
21
28
 
22
29
  export type GjcTeamPhase = "starting" | "running" | "awaiting_integration" | "complete" | "failed" | "cancelled";
23
30
  export type GjcTeamTaskStatus = "pending" | "blocked" | "in_progress" | "completed" | "failed";
@@ -672,6 +679,174 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
672
679
  throw error;
673
680
  }
674
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
+ }
675
850
  function stateCategoryForJsonPath(filePath: string): "state" | "ledger" {
676
851
  return filePath.endsWith(".jsonl") || filePath.includes(`${path.sep}telemetry${path.sep}`) ? "ledger" : "state";
677
852
  }
@@ -1622,16 +1797,13 @@ async function ensureWorkerWorktree(
1622
1797
  };
1623
1798
  }
1624
1799
 
1625
- export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): string {
1626
- return env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
1627
- }
1628
1800
  function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
1629
1801
  const suffix = detail?.trim() ? `:${detail.trim()}` : "";
1630
1802
  return `gjc_team_requires_tmux_leader: run \`gjc --tmux\` first, then run \`gjc team ...\` inside that tmux-backed leader session, or use \`gjc team --dry-run\` for state-only smoke tests${suffix}`;
1631
1803
  }
1632
1804
  function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): string {
1633
1805
  const result = Bun.spawnSync(
1634
- [tmuxCommand, "show-options", "-qv", "-t", `=${sessionName}`, GJC_TMUX_PROFILE_OPTION],
1806
+ [tmuxCommand, "show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
1635
1807
  {
1636
1808
  stdout: "pipe",
1637
1809
  stderr: "pipe",
@@ -1641,6 +1813,24 @@ function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): stri
1641
1813
  return result.stdout.toString().trim();
1642
1814
  }
1643
1815
 
1816
+ function retagGjcLaunchedTmuxSession(tmuxCommand: string, sessionName: string): boolean {
1817
+ const result = Bun.spawnSync(
1818
+ [
1819
+ tmuxCommand,
1820
+ "set-option",
1821
+ "-t",
1822
+ buildGjcTmuxExactOptionTarget(sessionName),
1823
+ GJC_TMUX_PROFILE_OPTION,
1824
+ GJC_TMUX_PROFILE_VALUE,
1825
+ ],
1826
+ {
1827
+ stdout: "pipe",
1828
+ stderr: "pipe",
1829
+ },
1830
+ );
1831
+ return result.exitCode === 0;
1832
+ }
1833
+
1644
1834
  function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEnv): GjcTmuxLeaderContext {
1645
1835
  const paneTarget = env.TMUX_PANE?.trim();
1646
1836
  const args = paneTarget
@@ -1652,8 +1842,21 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
1652
1842
  const [sessionName = "", windowIndex = ""] = sessionAndWindow.split(":");
1653
1843
  if (!sessionName || !windowIndex || !leaderPaneId.startsWith("%"))
1654
1844
  throw new Error(buildTeamTmuxLeaderRequirementMessage(`invalid_tmux_context:${result.stdout.toString().trim()}`));
1655
- if (readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE)
1656
- throw new Error(buildTeamTmuxLeaderRequirementMessage(`unmanaged_tmux_session:${sessionName}`));
1845
+ if (readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
1846
+ // Self-heal: a pane launched through `gjc --tmux` exports
1847
+ // GJC_TMUX_LAUNCHED=1, but the session can lose (or never receive) the
1848
+ // @gjc-profile user-option tag when startup attach fails mid-way or the
1849
+ // registry write races. That stranded-but-genuinely-GJC leader pane
1850
+ // previously hard-failed as unmanaged_tmux_session; re-tag it instead.
1851
+ const launchedByGjc = env[GJC_TMUX_LAUNCHED_ENV] === "1";
1852
+ const retagged = launchedByGjc && retagGjcLaunchedTmuxSession(tmuxCommand, sessionName);
1853
+ if (!retagged || readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE)
1854
+ throw new Error(
1855
+ buildTeamTmuxLeaderRequirementMessage(
1856
+ `unmanaged_tmux_session:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`,
1857
+ ),
1858
+ );
1859
+ }
1657
1860
  return { sessionName, windowIndex, leaderPaneId, target: `${sessionName}:${windowIndex}` };
1658
1861
  }
1659
1862
  export function resolveGjcWorkerCommand(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string {
@@ -32,6 +32,35 @@ 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
+
49
+ export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
50
+
51
+ export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {
52
+ return (
53
+ `the active multiplexer "${tmuxCommand}" lists this session but did not return GJC's ${GJC_TMUX_PROFILE_OPTION} ownership tag; ` +
54
+ "GJC-managed sessions and `gjc team` require a tmux provider that round-trips tmux user options. " +
55
+ "Alternative multiplexers such as psmux on Windows do not persist user options yet, so the Windows-native psmux path is not fully supported; " +
56
+ "use real tmux for GJC-managed session and team flows."
57
+ );
58
+ }
59
+
60
+ export function buildGjcTmuxUntaggedSessionError(sessionName: string, tmuxCommand: string): string {
61
+ return `${GJC_TMUX_UNTAGGED_REASON}:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`;
62
+ }
63
+
35
64
  export function sanitizeTmuxToken(value: string): string {
36
65
  return (
37
66
  value
@@ -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
+ };