@gajae-code/coding-agent 0.12.5 → 0.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -1
  3. package/dist/types/eval/backend.d.ts +2 -2
  4. package/dist/types/exec/bash-executor.d.ts +10 -8
  5. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  7. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  8. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  10. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  11. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  12. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  13. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  14. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  15. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  16. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  18. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  19. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  20. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  21. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  22. package/dist/types/sdk/broker/broker.d.ts +18 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  25. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -3
  26. package/dist/types/sdk/bus/index.d.ts +4 -0
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  29. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  30. package/dist/types/sdk/host/host.d.ts +23 -2
  31. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  32. package/dist/types/session/agent-session.d.ts +5 -4
  33. package/dist/types/session/artifacts.d.ts +8 -5
  34. package/dist/types/session/attempt-record-store.d.ts +19 -0
  35. package/dist/types/session/client-bridge.d.ts +14 -0
  36. package/dist/types/session/internal/managed-session-storage.d.ts +5 -2
  37. package/dist/types/session/messages.d.ts +4 -1
  38. package/dist/types/session/session-manager.d.ts +6 -4
  39. package/dist/types/session/session-storage.d.ts +8 -0
  40. package/dist/types/session/streaming-output.d.ts +32 -0
  41. package/dist/types/task/receipt.d.ts +2 -0
  42. package/dist/types/task/types.d.ts +178 -0
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/dist/types/tools/fetch.d.ts +3 -0
  45. package/dist/types/tools/output-meta.d.ts +37 -5
  46. package/dist/types/tools/tool-result.d.ts +1 -0
  47. package/dist/types/utils/artifact-id.d.ts +2 -0
  48. package/package.json +8 -8
  49. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  50. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  51. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  52. package/src/config/model-profiles.ts +8 -0
  53. package/src/config/settings-schema.ts +17 -3
  54. package/src/cursor.ts +20 -13
  55. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  56. package/src/eval/backend.ts +2 -2
  57. package/src/exec/bash-executor.ts +425 -151
  58. package/src/extensibility/extensions/runner.ts +80 -6
  59. package/src/extensibility/extensions/wrapper.ts +22 -15
  60. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  61. package/src/gjc-runtime/launch-tmux.ts +309 -293
  62. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  63. package/src/gjc-runtime/psmux-detect.ts +52 -6
  64. package/src/gjc-runtime/team-launch.ts +50 -2
  65. package/src/gjc-runtime/team-runtime.ts +716 -251
  66. package/src/gjc-runtime/team-store.ts +25 -7
  67. package/src/gjc-runtime/team-workers.ts +161 -1
  68. package/src/gjc-runtime/tmux-common.ts +16 -4
  69. package/src/gjc-runtime/tmux-gc.ts +37 -5
  70. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  71. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  72. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  73. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  74. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  75. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  76. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  77. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  78. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  79. package/src/internal-urls/agent-protocol.ts +98 -38
  80. package/src/internal-urls/artifact-protocol.ts +28 -17
  81. package/src/internal-urls/docs-index.generated.ts +5 -5
  82. package/src/internal-urls/local-protocol.ts +39 -18
  83. package/src/internal-urls/registry-helpers.ts +62 -0
  84. package/src/modes/acp/acp-agent.ts +51 -13
  85. package/src/modes/acp/acp-client-bridge.ts +154 -11
  86. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  87. package/src/modes/components/plugin-settings.ts +5 -0
  88. package/src/modes/components/settings-selector.ts +4 -0
  89. package/src/modes/controllers/selector-controller.ts +1 -0
  90. package/src/prompts/tools/bash.md +2 -2
  91. package/src/prompts/tools/monitor.md +4 -4
  92. package/src/sdk/acp/adapter.ts +200 -9
  93. package/src/sdk/broker/broker.ts +93 -10
  94. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  95. package/src/sdk/broker/lifecycle.ts +691 -92
  96. package/src/sdk/bus/chat-daemon-control.ts +32 -6
  97. package/src/sdk/bus/index.ts +347 -14
  98. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  99. package/src/sdk/bus/telegram-daemon.ts +283 -44
  100. package/src/sdk/bus/topic-registry.ts +133 -10
  101. package/src/sdk/host/host.ts +187 -9
  102. package/src/sdk/host/reverse-leases.ts +249 -42
  103. package/src/sdk/session.ts +7 -6
  104. package/src/session/agent-session.ts +930 -354
  105. package/src/session/artifacts.ts +211 -74
  106. package/src/session/attempt-record-store.ts +106 -0
  107. package/src/session/client-bridge.ts +15 -0
  108. package/src/session/internal/managed-session-scope.ts +252 -67
  109. package/src/session/internal/managed-session-storage.ts +105 -5
  110. package/src/session/messages.ts +32 -7
  111. package/src/session/session-manager.ts +19 -21
  112. package/src/session/session-storage.ts +146 -24
  113. package/src/session/streaming-output.ts +198 -40
  114. package/src/task/index.ts +45 -0
  115. package/src/task/provider-retry-status.ts +4 -1
  116. package/src/task/receipt.ts +4 -0
  117. package/src/task/render.ts +30 -0
  118. package/src/task/types.ts +34 -0
  119. package/src/tools/bash.ts +818 -239
  120. package/src/tools/eval.ts +99 -4
  121. package/src/tools/fetch.ts +77 -15
  122. package/src/tools/find.ts +7 -3
  123. package/src/tools/monitor.ts +7 -1
  124. package/src/tools/output-meta.ts +706 -116
  125. package/src/tools/search.ts +9 -4
  126. package/src/tools/tool-result.ts +1 -0
  127. package/src/utils/artifact-id.ts +6 -0
@@ -6,9 +6,13 @@ import type { WorkflowHudSummary } from "../skill-state/active-state";
6
6
  import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
7
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
8
8
  import type { GcPidProbe, GcRecord } from "./gc-runtime";
9
- import { applyGjcTmuxProfile } from "./launch-tmux";
10
9
  import { modeStatePath, sessionIdFromDirName, sessionReportsDir, teamStateRoot } from "./session-layout";
11
10
  import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
11
+ import {
12
+ GJC_COORDINATOR_SESSION_ID_ENV,
13
+ GJC_TMUX_OWNER_GENERATION_ENV,
14
+ GJC_TMUX_OWNER_STATE_DIR_ENV,
15
+ } from "./session-state-sidecar";
12
16
  import {
13
17
  AlreadyExistsError,
14
18
  appendJsonl as appendJsonlAudited,
@@ -67,10 +71,13 @@ import {
67
71
  workerMemoryGuardLedgerPath,
68
72
  } from "./team-worker-memory-guard";
69
73
  import {
74
+ buildGjcContinuationPrompt,
75
+ GJC_TEAM_CONTINUATION_ACK_POLL_MS,
70
76
  GJC_TEAM_CONTINUATION_PROMPT,
71
77
  type GjcTeamWorkerOrchestrationRuntime,
72
78
  type GjcTeamWorkerRuntime,
73
79
  gjcContinuationReservationDigest,
80
+ isValidGjcContinuationAck,
74
81
  isValidGjcContinuationOutcome,
75
82
  isValidGjcContinuationReservation,
76
83
  readGjcShutdownAuthority,
@@ -85,10 +92,12 @@ import {
85
92
  writeWorkerLifecycleForConfig as writeLifecycleForConfig,
86
93
  writeWorkerLifecycleRecord as writeLifecycleRecord,
87
94
  writeGjcShutdownRequest as writeShutdownRequest,
95
+ writeGjcWorkerContinuationAck as writeWorkerContinuationAck,
88
96
  writeGjcWorkerStartupAck as writeWorkerStartupAck,
89
97
  } from "./team-workers";
90
98
  import {
91
99
  buildGjcTmuxExactOptionTarget,
100
+ buildGjcTmuxProfileCommands,
92
101
  buildGjcTmuxUntaggedSessionHint,
93
102
  GJC_TMUX_ACTIVE_SESSION_ENV,
94
103
  GJC_TMUX_PROFILE_OPTION,
@@ -96,6 +105,15 @@ import {
96
105
  resolveGjcTmuxBinary,
97
106
  resolveGjcTmuxCommand,
98
107
  } from "./tmux-common";
108
+ import {
109
+ assertGjcTmuxMutationAuthoritySync,
110
+ bindGjcTmuxProviderAuthority,
111
+ buildTmuxProviderCommand,
112
+ hasGjcTmuxProviderAuthoritySync,
113
+ type ProviderAuthority,
114
+ readGjcTmuxProviderAuthoritySync,
115
+ resolveGjcTmuxProviderContext,
116
+ } from "./tmux-provider-context";
99
117
 
100
118
  export type {
101
119
  GjcTeamApiClaimResult,
@@ -163,6 +181,12 @@ export type GjcTeamWorktreeMode =
163
181
  | { enabled: true; detached: false; name: string };
164
182
 
165
183
  export interface GjcTeamConfig {
184
+ /**
185
+ * Launch-time platform. Persisted so the monitor path resolves the same
186
+ * provider context the launch did instead of re-reading `process.platform`,
187
+ * which makes Windows-authority branches unreachable off Windows.
188
+ */
189
+ platform?: NodeJS.Platform;
166
190
  team_name: string;
167
191
  display_name: string;
168
192
  requested_name: string;
@@ -178,6 +202,9 @@ export interface GjcTeamConfig {
178
202
  tmux_session: string;
179
203
  tmux_session_name: string;
180
204
  tmux_target: string;
205
+ tmux_provider_generation?: string;
206
+ tmux_provider_state_dir?: string;
207
+ tmux_provider_session_id?: string;
181
208
  workspace_mode: "direct" | "worktree";
182
209
  dry_run: boolean;
183
210
  leader: GjcTeamLeader;
@@ -519,6 +546,7 @@ export const GJC_TEAM_API_OPERATIONS = [
519
546
  "notification-replay",
520
547
  "notification-mark-pane-attempt",
521
548
  "worker-startup-ack",
549
+ "worker-continuation-ack",
522
550
  "create-task",
523
551
  "read-task",
524
552
  "list-tasks",
@@ -595,9 +623,12 @@ function now(): string {
595
623
 
596
624
  export interface GjcTeamRuntimeTestSeams {
597
625
  nowMs?: () => number;
598
- continuationTmuxDispatch?: (command: string, args: readonly string[]) => { exitCode?: number };
599
-
626
+ continuationTmuxDispatch?: (
627
+ command: string,
628
+ args: readonly string[],
629
+ ) => { exitCode?: number } | Promise<{ exitCode?: number }>;
600
630
  continuationBeforeDispatch?: () => Promise<void>;
631
+ continuationAckPoll?: () => Promise<void>;
601
632
  }
602
633
 
603
634
  let gjcTeamRuntimeTestSeams: GjcTeamRuntimeTestSeams | undefined;
@@ -1001,8 +1032,15 @@ export async function listTeamWorkerGcRecords(teamRoot: string, probe: GcPidProb
1001
1032
  export async function pruneTeamWorkerGcRecord(record: GcRecord, probe: GcPidProbe): Promise<boolean> {
1002
1033
  if (!record.path || !record.id.includes("/")) return false;
1003
1034
  const teamDirPath = path.dirname(path.dirname(record.path));
1004
- return withGjcTeamTaskMutation(taskStore(teamDirPath), capability =>
1005
- pruneTeamWorkerGcRecordUnlocked(record, probe, capability),
1035
+ // Prune deletes claim records and rewrites claimed tasks, so it is an
1036
+ // authority-changing operation and must take the same team mutation fence the
1037
+ // rest of the public surface takes. Without it, prune can strip a claim inside
1038
+ // the continuation dispatch window and silently suppress a stalled-worker
1039
+ // continuation.
1040
+ return withGjcTeamMutationFence(teamDirPath, () =>
1041
+ withGjcTeamTaskMutation(taskStore(teamDirPath), capability =>
1042
+ pruneTeamWorkerGcRecordUnlocked(record, probe, capability),
1043
+ ),
1006
1044
  );
1007
1045
  }
1008
1046
 
@@ -1285,6 +1323,7 @@ function manifestRecordFromConfig(config: GjcTeamConfig): Record<string, unknown
1285
1323
  worker_command: config.worker_command,
1286
1324
  worker_cli_plan: config.worker_cli_plan,
1287
1325
  tmux_command: config.tmux_command,
1326
+ tmux_provider_generation: config.tmux_provider_generation,
1288
1327
  leader: config.leader,
1289
1328
  workers: config.workers,
1290
1329
  workspace_mode: config.workspace_mode,
@@ -1540,40 +1579,37 @@ async function relaunchWorkerPaneForMemoryGuard(input: {
1540
1579
  input.env,
1541
1580
  );
1542
1581
  const workerCwd = input.worker.worktree_path ?? input.config.leader.cwd;
1543
- const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(input.config.tmux_command, input.platform);
1582
+ const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(
1583
+ input.config.tmux_command,
1584
+ input.config.tmux_provider_generation,
1585
+ );
1544
1586
  const splitTarget =
1545
1587
  input.worker.pane_id && paneBelongsToTeamTarget(input.config, input.worker.pane_id)
1546
1588
  ? input.worker.pane_id
1547
1589
  : input.config.tmux_target;
1548
- const split = Bun.spawnSync(
1549
- [
1550
- input.config.tmux_command,
1551
- "split-window",
1552
- "-v",
1553
- "-t",
1554
- splitTarget,
1555
- "-d",
1556
- "-P",
1557
- "-F",
1558
- "#{pane_id}",
1559
- "-c",
1560
- workerCwd,
1561
- ...(useSendKeysFallback ? [] : [workerCommand]),
1562
- ],
1563
- { stdout: "pipe", stderr: "pipe" },
1564
- );
1590
+ const split = executeTeamTmuxMutation(input.config, {
1591
+ type: "split",
1592
+ direction: "-v",
1593
+ target: splitTarget,
1594
+ cwd: workerCwd,
1595
+ ...(useSendKeysFallback ? {} : { command: workerCommand }),
1596
+ });
1565
1597
  if (split.exitCode !== 0)
1566
1598
  throw new Error(split.stderr.toString().trim() || `memory_guard_split_failed:${input.worker.id}`);
1567
1599
  const newPaneId = split.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
1568
1600
  if (!newPaneId.startsWith("%")) throw new Error(`memory_guard_split_missing_pane:${input.worker.id}`);
1569
1601
  if (useSendKeysFallback) {
1570
- Bun.spawnSync([input.config.tmux_command, "send-keys", "-l", "-t", newPaneId, workerCommand], {
1571
- stdout: "ignore",
1572
- stderr: "ignore",
1602
+ executeTeamTmuxMutation(input.config, {
1603
+ type: "literal-send",
1604
+ paneId: newPaneId,
1605
+ text: workerCommand,
1606
+ deferredProof: "worker-startup-ack",
1573
1607
  });
1574
- Bun.spawnSync([input.config.tmux_command, "send-keys", "-t", newPaneId, "Enter"], {
1575
- stdout: "ignore",
1576
- stderr: "ignore",
1608
+ executeTeamTmuxMutation(input.config, {
1609
+ type: "key-send",
1610
+ paneId: newPaneId,
1611
+ key: "Enter",
1612
+ deferredProof: "worker-startup-ack",
1577
1613
  });
1578
1614
  }
1579
1615
  const startupDeadline = Date.now() + input.startupAckTimeoutMs;
@@ -1591,36 +1627,24 @@ async function relaunchWorkerPaneForMemoryGuard(input: {
1591
1627
  // The successor may still be publishing its generation-bound ACK.
1592
1628
  }
1593
1629
  if (Date.now() >= startupDeadline) {
1594
- Bun.spawnSync([input.config.tmux_command, "kill-pane", "-t", newPaneId], {
1595
- stdout: "ignore",
1596
- stderr: "ignore",
1597
- });
1630
+ executeTeamTmuxMutation(input.config, { type: "kill-pane", paneId: newPaneId });
1598
1631
  throw new Error(`memory_guard_successor_startup_timeout:${input.worker.id}`);
1599
1632
  }
1600
1633
  await Bun.sleep(50);
1601
1634
  }
1602
1635
  const successorPane = probePaneTeamTarget(input.config, newPaneId);
1603
1636
  if (!successorPane.exists || !successorPane.belongsToTeamTarget) {
1604
- Bun.spawnSync([input.config.tmux_command, "kill-pane", "-t", newPaneId], {
1605
- stdout: "ignore",
1606
- stderr: "ignore",
1607
- });
1637
+ executeTeamTmuxMutation(input.config, { type: "kill-pane", paneId: newPaneId });
1608
1638
  throw new Error(`memory_guard_successor_pane_unavailable:${input.worker.id}`);
1609
1639
  }
1610
1640
  if (input.worker.pane_id) {
1611
1641
  const oldPane = probePaneTeamTarget(input.config, input.worker.pane_id);
1612
1642
  if (oldPane.exists && !oldPane.belongsToTeamTarget) {
1613
- Bun.spawnSync([input.config.tmux_command, "kill-pane", "-t", newPaneId], {
1614
- stdout: "ignore",
1615
- stderr: "ignore",
1616
- });
1643
+ executeTeamTmuxMutation(input.config, { type: "kill-pane", paneId: newPaneId });
1617
1644
  throw new Error(`memory_guard_old_pane_outside_team_target:${input.worker.id}`);
1618
1645
  }
1619
1646
  }
1620
- Bun.spawnSync([input.config.tmux_command, "select-layout", "-t", input.config.tmux_target, "main-vertical"], {
1621
- stdout: "ignore",
1622
- stderr: "ignore",
1623
- });
1647
+ executeTeamTmuxMutation(input.config, { type: "layout", target: input.config.tmux_target, layout: "main-vertical" });
1624
1648
  return newPaneId;
1625
1649
  }
1626
1650
 
@@ -1953,10 +1977,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
1953
1977
  postAckAuthority.claim.leased_until !== task?.claim?.leased_until ||
1954
1978
  Date.parse(postAckAuthority.claim.leased_until) <= currentTimeMs()
1955
1979
  ) {
1956
- Bun.spawnSync([config.tmux_command, "kill-pane", "-t", newPaneId], {
1957
- stdout: "ignore",
1958
- stderr: "ignore",
1959
- });
1980
+ executeTeamTmuxMutation(config, { type: "kill-pane", paneId: newPaneId });
1960
1981
  await restorePredecessorStartupState();
1961
1982
  return {
1962
1983
  ok: true,
@@ -1967,10 +1988,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
1967
1988
  }
1968
1989
  const successorPane = probePaneTeamTarget(config, newPaneId);
1969
1990
  if (!successorPane.exists || !successorPane.belongsToTeamTarget || !successorPane.pid) {
1970
- Bun.spawnSync([config.tmux_command, "kill-pane", "-t", newPaneId], {
1971
- stdout: "ignore",
1972
- stderr: "ignore",
1973
- });
1991
+ executeTeamTmuxMutation(config, { type: "kill-pane", paneId: newPaneId });
1974
1992
  await restorePredecessorStartupState();
1975
1993
  return {
1976
1994
  ok: true,
@@ -2000,10 +2018,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
2000
2018
  updated_at: nowIso,
2001
2019
  };
2002
2020
  const rollbackReplacement = async (): Promise<void> => {
2003
- Bun.spawnSync([config.tmux_command, "kill-pane", "-t", newPaneId], {
2004
- stdout: "ignore",
2005
- stderr: "ignore",
2006
- });
2021
+ executeTeamTmuxMutation(config, { type: "kill-pane", paneId: newPaneId });
2007
2022
  if (previousHeartbeat === undefined) await fs.rm(heartbeatPath, { force: true });
2008
2023
  else await Bun.write(heartbeatPath, previousHeartbeat);
2009
2024
  await syncTeamConfigAndManifest(dir, config);
@@ -2022,12 +2037,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
2022
2037
  if (!cutoverPane.exists || !cutoverPane.belongsToTeamTarget || cutoverPane.pid !== successorHeartbeat.pid)
2023
2038
  throw new Error(`memory_guard_successor_pane_changed:${worker.id}`);
2024
2039
  if (worker.pane_id && !config.dry_run) {
2025
- const killed = Bun.spawnSync([config.tmux_command, "kill-pane", "-t", worker.pane_id], {
2026
- stdout: "ignore",
2027
- stderr: "pipe",
2028
- });
2029
- if (killed.exitCode !== 0 && probePaneTeamTarget(config, worker.pane_id).exists)
2030
- throw new Error(killed.stderr.toString().trim() || `memory_guard_kill_failed:${worker.id}`);
2040
+ executeTeamTmuxMutation(config, { type: "kill-pane", paneId: worker.pane_id });
2031
2041
  }
2032
2042
  } catch (error) {
2033
2043
  await rollbackReplacement();
@@ -2138,6 +2148,7 @@ const workerRuntime: GjcTeamWorkerRuntime = {
2138
2148
  workerDir,
2139
2149
  readJson: readJsonFile,
2140
2150
  writeJson: writeJsonFile,
2151
+ withTaskMutation: (dir, fn) => withGjcTeamTaskMutation(taskStore(dir), fn),
2141
2152
  appendEvent,
2142
2153
  now,
2143
2154
  nowMs: currentTimeMs,
@@ -2505,57 +2516,86 @@ function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
2505
2516
  const suffix = detail?.trim() ? `:${detail.trim()}` : "";
2506
2517
  return `gjc_team_requires_tmux_leader: start a tmux session first (run \`gjc --tmux\`, or launch tmux yourself), then run \`gjc team ...\` inside it, or use \`gjc team --dry-run\` for state-only smoke tests${suffix}`;
2507
2518
  }
2508
- function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): string {
2519
+ function providerExecutableArgv(authority: ProviderAuthority): string[] {
2520
+ if (
2521
+ process.platform === "win32" &&
2522
+ authority.kind === "native-tmux" &&
2523
+ path.isAbsolute(authority.command) &&
2524
+ path.extname(authority.command) === ""
2525
+ )
2526
+ return [
2527
+ path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
2528
+ authority.command.replaceAll("\\", "/"),
2529
+ ];
2530
+ return [authority.command];
2531
+ }
2532
+ function readGjcTmuxProfileValue(authority: ProviderAuthority, sessionName: string): string {
2509
2533
  const result = Bun.spawnSync(
2510
- [tmuxCommand, "show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
2511
- {
2512
- stdout: "pipe",
2513
- stderr: "pipe",
2514
- },
2534
+ [
2535
+ ...providerExecutableArgv(authority),
2536
+ ...buildTmuxProviderCommand(authority, "show-options", [
2537
+ "-qv",
2538
+ "-t",
2539
+ buildGjcTmuxExactOptionTarget(sessionName, { binary: authority.binary }),
2540
+ GJC_TMUX_PROFILE_OPTION,
2541
+ ]),
2542
+ ],
2543
+ { stdout: "pipe", stderr: "pipe" },
2515
2544
  );
2516
2545
  if (result.exitCode !== 0) return "";
2517
2546
  return result.stdout.toString().trim();
2518
2547
  }
2519
2548
 
2520
- function tagTmuxSessionAsGjcLeader(tmuxCommand: string, sessionName: string): boolean {
2549
+ function tagTmuxSessionAsGjcLeader(authority: ProviderAuthority, sessionName: string): boolean {
2550
+ assertGjcTmuxMutationAuthoritySync(authority);
2521
2551
  const result = Bun.spawnSync(
2522
2552
  [
2523
- tmuxCommand,
2524
- "set-option",
2525
- "-t",
2526
- buildGjcTmuxExactOptionTarget(sessionName),
2527
- GJC_TMUX_PROFILE_OPTION,
2528
- GJC_TMUX_PROFILE_VALUE,
2553
+ ...providerExecutableArgv(authority),
2554
+ ...buildTmuxProviderCommand(authority, "set-option", [
2555
+ "-t",
2556
+ buildGjcTmuxExactOptionTarget(sessionName, { binary: authority.binary }),
2557
+ GJC_TMUX_PROFILE_OPTION,
2558
+ GJC_TMUX_PROFILE_VALUE,
2559
+ ]),
2529
2560
  ],
2530
- {
2531
- stdout: "pipe",
2532
- stderr: "pipe",
2533
- },
2561
+ { stdout: "pipe", stderr: "pipe" },
2534
2562
  );
2535
- return result.exitCode === 0;
2563
+ if (result.exitCode !== 0) return false;
2564
+ assertGjcTmuxMutationAuthoritySync(authority);
2565
+ return readGjcTmuxProfileValue(authority, sessionName) === GJC_TMUX_PROFILE_VALUE;
2536
2566
  }
2537
2567
 
2538
2568
  function readCurrentTmuxLeaderContext(
2539
2569
  tmuxCommand: string,
2540
2570
  env: NodeJS.ProcessEnv,
2541
- adoptUnmanagedSession = true,
2571
+ authority: ProviderAuthority,
2572
+ verifyProfile = true,
2542
2573
  ): GjcTmuxLeaderContext {
2543
- if (Bun.which(tmuxCommand) === null)
2574
+ if (!path.isAbsolute(tmuxCommand) && Bun.which(tmuxCommand) === null)
2544
2575
  throw new Error(buildTeamTmuxLeaderRequirementMessage(`tmux_not_installed:${tmuxCommand}`));
2545
2576
  // Prefer the explicit GJC-managed session name propagated by `gjc --tmux`
2546
2577
  // (GJC_TMUX_ACTIVE_SESSION). Under psmux on Windows the inherited TMUX_PANE
2547
2578
  // can resolve to the wrong/default session, so querying the tagged session
2548
2579
  // by name is authoritative for GJC-launched leaders. Fall back to TMUX_PANE,
2549
2580
  // then to the ambient session, to keep native tmux/WSL flows unchanged.
2550
- const activeSession = env[GJC_TMUX_ACTIVE_SESSION_ENV]?.trim();
2551
- const displayTarget = activeSession ? buildGjcTmuxExactOptionTarget(activeSession, { env }) : env.TMUX_PANE?.trim();
2581
+ const activeSession =
2582
+ authority.kind === "windows-psmux" || env.TMUX_PANE?.trim()
2583
+ ? env[GJC_TMUX_ACTIVE_SESSION_ENV]?.trim()
2584
+ : undefined;
2585
+ const displayTarget = activeSession
2586
+ ? buildGjcTmuxExactOptionTarget(activeSession, { env, binary: authority.binary })
2587
+ : env.TMUX_PANE?.trim();
2552
2588
  const args = displayTarget
2553
2589
  ? ["display-message", "-p", "-t", displayTarget, "#S:#I #{pane_id}"]
2554
2590
  : ["display-message", "-p", "#S:#I #{pane_id}"];
2555
- const result = Bun.spawnSync([tmuxCommand, ...args], {
2556
- stdout: "pipe",
2557
- stderr: "pipe",
2558
- });
2591
+ const result = Bun.spawnSync(
2592
+ [...providerExecutableArgv(authority), ...buildTmuxProviderCommand(authority, args[0]!, args.slice(1))],
2593
+ {
2594
+ stdout: "pipe",
2595
+ env,
2596
+ stderr: "pipe",
2597
+ },
2598
+ );
2559
2599
  if (result.exitCode !== 0) {
2560
2600
  // Distinguish "you are not inside any tmux session" from a genuine tmux
2561
2601
  // query failure so the caller gets actionable guidance instead of raw
@@ -2572,7 +2612,7 @@ function readCurrentTmuxLeaderContext(
2572
2612
  const [sessionName = "", windowIndex = ""] = sessionAndWindow.split(":");
2573
2613
  if (!sessionName || !windowIndex || !leaderPaneId.startsWith("%"))
2574
2614
  throw new Error(buildTeamTmuxLeaderRequirementMessage(`invalid_tmux_context:${result.stdout.toString().trim()}`));
2575
- if (adoptUnmanagedSession && readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
2615
+ if (verifyProfile && readGjcTmuxProfileValue(authority, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
2576
2616
  // Adopt any real tmux leader as a GJC team leader — including a session
2577
2617
  // the user created outside `gjc --tmux` — by writing GJC's @gjc-profile
2578
2618
  // ownership tag and reading it back. A provider that round-trips tmux
@@ -2580,8 +2620,8 @@ function readCurrentTmuxLeaderContext(
2580
2620
  // not (e.g. psmux on Windows) drops it, so the readback still fails and
2581
2621
  // the leader is rejected as unmanaged. This also self-heals a genuine
2582
2622
  // `gjc --tmux` pane that lost its @gjc-profile tag mid-startup.
2583
- const tagged = tagTmuxSessionAsGjcLeader(tmuxCommand, sessionName);
2584
- if (!tagged || readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE)
2623
+ const tagged = tagTmuxSessionAsGjcLeader(authority, sessionName);
2624
+ if (!tagged || readGjcTmuxProfileValue(authority, sessionName) !== GJC_TMUX_PROFILE_VALUE)
2585
2625
  throw new Error(
2586
2626
  buildTeamTmuxLeaderRequirementMessage(
2587
2627
  `unmanaged_tmux_session:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`,
@@ -2603,7 +2643,26 @@ export function probeGjcTeamAvailability(
2603
2643
  env: NodeJS.ProcessEnv = process.env,
2604
2644
  ): { available: true } | { available: false; reason: string } {
2605
2645
  try {
2606
- readCurrentTmuxLeaderContext(resolveGjcTmuxCommand(env), env, false);
2646
+ const stateDir = env[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim();
2647
+ const sessionId = env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim();
2648
+ const generation = env[GJC_TMUX_OWNER_GENERATION_ENV]?.trim();
2649
+ const authority =
2650
+ stateDir && sessionId && generation && hasGjcTmuxProviderAuthoritySync({ stateDir, sessionId, generation })
2651
+ ? readGjcTmuxProviderAuthoritySync({ stateDir, sessionId, generation })
2652
+ : null;
2653
+ const provider = authority ?? resolveGjcTmuxProviderContext({ env });
2654
+ if (provider.binary.isPsmux && !authority) throw new Error("gjc_team_tmux_provider_authority_unavailable");
2655
+ readCurrentTmuxLeaderContext(
2656
+ provider.command,
2657
+ env,
2658
+ authority ??
2659
+ bindGjcTmuxProviderAuthority(provider, {
2660
+ stateDir: process.cwd(),
2661
+ sessionId: "team-probe",
2662
+ generation: "probe",
2663
+ }),
2664
+ false,
2665
+ );
2607
2666
  return { available: true };
2608
2667
  } catch (error) {
2609
2668
  return { available: false, reason: error instanceof Error ? error.message : String(error) };
@@ -2676,8 +2735,12 @@ export function resolveGjcWorkerCommand(
2676
2735
  }
2677
2736
  export { buildWorkerCommand } from "./team-launch";
2678
2737
 
2679
- function shouldDispatchWorkerWithSendKeys(tmuxCommand: string, platform: NodeJS.Platform = process.platform): boolean {
2680
- return platform === "win32" || path.basename(tmuxCommand).toLowerCase() === "psmux";
2738
+ function shouldDispatchWorkerWithSendKeys(tmuxCommand: string, providerGeneration?: string): boolean {
2739
+ const command = path
2740
+ .basename(tmuxCommand)
2741
+ .toLowerCase()
2742
+ .replace(/\.exe$/, "");
2743
+ return Boolean(providerGeneration) || command === "psmux" || command === "pmux";
2681
2744
  }
2682
2745
 
2683
2746
  interface GjcTeamInitialLane {
@@ -2768,6 +2831,181 @@ function buildInitialTasks(task: string, workers: GjcTeamWorker[]): GjcTeamTask[
2768
2831
  }));
2769
2832
  }
2770
2833
 
2834
+ function configuredWindowsTmuxCommandIsNative(command: string): boolean {
2835
+ const normalized = command.trim().replace(/\\/g, "/");
2836
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
2837
+ return basename === "tmux" || basename === "tmux.exe";
2838
+ }
2839
+
2840
+ function teamProviderAuthority(config: GjcTeamConfig): ProviderAuthority {
2841
+ if (
2842
+ config.tmux_provider_generation &&
2843
+ hasGjcTmuxProviderAuthoritySync({
2844
+ stateDir: config.tmux_provider_state_dir ?? config.state_root,
2845
+ sessionId: config.tmux_provider_session_id ?? config.team_name,
2846
+ generation: config.tmux_provider_generation,
2847
+ })
2848
+ )
2849
+ return readGjcTmuxProviderAuthoritySync({
2850
+ stateDir: config.tmux_provider_state_dir ?? config.state_root,
2851
+ sessionId: config.tmux_provider_session_id ?? config.team_name,
2852
+ generation: config.tmux_provider_generation,
2853
+ });
2854
+ if (config.tmux_provider_generation && !config.dry_run)
2855
+ throw new Error("gjc_team_tmux_provider_authority_unavailable");
2856
+
2857
+ const binary = resolveGjcTmuxBinary({
2858
+ env: {
2859
+ ...process.env,
2860
+ GJC_TMUX_COMMAND: config.tmux_command,
2861
+ GJC_TEAM_TMUX_COMMAND: config.tmux_command,
2862
+ },
2863
+ });
2864
+ const context = resolveGjcTmuxProviderContext({ binary, platform: config.platform ?? process.platform });
2865
+ if (context.kind === "windows-psmux") throw new Error("gjc_team_tmux_provider_authority_unavailable");
2866
+ if ((config.platform ?? process.platform) === "win32" && !configuredWindowsTmuxCommandIsNative(config.tmux_command))
2867
+ throw new Error("gjc_team_tmux_provider_ambiguous");
2868
+ return bindGjcTmuxProviderAuthority(context, {
2869
+ stateDir: config.state_root,
2870
+ sessionId: config.team_name,
2871
+ generation: "native-tmux",
2872
+ });
2873
+ }
2874
+
2875
+ function teamTmuxArgs(config: GjcTeamConfig, command: string, args: readonly string[] = []): string[] {
2876
+ const authority = teamProviderAuthority(config);
2877
+ return [...providerExecutableArgv(authority), ...buildTmuxProviderCommand(authority, command, args)];
2878
+ }
2879
+
2880
+ type TeamTmuxMutation =
2881
+ | { type: "split"; direction: string; target: string; cwd: string; command?: string }
2882
+ | {
2883
+ type: "literal-send";
2884
+ paneId: string;
2885
+ text: string;
2886
+ deferredProof: "worker-startup-ack" | "continuation-outcome";
2887
+ }
2888
+ | { type: "key-send"; paneId: string; key: string; deferredProof: "worker-startup-ack" | "continuation-outcome" }
2889
+ | { type: "layout"; target: string; layout: string }
2890
+ | { type: "set-window-option"; target: string; name: string; value: string }
2891
+ | { type: "kill-pane"; paneId: string }
2892
+ | { type: "profile-option"; target: string; name: string; value: string }
2893
+ | { type: "profile-window-option"; target: string; name: string; value: string };
2894
+
2895
+ function readTeamTmuxValue(
2896
+ config: GjcTeamConfig,
2897
+ command: "show-options" | "show-window-options",
2898
+ target: string,
2899
+ name: string,
2900
+ ): string | undefined {
2901
+ const result = Bun.spawnSync(teamTmuxArgs(config, command, ["-qv", "-t", target, name]), {
2902
+ stdout: "pipe",
2903
+ stderr: "pipe",
2904
+ });
2905
+ if (result.exitCode !== 0) return undefined;
2906
+ return result.stdout.toString().trim();
2907
+ }
2908
+ function teamTargetExists(config: GjcTeamConfig, target: string): boolean {
2909
+ const result = Bun.spawnSync(teamTmuxArgs(config, "display-message", ["-p", "-t", target, "#S:#I"]), {
2910
+ stdout: "pipe",
2911
+ stderr: "pipe",
2912
+ });
2913
+ if (result.exitCode !== 0) return false;
2914
+ const observed = result.stdout.toString().trim().split(/\s+/)[0];
2915
+ const unprefixed = target.startsWith("=") ? target.slice(1) : target;
2916
+ const expected = unprefixed.endsWith(":") ? `${unprefixed}0` : unprefixed;
2917
+ return observed === expected || (!expected.includes(":") && observed?.startsWith(`${expected}:`) === true);
2918
+ }
2919
+
2920
+ function assertTeamTmuxMutationPreproof(config: GjcTeamConfig, operation: TeamTmuxMutation): void {
2921
+ if (operation.type === "split") {
2922
+ if (
2923
+ (operation.target !== config.tmux_target || !teamTargetExists(config, operation.target)) &&
2924
+ !probePaneTeamTarget(config, operation.target).belongsToTeamTarget
2925
+ )
2926
+ throw new Error("tmux_split_preproof_failed");
2927
+ return;
2928
+ }
2929
+ if (operation.type === "layout" || operation.type === "set-window-option") {
2930
+ if (operation.target !== config.tmux_target || !teamTargetExists(config, operation.target))
2931
+ throw new Error(`tmux_${operation.type}_session_preproof_failed`);
2932
+ return;
2933
+ }
2934
+ if (operation.type === "profile-option" || operation.type === "profile-window-option") {
2935
+ if (operation.target !== config.tmux_target || !teamTargetExists(config, operation.target))
2936
+ throw new Error(`tmux_${operation.type}_session_preproof_failed`);
2937
+ return;
2938
+ }
2939
+ const pane = probePaneTeamTarget(config, operation.paneId);
2940
+ if (operation.type === "kill-pane" ? !pane.exists || !pane.belongsToTeamTarget : !pane.belongsToTeamTarget)
2941
+ throw new Error(`tmux_${operation.type}_preproof_failed`);
2942
+ // Delivery is proven asynchronously by the generation-bound worker startup ACK
2943
+ // or continuation outcome named in the closed operation variant. Exit status
2944
+ // alone is never treated as delivery proof.
2945
+ }
2946
+
2947
+ function executeTeamTmuxMutation(
2948
+ config: GjcTeamConfig,
2949
+ operation: TeamTmuxMutation,
2950
+ ): Bun.SyncSubprocess<"pipe", "pipe"> {
2951
+ const authority = teamProviderAuthority(config);
2952
+ assertTeamTmuxMutationPreproof(config, operation);
2953
+ const args =
2954
+ operation.type === "split"
2955
+ ? [
2956
+ "split-window",
2957
+ operation.direction,
2958
+ "-t",
2959
+ operation.target,
2960
+ "-d",
2961
+ "-P",
2962
+ "-F",
2963
+ "#{pane_id}",
2964
+ "-c",
2965
+ operation.cwd,
2966
+ ...(operation.command ? [operation.command] : []),
2967
+ ]
2968
+ : operation.type === "literal-send"
2969
+ ? ["send-keys", "-l", "-t", operation.paneId, operation.text]
2970
+ : operation.type === "key-send"
2971
+ ? ["send-keys", "-t", operation.paneId, operation.key]
2972
+ : operation.type === "layout"
2973
+ ? ["select-layout", "-t", operation.target, operation.layout]
2974
+ : operation.type === "set-window-option" || operation.type === "profile-window-option"
2975
+ ? ["set-window-option", "-t", operation.target, operation.name, operation.value]
2976
+ : operation.type === "kill-pane"
2977
+ ? ["kill-pane", "-t", operation.paneId]
2978
+ : ["set-option", "-t", operation.target, operation.name, operation.value];
2979
+ assertGjcTmuxMutationAuthoritySync(authority);
2980
+ const result = Bun.spawnSync(
2981
+ [...providerExecutableArgv(authority), ...buildTmuxProviderCommand(authority, args[0]!, args.slice(1))],
2982
+ { stdout: "pipe", stderr: "pipe" },
2983
+ );
2984
+ if (result.exitCode !== 0 && operation.type !== "kill-pane")
2985
+ throw new Error(result.stderr.toString().trim() || `tmux_${operation.type}_failed`);
2986
+ assertGjcTmuxMutationAuthoritySync(authority);
2987
+ if (operation.type === "split") {
2988
+ const paneId = result.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
2989
+ if (!paneId.startsWith("%") || !probePaneTeamTarget(config, paneId).belongsToTeamTarget)
2990
+ throw new Error("tmux_split_postproof_failed");
2991
+ } else if (operation.type === "kill-pane") {
2992
+ if (probePaneTeamTarget(config, operation.paneId).exists) throw new Error("tmux_kill_postproof_failed");
2993
+ } else if (operation.type === "layout") {
2994
+ const layout = Bun.spawnSync(
2995
+ teamTmuxArgs(config, "display-message", ["-p", "-t", operation.target, "#{window_layout}"]),
2996
+ { stdout: "pipe", stderr: "pipe" },
2997
+ );
2998
+ if (layout.exitCode !== 0 || layout.stdout.toString().trim() !== operation.layout)
2999
+ throw new Error("tmux_layout_postproof_failed");
3000
+ } else if (operation.type === "set-window-option" || operation.type === "profile-window-option") {
3001
+ if (readTeamTmuxValue(config, "show-window-options", operation.target, operation.name) !== operation.value)
3002
+ throw new Error("tmux_window_option_postproof_failed");
3003
+ } else if (operation.type === "profile-option") {
3004
+ if (readTeamTmuxValue(config, "show-options", operation.target, operation.name) !== operation.value)
3005
+ throw new Error("tmux_profile_option_postproof_failed");
3006
+ }
3007
+ return result;
3008
+ }
2771
3009
  async function startTmuxSession(
2772
3010
  config: GjcTeamConfig,
2773
3011
  dir: string,
@@ -2789,24 +3027,17 @@ async function startTmuxSession(
2789
3027
  worker.index === 1 ? config.tmux_target : (rightStackRootPaneId ?? config.tmux_target);
2790
3028
  const workerCommand = buildWorkerCommand(config, worker, process.platform, undefined, env);
2791
3029
  const workerCwd = worker.worktree_path ?? config.leader.cwd;
2792
- const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(config.tmux_command);
2793
- const splitArgs = [
3030
+ const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(
2794
3031
  config.tmux_command,
2795
- "split-window",
2796
- splitDirection,
2797
- "-t",
2798
- splitTarget,
2799
- "-d",
2800
- "-P",
2801
- "-F",
2802
- "#{pane_id}",
2803
- "-c",
2804
- workerCwd,
2805
- ...(useSendKeysFallback ? [] : [workerCommand]),
2806
- ];
2807
- const split: Bun.SyncSubprocess<"pipe", "pipe"> = Bun.spawnSync(splitArgs, { stdout: "pipe", stderr: "pipe" });
2808
- if (split.exitCode !== 0)
2809
- throw new Error(split.stderr.toString().trim() || `tmux_split_failed:${config.tmux_target}:${worker.id}`);
3032
+ config.tmux_provider_generation,
3033
+ );
3034
+ const split = executeTeamTmuxMutation(config, {
3035
+ type: "split",
3036
+ direction: splitDirection,
3037
+ target: splitTarget,
3038
+ cwd: workerCwd,
3039
+ ...(useSendKeysFallback ? {} : { command: workerCommand }),
3040
+ });
2810
3041
  const paneId: string = split.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
2811
3042
  if (!paneId.startsWith("%")) throw new Error(`tmux_split_missing_pane:${config.tmux_target}:${worker.id}`);
2812
3043
  rollbackPaneIds.push(paneId);
@@ -2823,53 +3054,71 @@ async function startTmuxSession(
2823
3054
  // "Enter" as literal text too. Sending the body in literal mode first and
2824
3055
  // the Enter keypress second keeps the body verbatim while still submitting
2825
3056
  // the prompt as a keystroke.
2826
- Bun.spawnSync([config.tmux_command, "send-keys", "-l", "-t", paneId, workerCommand], {
2827
- stdout: "ignore",
2828
- stderr: "ignore",
3057
+ executeTeamTmuxMutation(config, {
3058
+ type: "literal-send",
3059
+ paneId,
3060
+ text: workerCommand,
3061
+ deferredProof: "worker-startup-ack",
2829
3062
  });
2830
- const sendKeys = Bun.spawnSync([config.tmux_command, "send-keys", "-t", paneId, "Enter"], {
2831
- stdout: "ignore",
2832
- stderr: "ignore",
3063
+ executeTeamTmuxMutation(config, {
3064
+ type: "key-send",
3065
+ paneId,
3066
+ key: "Enter",
3067
+ deferredProof: "worker-startup-ack",
2833
3068
  });
2834
- // void-cast the exit code so the linter does not flag an unused expression;
2835
- // the value is intentionally discarded here because the actual spawn outcome
2836
- // is recovered by the leader through the worker startup-ack watcher, not via
2837
- // the spawn exit code.
2838
- void sendKeys.exitCode;
2839
3069
  }
2840
3070
  }
2841
- Bun.spawnSync([config.tmux_command, "select-layout", "-t", config.tmux_target, "main-vertical"], {
2842
- stdout: "ignore",
2843
- stderr: "ignore",
2844
- });
3071
+ executeTeamTmuxMutation(config, { type: "layout", target: config.tmux_target, layout: "main-vertical" });
2845
3072
  const widthResult = Bun.spawnSync(
2846
- [config.tmux_command, "display-message", "-p", "-t", config.tmux_target, "#{window_width}"],
3073
+ teamTmuxArgs(config, "display-message", ["-p", "-t", config.tmux_target, "#{window_width}"]),
2847
3074
  { stdout: "pipe", stderr: "ignore" },
2848
3075
  );
2849
3076
  const width = Number.parseInt(widthResult.stdout.toString().trim(), 10);
2850
3077
  if (Number.isFinite(width) && width >= 40) {
2851
- Bun.spawnSync(
2852
- [
2853
- config.tmux_command,
2854
- "set-window-option",
2855
- "-t",
2856
- config.tmux_target,
2857
- "main-pane-width",
2858
- String(Math.floor(width / 2)),
2859
- ],
2860
- { stdout: "ignore", stderr: "ignore" },
2861
- );
2862
- Bun.spawnSync([config.tmux_command, "select-layout", "-t", config.tmux_target, "main-vertical"], {
2863
- stdout: "ignore",
2864
- stderr: "ignore",
3078
+ executeTeamTmuxMutation(config, {
3079
+ type: "set-window-option",
3080
+ target: config.tmux_target,
3081
+ name: "main-pane-width",
3082
+ value: String(Math.floor(width / 2)),
2865
3083
  });
3084
+ executeTeamTmuxMutation(config, { type: "layout", target: config.tmux_target, layout: "main-vertical" });
2866
3085
  }
2867
- const profileResult = applyGjcTmuxProfile({
2868
- tmuxCommand: config.tmux_command,
2869
- target: config.tmux_target,
2870
- cwd: config.leader.cwd,
3086
+ const profileCommands = buildGjcTmuxProfileCommands(
3087
+ config.tmux_target,
2871
3088
  env,
3089
+ {},
3090
+ {
3091
+ tmuxCommand: config.tmux_command,
3092
+ },
3093
+ );
3094
+ const profileFailures = profileCommands.filter(command => {
3095
+ const [kind, targetFlag, target, name, value] = command.args;
3096
+ if (
3097
+ (kind !== "set-option" && kind !== "set-window-option") ||
3098
+ targetFlag !== "-t" ||
3099
+ !target ||
3100
+ !name ||
3101
+ value === undefined ||
3102
+ command.args.length !== 5
3103
+ )
3104
+ return true;
3105
+ try {
3106
+ executeTeamTmuxMutation(config, {
3107
+ type: kind === "set-option" ? "profile-option" : "profile-window-option",
3108
+ target,
3109
+ name,
3110
+ value,
3111
+ });
3112
+ return false;
3113
+ } catch {
3114
+ return true;
3115
+ }
2872
3116
  });
3117
+ const profileResult = {
3118
+ skipped: false,
3119
+ commands: profileCommands,
3120
+ failures: profileFailures,
3121
+ };
2873
3122
  await appendTelemetry(dir, {
2874
3123
  type: "tmux_profile_applied",
2875
3124
  message: profileResult.skipped
@@ -2891,11 +3140,13 @@ async function startTmuxSession(
2891
3140
  });
2892
3141
  return workers;
2893
3142
  } catch (error) {
2894
- for (const paneId of rollbackPaneIds)
2895
- Bun.spawnSync([config.tmux_command, "kill-pane", "-t", paneId], {
2896
- stdout: "ignore",
2897
- stderr: "ignore",
2898
- });
3143
+ for (const paneId of rollbackPaneIds) {
3144
+ try {
3145
+ executeTeamTmuxMutation(config, { type: "kill-pane", paneId });
3146
+ } catch {
3147
+ // Preserve the original launch failure while making a best-effort cleanup.
3148
+ }
3149
+ }
2899
3150
  throw error;
2900
3151
  }
2901
3152
  }
@@ -2905,7 +3156,7 @@ function probePaneTeamTarget(
2905
3156
  ): { exists: boolean; belongsToTeamTarget: boolean; pid?: number } {
2906
3157
  if (paneId === config.leader.pane_id) return { exists: true, belongsToTeamTarget: false };
2907
3158
  const result = Bun.spawnSync(
2908
- [config.tmux_command, "display-message", "-p", "-t", paneId, "#S:#I #{pane_id} #{pane_pid}"],
3159
+ teamTmuxArgs(config, "display-message", ["-p", "-t", paneId, "#S:#I #{pane_id} #{pane_pid}"]),
2909
3160
  { stdout: "pipe", stderr: "ignore" },
2910
3161
  );
2911
3162
  if (result.exitCode !== 0) return { exists: false, belongsToTeamTarget: false };
@@ -2924,10 +3175,7 @@ function paneBelongsToTeamTarget(config: GjcTeamConfig, paneId: string): boolean
2924
3175
  function killWorkerPanes(config: GjcTeamConfig): void {
2925
3176
  for (const worker of config.workers)
2926
3177
  if (worker.pane_id?.startsWith("%") && paneBelongsToTeamTarget(config, worker.pane_id))
2927
- Bun.spawnSync([config.tmux_command, "kill-pane", "-t", worker.pane_id], {
2928
- stdout: "ignore",
2929
- stderr: "ignore",
2930
- });
3178
+ executeTeamTmuxMutation(config, { type: "kill-pane", paneId: worker.pane_id });
2931
3179
  }
2932
3180
  async function rollbackCreatedWorktrees(workers: GjcTeamWorker[]): Promise<void> {
2933
3181
  for (const worker of workers.filter(worker => worker.worktree_created).reverse())
@@ -3870,6 +4118,11 @@ async function validateGjcContinuationEligibility(
3870
4118
  staleMs: number,
3871
4119
  env: NodeJS.ProcessEnv,
3872
4120
  reservedHoldUntil?: string,
4121
+ // Revalidation runs after the dispatch fence has been released once for the
4122
+ // reservation write, so a non-claim task edit can legitimately land in between
4123
+ // and bump `version`. Claim identity — owner, token, lease — is what authorizes
4124
+ // continuation; a version bump alone is not a claim change.
4125
+ allowVersionDrift = false,
3873
4126
  ): Promise<string | null> {
3874
4127
  const phase = await readContinuationJson<Record<string, unknown>>(path.join(dir, "phase.json"));
3875
4128
  if (
@@ -3909,7 +4162,7 @@ async function validateGjcContinuationEligibility(
3909
4162
  if (
3910
4163
  !authority.valid ||
3911
4164
  authority.task.id !== task.id ||
3912
- authority.task.version !== task.version ||
4165
+ (!allowVersionDrift && authority.task.version !== task.version) ||
3913
4166
  authority.task.claim?.owner !== task.claim?.owner ||
3914
4167
  authority.task.claim?.token !== task.claim?.token ||
3915
4168
  authority.task.claim?.leased_until !== task.claim?.leased_until
@@ -3920,10 +4173,63 @@ async function validateGjcContinuationEligibility(
3920
4173
  if (!Number.isFinite(leaseUntil) || leaseUntil <= currentTimeMs()) return "invalid_or_expired_lease";
3921
4174
  if (holdUntil !== undefined && (!Number.isFinite(holdUntil) || leaseUntil < holdUntil))
3922
4175
  return "lease_does_not_cover_hold";
3923
- if (reservedHoldUntil !== undefined && shouldDispatchWorkerWithSendKeys(config.tmux_command))
3924
- return "unsupported_send_keys_transport";
3925
4176
  return null;
3926
4177
  }
4178
+ async function validateGjcContinuationAckAuthority(
4179
+ dir: string,
4180
+ config: GjcTeamConfig,
4181
+ worker: GjcTeamWorker,
4182
+ task: GjcTeamTask,
4183
+ heartbeatAt: string,
4184
+ staleMs: number,
4185
+ env: NodeJS.ProcessEnv,
4186
+ reservation: Record<string, unknown>,
4187
+ incident: string,
4188
+ attempt: number,
4189
+ ): Promise<string | null> {
4190
+ let currentConfig: GjcTeamConfig;
4191
+ try {
4192
+ currentConfig = await readConfig(dir);
4193
+ } catch {
4194
+ return "invalid_config_authority";
4195
+ }
4196
+ try {
4197
+ assertGjcTmuxMutationAuthoritySync(teamProviderAuthority(currentConfig));
4198
+ } catch {
4199
+ return "provider_authority_changed";
4200
+ }
4201
+ const currentWorker = currentConfig.workers.find(candidate => candidate.id === worker.id);
4202
+ if (
4203
+ !currentWorker?.pane_id ||
4204
+ currentConfig.team_name !== config.team_name ||
4205
+ currentWorker.pane_id !== reservation.pane_id ||
4206
+ !isValidGjcContinuationReservation(
4207
+ reservation,
4208
+ incident,
4209
+ attempt,
4210
+ currentConfig,
4211
+ worker.id,
4212
+ task,
4213
+ task.claim!,
4214
+ heartbeatAt,
4215
+ currentWorker.pane_id,
4216
+ )
4217
+ )
4218
+ return "reservation_authority_changed";
4219
+ const reason = await validateGjcContinuationEligibility(
4220
+ dir,
4221
+ currentConfig,
4222
+ currentWorker,
4223
+ task,
4224
+ heartbeatAt,
4225
+ staleMs,
4226
+ env,
4227
+ );
4228
+ if (reason) return reason;
4229
+ const lifecycle = (await readLifecycleById(workerRuntime, dir, currentConfig))[worker.id];
4230
+ const incarnation = `${currentWorker.pane_id ?? ""}:${lifecycle?.started_at ?? lifecycle?.updated_at}`;
4231
+ return incarnation === reservation.worker_incarnation ? null : "worker_incarnation_changed";
4232
+ }
3927
4233
  function normalizeGjcContinuationDispatchError(value: unknown, fallback: string, maxLength: number): string {
3928
4234
  try {
3929
4235
  const normalized = String(value ?? "")
@@ -4083,12 +4389,12 @@ async function continueStalledGjcTeamWorkers(
4083
4389
  )
4084
4390
  )
4085
4391
  continue;
4086
- if (
4087
- !isValidGjcContinuationOutcome(firstOutcome, firstReservation, incident, 1) ||
4088
- firstOutcome.result !== "sent"
4089
- )
4090
- continue;
4091
- const firstHold = Date.parse(String(firstOutcome.hold_until));
4392
+ if (!isValidGjcContinuationOutcome(firstOutcome, firstReservation, incident, 1)) continue;
4393
+ const firstAck = await readContinuationJson<Record<string, unknown>>(
4394
+ path.join(journalDir, "attempt-01.ack.json"),
4395
+ );
4396
+ if (!isValidGjcContinuationAck(firstAck, firstReservation, incident, 1)) continue;
4397
+ const firstHold = Date.parse(String(firstReservation.hold_until));
4092
4398
  const leaseUntil = Date.parse(task.claim.leased_until);
4093
4399
  if (
4094
4400
  !Number.isFinite(firstHold) ||
@@ -4119,90 +4425,211 @@ async function continueStalledGjcTeamWorkers(
4119
4425
  leased_until: task.claim.leased_until,
4120
4426
  heartbeat_at: heartbeat.last_turn_at,
4121
4427
  pane_id: worker.pane_id,
4428
+ worker_incarnation: `${worker.pane_id}:${lifecycle.started_at ?? lifecycle.updated_at}`,
4122
4429
  tmux_target: config.tmux_target,
4123
4430
  attempt,
4431
+ attempt_nonce: randomUUID(),
4124
4432
  reserved_at: reservedAt,
4125
4433
  hold_until: holdUntil,
4126
4434
  prompt_version: 1,
4127
- prompt_sha256: createHash("sha256").update(GJC_TEAM_CONTINUATION_PROMPT).digest("hex"),
4435
+ prompt_sha256: "",
4128
4436
  dispatch_protocol: "tmux_command_sequence_v1",
4129
4437
  };
4438
+ const continuationPrompt = buildGjcContinuationPrompt(reservation);
4439
+ reservation.prompt_sha256 = createHash("sha256").update(continuationPrompt).digest("hex");
4130
4440
  const reservationPath = path.join(journalDir, `attempt-0${attempt}.reservation.json`);
4441
+ let reservationSkipReason: string | null = null;
4131
4442
  try {
4132
- await createJsonNoClobber(
4133
- reservationPath,
4134
- reservation,
4135
- stateWriterOptions(reservationPath, "state", "continuation-reservation"),
4136
- );
4443
+ await withGjcTeamTaskMutation(taskStore(dir), async () => {
4444
+ const reservationReason = await validateGjcContinuationEligibility(
4445
+ dir,
4446
+ config,
4447
+ worker,
4448
+ task,
4449
+ heartbeat.last_turn_at,
4450
+ staleMs,
4451
+ env,
4452
+ new Date(currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS + holdMs).toISOString(),
4453
+ );
4454
+ if (reservationReason) {
4455
+ // An ineligible reservation must not abort the monitor pass for every other
4456
+ // worker. Record the same auditable `skipped` outcome the post-dispatch
4457
+ // revalidation site records, so a short or changed lease is journalled
4458
+ // rather than thrown out of `monitorGjcTeam`.
4459
+ reservationSkipReason = reservationReason;
4460
+ const skippedPath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
4461
+ await createJsonNoClobber(
4462
+ skippedPath,
4463
+ {
4464
+ schema_version: 1,
4465
+ incident_hash: incident,
4466
+ attempt,
4467
+ reservation_sha256: gjcContinuationReservationDigest(reservation),
4468
+ recorded_at: now(),
4469
+ result: "skipped",
4470
+ reason: reservationReason,
4471
+ },
4472
+ stateWriterOptions(skippedPath, "state", "continuation-outcome"),
4473
+ );
4474
+ return;
4475
+ }
4476
+ await createJsonNoClobber(
4477
+ reservationPath,
4478
+ reservation,
4479
+ stateWriterOptions(reservationPath, "state", "continuation-reservation"),
4480
+ );
4481
+ });
4137
4482
  } catch (error) {
4138
4483
  if (error instanceof AlreadyExistsError) continue;
4139
4484
  throw error;
4140
4485
  }
4486
+ if (reservationSkipReason !== null) return;
4141
4487
  let result: "sent" | "unknown" = "unknown";
4142
4488
  let outcomeReason = "tmux_missing_exit_code";
4143
4489
  let tmuxExitCode: number | undefined;
4144
4490
  let tmuxError: { name: string; code?: string; message: string } | undefined;
4145
4491
  let dispatchedAt: string | undefined;
4146
4492
  let dispatchHoldUntil: string | undefined;
4147
- if (gjcTeamRuntimeTestSeams?.continuationBeforeDispatch)
4148
- await gjcTeamRuntimeTestSeams.continuationBeforeDispatch();
4149
- const revalidationReason = await validateGjcContinuationEligibility(
4150
- dir,
4151
- config,
4152
- worker,
4153
- task,
4154
- heartbeat.last_turn_at,
4155
- staleMs,
4156
- env,
4157
- new Date(currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS + holdMs).toISOString(),
4158
- );
4159
- if (revalidationReason) {
4160
- const skippedPath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
4161
- await createJsonNoClobber(
4162
- skippedPath,
4163
- {
4164
- schema_version: 1,
4165
- incident_hash: incident,
4166
- attempt,
4167
- reservation_sha256: gjcContinuationReservationDigest(reservation),
4168
- recorded_at: now(),
4169
- result: "skipped",
4170
- reason: revalidationReason,
4171
- },
4172
- stateWriterOptions(skippedPath, "state", "continuation-outcome"),
4493
+ // The reservation above was written under the task lock, and that lock is
4494
+ // released before dispatch. Revalidation plus send-keys is therefore its own
4495
+ // critical section, and it must exclude concurrent claim mutation: otherwise a
4496
+ // claim-releasing operation (worker GC prune, stale-claim recovery) lands in
4497
+ // the gap, revalidation observes no current claim, and the incident is
4498
+ // journalled `skipped` with no continuation dispatched at all.
4499
+ //
4500
+ // Take the same team mutation fence every public authority-changing operation
4501
+ // takes, and release it before the ACK wait. The ACK is published by a
4502
+ // separate receiver process that must acquire the same cross-process fence,
4503
+ // so holding it across the wait would deadlock the very ACK being awaited.
4504
+ const attemptOutcome = await withGjcTeamMutationFence(dir, async () => {
4505
+ if (gjcTeamRuntimeTestSeams?.continuationBeforeDispatch)
4506
+ await gjcTeamRuntimeTestSeams.continuationBeforeDispatch();
4507
+ const revalidationReason = await validateGjcContinuationEligibility(
4508
+ dir,
4509
+ config,
4510
+ worker,
4511
+ task,
4512
+ heartbeat.last_turn_at,
4513
+ staleMs,
4514
+ env,
4515
+ new Date(currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS + holdMs).toISOString(),
4516
+
4517
+ true,
4173
4518
  );
4519
+ if (revalidationReason) return { kind: "skipped" as const, reason: revalidationReason };
4520
+ // Eligibility validation above proves the worker owns a real pane, but that
4521
+ // runtime proof does not narrow the optional `pane_id` field for the type
4522
+ // checker. Bind it to a proven non-empty local and dispatch through that, so
4523
+ // the frozen argv is `readonly string[]` and both send operations address the
4524
+ // identical pane. A missing pane id here is an eligibility gap, not a
4525
+ // dispatch error, so report it as the same typed skipped outcome.
4526
+ const paneId = worker.pane_id;
4527
+ if (!paneId) return { kind: "skipped" as const, reason: "worker_pane_missing" };
4528
+ try {
4529
+ const args: readonly string[] = Object.freeze([
4530
+ "send-keys",
4531
+ "-l",
4532
+ "-t",
4533
+ paneId,
4534
+ continuationPrompt,
4535
+ ";",
4536
+ "send-keys",
4537
+ "-t",
4538
+ paneId,
4539
+ "Enter",
4540
+ ]);
4541
+ const dispatch = await (gjcTeamRuntimeTestSeams?.continuationTmuxDispatch
4542
+ ? gjcTeamRuntimeTestSeams.continuationTmuxDispatch(config.tmux_command, args)
4543
+ : (() => {
4544
+ executeTeamTmuxMutation(config, {
4545
+ type: "literal-send",
4546
+ paneId,
4547
+ text: continuationPrompt,
4548
+ deferredProof: "continuation-outcome",
4549
+ });
4550
+ return executeTeamTmuxMutation(config, {
4551
+ type: "key-send",
4552
+ paneId,
4553
+ key: "Enter",
4554
+ deferredProof: "continuation-outcome",
4555
+ });
4556
+ })());
4557
+ return { kind: "dispatched" as const, exitCode: dispatch.exitCode };
4558
+ } catch (error) {
4559
+ return { kind: "threw" as const, error };
4560
+ }
4561
+ });
4562
+ if (attemptOutcome.kind === "skipped") {
4563
+ const skippedPath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
4564
+ await withGjcTeamTaskMutation(taskStore(dir), async () => {
4565
+ await createJsonNoClobber(
4566
+ skippedPath,
4567
+ {
4568
+ schema_version: 1,
4569
+ incident_hash: incident,
4570
+ attempt,
4571
+ reservation_sha256: gjcContinuationReservationDigest(reservation),
4572
+ recorded_at: now(),
4573
+ result: "skipped",
4574
+ reason: attemptOutcome.reason,
4575
+ },
4576
+ stateWriterOptions(skippedPath, "state", "continuation-outcome"),
4577
+ );
4578
+ });
4174
4579
  return;
4175
4580
  }
4176
4581
  try {
4177
- const args = Object.freeze([
4178
- "send-keys",
4179
- "-l",
4180
- "-t",
4181
- worker.pane_id,
4182
- GJC_TEAM_CONTINUATION_PROMPT,
4183
- ";",
4184
- "send-keys",
4185
- "-t",
4186
- worker.pane_id,
4187
- "Enter",
4188
- ]);
4189
- const dispatch = gjcTeamRuntimeTestSeams?.continuationTmuxDispatch
4190
- ? gjcTeamRuntimeTestSeams.continuationTmuxDispatch(config.tmux_command, args)
4191
- : Bun.spawnSync([config.tmux_command, ...args], {
4192
- stdout: "ignore",
4193
- stderr: "ignore",
4194
- timeout: GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS,
4195
- });
4196
- const dispatchAtMs = currentTimeMs();
4197
- tmuxExitCode = dispatch.exitCode;
4198
- if (dispatch.exitCode === 0) {
4199
- result = "sent";
4200
- outcomeReason = "tmux_sent";
4201
- dispatchedAt = new Date(dispatchAtMs).toISOString();
4202
- dispatchHoldUntil = new Date(dispatchAtMs + holdMs).toISOString();
4203
- } else if (typeof dispatch.exitCode === "number") {
4204
- outcomeReason = "tmux_nonzero_exit";
4205
- }
4582
+ if (attemptOutcome.kind === "threw") throw attemptOutcome.error;
4583
+ tmuxExitCode = attemptOutcome.exitCode;
4584
+ if (attemptOutcome.exitCode === 0) {
4585
+ // Bound the ack wait on the seamed clock so a test that advances `nowMs`
4586
+ // exhausts the dispatch budget deterministically instead of sleeping.
4587
+ // The wait loop advances EITHER the seamed clock (a test-supplied
4588
+ // continuationAckPoll) OR wall time (the real Bun.sleep below). Bounding
4589
+ // only one lets the other run forever: with a frozen fake clock and no ack
4590
+ // seam, a seamed-only deadline never trips. Bound both.
4591
+ const ackDeadline = currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS;
4592
+ const ackWallDeadline = Date.now() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS;
4593
+ while (true) {
4594
+ const ackAuthorityReason = await validateGjcContinuationAckAuthority(
4595
+ dir,
4596
+ config,
4597
+ worker,
4598
+ task,
4599
+ heartbeat.last_turn_at,
4600
+ staleMs,
4601
+ env,
4602
+ reservation,
4603
+ incident,
4604
+ attempt,
4605
+ );
4606
+ if (ackAuthorityReason) {
4607
+ outcomeReason = `continuation_${ackAuthorityReason}`;
4608
+ break;
4609
+ }
4610
+ const ack = await readContinuationJson<Record<string, unknown>>(
4611
+ path.join(journalDir, `attempt-0${attempt}.ack.json`),
4612
+ );
4613
+ if (isValidGjcContinuationAck(ack, reservation, incident, attempt)) {
4614
+ result = "sent";
4615
+ outcomeReason = "tmux_sent";
4616
+ dispatchedAt = now();
4617
+ dispatchHoldUntil = new Date(
4618
+ Date.parse(dispatchedAt) + (attempt === 1 ? 30_000 : 120_000),
4619
+ ).toISOString();
4620
+ break;
4621
+ }
4622
+ if (currentTimeMs() >= ackDeadline || Date.now() >= ackWallDeadline) {
4623
+ outcomeReason = "tmux_exit_zero_unacknowledged";
4624
+ break;
4625
+ }
4626
+ if (gjcTeamRuntimeTestSeams?.continuationAckPoll) {
4627
+ await gjcTeamRuntimeTestSeams.continuationAckPoll();
4628
+ } else {
4629
+ await Bun.sleep(GJC_TEAM_CONTINUATION_ACK_POLL_MS);
4630
+ }
4631
+ }
4632
+ } else if (typeof attemptOutcome.exitCode === "number") outcomeReason = "tmux_nonzero_exit";
4206
4633
  } catch (error) {
4207
4634
  outcomeReason = "tmux_dispatch_threw";
4208
4635
  const name = normalizeGjcContinuationDispatchError(
@@ -4226,31 +4653,58 @@ async function continueStalledGjcTeamWorkers(
4226
4653
  };
4227
4654
  }
4228
4655
  const outcomePath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
4229
- try {
4230
- await createJsonNoClobber(
4231
- outcomePath,
4232
- {
4233
- schema_version: 1,
4234
- incident_hash: incident,
4656
+ await withGjcTeamTaskMutation(taskStore(dir), async () => {
4657
+ if (result === "sent") {
4658
+ const outcomeAuthorityReason = await validateGjcContinuationAckAuthority(
4659
+ dir,
4660
+ config,
4661
+ worker,
4662
+ task,
4663
+ heartbeat.last_turn_at,
4664
+ staleMs,
4665
+ env,
4666
+ reservation,
4667
+ incident,
4235
4668
  attempt,
4236
- reservation_sha256: gjcContinuationReservationDigest(reservation),
4237
- recorded_at: now(),
4238
- result,
4239
- reason: outcomeReason,
4240
- ...(tmuxExitCode === undefined ? {} : { tmux_exit_code: tmuxExitCode }),
4241
- ...(dispatchedAt === undefined || dispatchHoldUntil === undefined
4242
- ? {}
4243
- : { dispatched_at: dispatchedAt, hold_until: dispatchHoldUntil }),
4244
- ...(tmuxError ? { tmux_error: tmuxError } : {}),
4245
- },
4246
- stateWriterOptions(outcomePath, "state", "continuation-outcome"),
4247
- );
4248
- } catch (error) {
4249
- if (!(error instanceof AlreadyExistsError)) throw error;
4250
- const existing = await readContinuationJson<Record<string, unknown>>(outcomePath);
4251
- if (!isValidGjcContinuationOutcome(existing, reservation, incident, attempt))
4252
- throw new Error(`invalid_continuation_outcome:${incident}:${attempt}`);
4253
- }
4669
+ );
4670
+ const ack = await readContinuationJson<Record<string, unknown>>(
4671
+ path.join(journalDir, `attempt-0${attempt}.ack.json`),
4672
+ );
4673
+ if (outcomeAuthorityReason || !isValidGjcContinuationAck(ack, reservation, incident, attempt)) {
4674
+ result = "unknown";
4675
+ outcomeReason = outcomeAuthorityReason
4676
+ ? `continuation_${outcomeAuthorityReason}`
4677
+ : "tmux_exit_zero_unacknowledged";
4678
+ dispatchedAt = undefined;
4679
+ dispatchHoldUntil = undefined;
4680
+ }
4681
+ }
4682
+ try {
4683
+ await createJsonNoClobber(
4684
+ outcomePath,
4685
+ {
4686
+ schema_version: 1,
4687
+ incident_hash: incident,
4688
+ attempt,
4689
+ reservation_sha256: gjcContinuationReservationDigest(reservation),
4690
+ recorded_at: now(),
4691
+ result,
4692
+ reason: outcomeReason,
4693
+ ...(tmuxExitCode === undefined ? {} : { tmux_exit_code: tmuxExitCode }),
4694
+ ...(result === "sent" && dispatchedAt && dispatchHoldUntil
4695
+ ? { dispatched_at: dispatchedAt, hold_until: dispatchHoldUntil }
4696
+ : {}),
4697
+ ...(tmuxError ? { tmux_error: tmuxError } : {}),
4698
+ },
4699
+ stateWriterOptions(outcomePath, "state", "continuation-outcome"),
4700
+ );
4701
+ } catch (error) {
4702
+ if (!(error instanceof AlreadyExistsError)) throw error;
4703
+ const existing = await readContinuationJson<Record<string, unknown>>(outcomePath);
4704
+ if (!isValidGjcContinuationOutcome(existing, reservation, incident, attempt))
4705
+ throw new Error(`invalid_continuation_outcome:${incident}:${attempt}`);
4706
+ }
4707
+ });
4254
4708
  return;
4255
4709
  }
4256
4710
  }
@@ -4263,9 +4717,9 @@ export async function monitorGjcTeam(
4263
4717
  const dir = await findTeamDir(teamName, cwd, env);
4264
4718
  const config = await readConfig(dir);
4265
4719
  const previous = await readJsonFile<GjcTeamMonitorSnapshot>(monitorSnapshotPath(dir));
4720
+ await continueStalledGjcTeamWorkers(dir, config, env);
4266
4721
  await withGjcTeamTaskMutation(taskStore(dir), async capability => {
4267
4722
  const config = await readConfig(dir);
4268
- await continueStalledGjcTeamWorkers(dir, config, env);
4269
4723
  await reconcileGjcTeamStaleClaimsUnlocked(workerOrchestrationRuntime, teamName, dir, config, env, capability);
4270
4724
  await computeLifecycleNudges(config, dir, cwd, env);
4271
4725
  });
@@ -4309,6 +4763,15 @@ async function writeGjcWorkerStartupAck(
4309
4763
  ): Promise<Record<string, unknown>> {
4310
4764
  return writeWorkerStartupAck(workerRuntime, teamName, worker, cwd, env, input);
4311
4765
  }
4766
+ async function writeGjcWorkerContinuationAck(
4767
+ teamName: string,
4768
+ worker: string,
4769
+ cwd: string,
4770
+ env: NodeJS.ProcessEnv,
4771
+ input: Record<string, unknown>,
4772
+ ): Promise<Record<string, unknown>> {
4773
+ return writeWorkerContinuationAck(workerRuntime, teamName, worker, cwd, env, input);
4774
+ }
4312
4775
  function parseDurationEnv(env: NodeJS.ProcessEnv, name: string, fallbackMs: number): number {
4313
4776
  const raw = env[name]?.trim();
4314
4777
  if (!raw) return fallbackMs;
@@ -5183,6 +5646,8 @@ export async function executeGjcTeamApiOperation(
5183
5646
  }
5184
5647
  case "worker-startup-ack":
5185
5648
  return writeGjcWorkerStartupAck(teamName, worker, cwd, env, input);
5649
+ case "worker-continuation-ack":
5650
+ return writeGjcWorkerContinuationAck(teamName, worker, cwd, env, input);
5186
5651
  case "read-config":
5187
5652
  return await readConfig(await findTeamDir(teamName, cwd, env));
5188
5653
  case "read-manifest":