@gajae-code/coding-agent 0.7.7 → 0.7.8

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 (63) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/model-registry.d.ts +5 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +45 -0
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  12. package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
  13. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  14. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  15. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  16. package/dist/types/modes/components/model-selector.d.ts +2 -0
  17. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  18. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  19. package/dist/types/modes/components/status-line.d.ts +4 -3
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  22. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  23. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  24. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  25. package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
  26. package/package.json +7 -7
  27. package/src/cli/local-provider-smoke.ts +632 -0
  28. package/src/cli/notify-cli.ts +4 -2
  29. package/src/cli/update-cli.ts +155 -13
  30. package/src/cli.ts +18 -0
  31. package/src/commands/launch.ts +46 -1
  32. package/src/commands/local-provider.ts +62 -0
  33. package/src/commands/notify.ts +1 -1
  34. package/src/config/model-registry.ts +55 -1
  35. package/src/config/models-config-schema.ts +9 -0
  36. package/src/config/settings-schema.ts +47 -0
  37. package/src/config/settings.ts +11 -0
  38. package/src/coordinator-mcp/server.ts +276 -34
  39. package/src/gjc-runtime/launch-tmux.ts +34 -8
  40. package/src/gjc-runtime/launch-worktree.ts +16 -2
  41. package/src/gjc-runtime/team-runtime.ts +21 -6
  42. package/src/gjc-runtime/tmux-common.ts +16 -0
  43. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/modes/components/agent-dashboard.ts +2 -1
  46. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  47. package/src/modes/components/model-selector.ts +182 -5
  48. package/src/modes/components/session-observer-overlay.ts +3 -3
  49. package/src/modes/components/settings-selector.ts +491 -5
  50. package/src/modes/components/status-line/segments.ts +7 -18
  51. package/src/modes/components/status-line/types.ts +6 -2
  52. package/src/modes/components/status-line.ts +69 -35
  53. package/src/modes/controllers/event-controller.ts +98 -4
  54. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  55. package/src/modes/controllers/input-controller.ts +15 -0
  56. package/src/modes/controllers/selector-controller.ts +12 -4
  57. package/src/modes/prompt-action-autocomplete.ts +8 -0
  58. package/src/modes/tmux-scroll.ts +92 -0
  59. package/src/modes/utils/terminal-bell.ts +48 -0
  60. package/src/notifications/telegram-daemon-cli.ts +112 -9
  61. package/src/notifications/telegram-daemon.ts +4 -0
  62. package/src/session/agent-session.ts +74 -5
  63. package/src/slash-commands/builtin-registry.ts +106 -13
@@ -119,7 +119,7 @@ interface TurnRecord {
119
119
  target: string | null;
120
120
  tmux_keys_sent?: boolean;
121
121
  prompt_acknowledged?: boolean;
122
- state?: "queued" | "tmux_keys_sent" | "acknowledged" | "unavailable";
122
+ state?: "queued" | "tmux_keys_sent" | "acknowledged" | "unavailable" | "unacknowledged";
123
123
  attempts: Array<{
124
124
  delivered: boolean;
125
125
  created_at: string;
@@ -214,6 +214,9 @@ interface CoordinatorEventInput {
214
214
  }
215
215
 
216
216
  const MISSING_FINAL_RESPONSE_ADVISORY = "completion_missing_final_response";
217
+ const PROMPT_ACK_TIMEOUT_REASON = "runtime_prompt_ack_timeout";
218
+ const DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 10_000;
219
+ const MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 5 * 60 * 1000;
217
220
  const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
218
221
  const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
219
222
  const TURN_ID_PATTERN = /^turn-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -317,8 +320,14 @@ function toolSchema(name: CoordinatorToolName): {
317
320
  properties: {
318
321
  session_id: sessionId,
319
322
  turn_id: { type: "string" },
320
- timeout_ms: { type: "number" },
321
- poll_interval_ms: { type: "number" },
323
+ timeout_ms: {
324
+ type: "number",
325
+ description: "Bounded await timeout in milliseconds, capped at 30 minutes.",
326
+ },
327
+ poll_interval_ms: {
328
+ type: "number",
329
+ description: "Bounded polling interval in milliseconds, capped at 10 seconds.",
330
+ },
322
331
  lines: { type: "number" },
323
332
  },
324
333
  required: ["turn_id"],
@@ -406,7 +415,10 @@ function toolSchema(name: CoordinatorToolName): {
406
415
  after_seq: { type: "number" },
407
416
  session_id: sessionId,
408
417
  event_types: { type: "array", items: { type: "string" } },
409
- timeout_ms: { type: "number" },
418
+ timeout_ms: {
419
+ type: "number",
420
+ description: "Bounded event long-poll timeout in milliseconds, capped at 30 seconds.",
421
+ },
410
422
  limit: { type: "number" },
411
423
  },
412
424
  },
@@ -447,7 +459,8 @@ function toolSchema(name: CoordinatorToolName): {
447
459
  await_completion: { type: "boolean", description: "If true, poll the turn until terminal or timeout." },
448
460
  timeout_ms: {
449
461
  type: "number",
450
- description: "Bounded await timeout; same cap semantics as gjc_coordinator_await_turn.",
462
+ description:
463
+ "Bounded await timeout in milliseconds, capped at 30 minutes like gjc_coordinator_await_turn.",
451
464
  },
452
465
  poll_interval_ms: { type: "number", description: "Bounded await polling interval." },
453
466
  lines: { type: "number", description: "Bounded advisory tail lines returned with await/read payloads." },
@@ -1039,6 +1052,135 @@ async function markTurnTerminalFromSessionState(
1039
1052
  return resolved;
1040
1053
  }
1041
1054
 
1055
+ function runtimeStateAcknowledgesTurn(turn: TurnRecord, sessionState: CoordinatorSessionState | null): boolean {
1056
+ return (
1057
+ sessionState?.source === "agent_session_event" &&
1058
+ sessionState.current_turn_id === turn.turn_id &&
1059
+ (sessionState.state === "running" ||
1060
+ sessionState.state === "needs_user_input" ||
1061
+ sessionState.state === "completed" ||
1062
+ sessionState.state === "errored")
1063
+ );
1064
+ }
1065
+
1066
+ async function markTurnAcknowledgedFromRuntimeState(
1067
+ namespaceDir: string,
1068
+ turn: TurnRecord,
1069
+ sessionState: CoordinatorSessionState,
1070
+ ): Promise<TurnRecord> {
1071
+ if (turn.delivery.prompt_acknowledged === true && turn.delivery.state === "acknowledged") return turn;
1072
+ const timestamp = new Date().toISOString();
1073
+ const acknowledged: TurnRecord = {
1074
+ ...turn,
1075
+ delivery: {
1076
+ ...turn.delivery,
1077
+ delivered: true,
1078
+ prompt_acknowledged: true,
1079
+ state: "acknowledged",
1080
+ attempts: [
1081
+ ...turn.delivery.attempts,
1082
+ {
1083
+ delivered: true,
1084
+ created_at: sessionState.updated_at,
1085
+ reason: "runtime_prompt_acknowledged",
1086
+ channel: "runtime_ack",
1087
+ tmux_keys_sent: turn.delivery.tmux_keys_sent,
1088
+ },
1089
+ ],
1090
+ },
1091
+ updated_at: timestamp,
1092
+ };
1093
+ await writeTurnRecord(namespaceDir, acknowledged);
1094
+ await writeActiveTurn(namespaceDir, acknowledged);
1095
+ return acknowledged;
1096
+ }
1097
+
1098
+ function turnAwaitingRuntimeAckExpired(turn: TurnRecord, nowMs: number, ackTimeoutMs: number): boolean {
1099
+ if (!ACTIVE_TURN_STATUSES.has(turn.status)) return false;
1100
+ if (turn.delivery.tmux_keys_sent !== true) return false;
1101
+ if (turn.delivery.prompt_acknowledged === true) return false;
1102
+ if (turn.delivery.state !== "tmux_keys_sent") return false;
1103
+ const deliveredAt =
1104
+ turn.delivery.attempts.findLast(attempt => attempt.channel === "tmux_keys")?.created_at ?? turn.updated_at;
1105
+ const deliveredMs = Date.parse(deliveredAt);
1106
+ return Number.isFinite(deliveredMs) && nowMs - deliveredMs >= ackTimeoutMs;
1107
+ }
1108
+
1109
+ async function markTurnFailedForUnacknowledgedDelivery(
1110
+ namespaceDir: string,
1111
+ turn: TurnRecord,
1112
+ ackTimeoutMs: number,
1113
+ ): Promise<TurnRecord> {
1114
+ const timestamp = new Date().toISOString();
1115
+ const message = `Tmux key delivery succeeded, but the GJC runtime did not acknowledge the prompt or emit turn_start within ${ackTimeoutMs}ms. The turn never started; stop waiting and inspect/retry the coordinator session.`;
1116
+ const failed: TurnRecord = {
1117
+ ...turn,
1118
+ status: "failed",
1119
+ delivery: {
1120
+ ...turn.delivery,
1121
+ delivered: false,
1122
+ queued: false,
1123
+ prompt_acknowledged: false,
1124
+ state: "unacknowledged",
1125
+ attempts: [
1126
+ ...turn.delivery.attempts,
1127
+ {
1128
+ delivered: false,
1129
+ created_at: timestamp,
1130
+ reason: PROMPT_ACK_TIMEOUT_REASON,
1131
+ channel: "runtime_ack",
1132
+ tmux_keys_sent: true,
1133
+ },
1134
+ ],
1135
+ },
1136
+ final_response: {
1137
+ text: message,
1138
+ format: "markdown",
1139
+ source: "coordinator_delivery_ack_timeout",
1140
+ artifact_path: null,
1141
+ truncated: false,
1142
+ },
1143
+ error: { code: PROMPT_ACK_TIMEOUT_REASON, message, recoverable: true },
1144
+ evidence: [
1145
+ ...turn.evidence,
1146
+ {
1147
+ type: PROMPT_ACK_TIMEOUT_REASON,
1148
+ message,
1149
+ tmux_keys_sent: true,
1150
+ prompt_acknowledged: false,
1151
+ created_at: timestamp,
1152
+ },
1153
+ ],
1154
+ liveness: { checked_at: timestamp, live: turn.liveness.live, reason: PROMPT_ACK_TIMEOUT_REASON },
1155
+ updated_at: timestamp,
1156
+ completed_at: timestamp,
1157
+ };
1158
+ await writeTurnRecord(namespaceDir, failed);
1159
+ await clearActiveTurn(namespaceDir, failed);
1160
+ await writeSessionState(namespaceDir, failed.session_id, "stale", {
1161
+ lastTurnId: failed.turn_id,
1162
+ live: failed.liveness.live,
1163
+ reason: PROMPT_ACK_TIMEOUT_REASON,
1164
+ });
1165
+ return failed;
1166
+ }
1167
+
1168
+ async function reconcileRuntimeAcknowledgement(
1169
+ namespaceDir: string,
1170
+ turn: TurnRecord,
1171
+ sessionState: CoordinatorSessionState | null,
1172
+ ackTimeoutMs: number,
1173
+ options: { failOnTimeout: boolean } = { failOnTimeout: true },
1174
+ ): Promise<TurnRecord> {
1175
+ if (sessionState && runtimeStateAcknowledgesTurn(turn, sessionState)) {
1176
+ return await markTurnAcknowledgedFromRuntimeState(namespaceDir, turn, sessionState);
1177
+ }
1178
+ if (options.failOnTimeout && turnAwaitingRuntimeAckExpired(turn, Date.now(), ackTimeoutMs)) {
1179
+ return await markTurnFailedForUnacknowledgedDelivery(namespaceDir, turn, ackTimeoutMs);
1180
+ }
1181
+ return turn;
1182
+ }
1183
+
1042
1184
  function shellQuote(value: string): string {
1043
1185
  return `'${value.replaceAll("'", "'\\''")}'`;
1044
1186
  }
@@ -1086,16 +1228,33 @@ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
1086
1228
  return null;
1087
1229
  }
1088
1230
 
1089
- function boundedTimeoutMs(value: unknown): number {
1231
+ export const COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS = 30 * 60 * 1000;
1232
+ export const COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS = MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS;
1233
+ export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
1234
+ export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
1235
+
1236
+ function parsePositiveIntegerMs(value: unknown, fallback: number): number {
1090
1237
  const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
1091
- if (!Number.isFinite(parsed) || parsed <= 0) return 1000;
1092
- return Math.min(parsed, 30_000);
1238
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1093
1239
  }
1094
1240
 
1095
- function boundedPollIntervalMs(value: unknown): number {
1096
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
1097
- if (!Number.isFinite(parsed) || parsed <= 0) return 100;
1098
- return Math.min(Math.max(parsed, 10), 1000);
1241
+ export function boundedAwaitTurnTimeoutMs(value: unknown): number {
1242
+ return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS);
1243
+ }
1244
+
1245
+ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
1246
+ return Math.min(
1247
+ parsePositiveIntegerMs(value, DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS),
1248
+ COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS,
1249
+ );
1250
+ }
1251
+
1252
+ export function boundedEventWatchTimeoutMs(value: unknown): number {
1253
+ return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
1254
+ }
1255
+
1256
+ export function boundedPollIntervalMs(value: unknown): number {
1257
+ return Math.min(Math.max(parsePositiveIntegerMs(value, 100), 10), COORDINATOR_POLL_INTERVAL_MAX_MS);
1099
1258
  }
1100
1259
  async function runCommand(command: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1101
1260
  const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
@@ -1387,7 +1546,9 @@ export async function readCoordinatorArtifact(
1387
1546
  }
1388
1547
 
1389
1548
  export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions = {}) {
1390
- const config = buildCoordinatorMcpConfig(options.env ?? process.env);
1549
+ const env = options.env ?? process.env;
1550
+ const config = buildCoordinatorMcpConfig(env);
1551
+ const promptAckTimeoutMs = boundedRuntimePromptAckTimeoutMs(env.GJC_COORDINATOR_MCP_PROMPT_ACK_TIMEOUT_MS);
1391
1552
  const services = options.services ?? {};
1392
1553
  const namespaceDir = coordinatorNamespacePath(config);
1393
1554
  const commandRunner = services.commandRunner ?? runCommand;
@@ -1485,14 +1646,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1485
1646
  await writeTurnRecord(namespaceDir, activeTurn);
1486
1647
  await writeActiveTurn(namespaceDir, activeTurn);
1487
1648
  const sessionState = await readSessionState(namespaceDir, activeTurn.session_id);
1488
- const runtimeStateAlreadySettled =
1489
- sessionState?.current_turn_id === activeTurn.turn_id &&
1490
- (sessionState.state === "completed" || sessionState.state === "errored");
1491
- if (!runtimeStateAlreadySettled) {
1492
- await writeSessionState(namespaceDir, activeTurn.session_id, tmuxKeysSent ? "running" : "stale", {
1649
+ const runtimeStateAlreadyAcknowledged =
1650
+ sessionState !== null && runtimeStateAcknowledgesTurn(activeTurn, sessionState);
1651
+ const resolvedTurn =
1652
+ runtimeStateAlreadyAcknowledged && sessionState
1653
+ ? await markTurnAcknowledgedFromRuntimeState(namespaceDir, activeTurn, sessionState)
1654
+ : activeTurn;
1655
+ if (!runtimeStateAlreadyAcknowledged && !tmuxKeysSent) {
1656
+ await writeSessionState(namespaceDir, activeTurn.session_id, "stale", {
1493
1657
  currentTurnId: activeTurn.turn_id,
1494
1658
  live,
1495
- reason: tmuxKeysSent ? null : "tmux_delivery_unavailable",
1659
+ reason: "tmux_delivery_unavailable",
1496
1660
  });
1497
1661
  }
1498
1662
  await appendCoordinatorEvent(namespaceDir, {
@@ -1505,7 +1669,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1505
1669
  payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, activeTurn.turn_id)),
1506
1670
  metadata: { target: typeof target === "string" ? target : null, live },
1507
1671
  });
1508
- return activeTurn;
1672
+ return resolvedTurn;
1509
1673
  }
1510
1674
 
1511
1675
  async function promoteNextQueuedTurn(sessionId: string): Promise<TurnRecord | null> {
@@ -1533,33 +1697,67 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1533
1697
  let resolvedTurn = turn;
1534
1698
  let advisoryStatus: Record<string, unknown> = { live: false };
1535
1699
  let sessionState = await readSessionState(namespaceDir, turn.session_id);
1700
+ resolvedTurn = await reconcileRuntimeAcknowledgement(
1701
+ namespaceDir,
1702
+ resolvedTurn,
1703
+ sessionState,
1704
+ promptAckTimeoutMs,
1705
+ { failOnTimeout: false },
1706
+ );
1707
+ if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1536
1708
  if (
1537
1709
  sessionState &&
1538
- ACTIVE_TURN_STATUSES.has(turn.status) &&
1539
- sessionState.current_turn_id === turn.turn_id &&
1710
+ ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
1711
+ (sessionState.current_turn_id === resolvedTurn.turn_id ||
1712
+ (sessionState.state === "errored" &&
1713
+ sessionState.source === "agent_session_event" &&
1714
+ sessionState.current_turn_id == null)) &&
1540
1715
  (sessionState.state === "completed" || sessionState.state === "errored")
1541
1716
  ) {
1542
- resolvedTurn = await markTurnTerminalFromSessionState(namespaceDir, turn, sessionState);
1717
+ resolvedTurn = await markTurnTerminalFromSessionState(namespaceDir, resolvedTurn, sessionState);
1543
1718
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1544
1719
  } else if (
1545
1720
  sessionState &&
1546
- ACTIVE_TURN_STATUSES.has(turn.status) &&
1547
- sessionState.current_turn_id === turn.turn_id &&
1721
+ ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
1722
+ sessionState.current_turn_id === resolvedTurn.turn_id &&
1548
1723
  sessionState.state === "stale" &&
1549
1724
  sessionState.reason === "tmux_delivery_unavailable" &&
1550
- turn.delivery.state === "unavailable" &&
1725
+ resolvedTurn.delivery.state === "unavailable" &&
1551
1726
  session &&
1552
1727
  hasTmuxIdentity(session)
1553
1728
  ) {
1554
- resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, turn, "tmux_delivery_unavailable");
1729
+ resolvedTurn = await markTurnFailedForUnavailableSession(
1730
+ namespaceDir,
1731
+ resolvedTurn,
1732
+ "tmux_delivery_unavailable",
1733
+ );
1555
1734
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1556
- } else if (!session && ACTIVE_TURN_STATUSES.has(turn.status)) {
1557
- resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, turn, "session_record_missing");
1735
+ } else if (!session && ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1736
+ resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "session_record_missing");
1558
1737
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1559
1738
  } else if (session) {
1560
1739
  advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines), commandRunner);
1561
- if (ACTIVE_TURN_STATUSES.has(turn.status) && hasTmuxIdentity(session) && advisoryStatus.live === false) {
1562
- resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, turn, "tmux_session_missing");
1740
+ if (
1741
+ ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
1742
+ hasTmuxIdentity(session) &&
1743
+ advisoryStatus.live === false
1744
+ ) {
1745
+ resolvedTurn = await markTurnFailedForUnavailableSession(
1746
+ namespaceDir,
1747
+ resolvedTurn,
1748
+ "tmux_session_missing",
1749
+ );
1750
+ sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1751
+ }
1752
+ }
1753
+ if (ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1754
+ resolvedTurn = await reconcileRuntimeAcknowledgement(
1755
+ namespaceDir,
1756
+ resolvedTurn,
1757
+ sessionState,
1758
+ promptAckTimeoutMs,
1759
+ );
1760
+ if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1563
1761
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1564
1762
  }
1565
1763
  }
@@ -1579,6 +1777,46 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1579
1777
  };
1580
1778
  }
1581
1779
 
1780
+ async function reconcileActiveTurnAcknowledgements(): Promise<void> {
1781
+ const turns = (await listJsonFiles(turnsDir(namespaceDir)))
1782
+ .map(turn => asRecord(turn) as TurnRecord | null)
1783
+ .filter((turn): turn is TurnRecord => turn !== null && ACTIVE_TURN_STATUSES.has(turn.status));
1784
+ for (const turn of turns) {
1785
+ let sessionState = await readSessionState(namespaceDir, turn.session_id);
1786
+ const resolvedTurn = await reconcileRuntimeAcknowledgement(
1787
+ namespaceDir,
1788
+ turn,
1789
+ sessionState,
1790
+ promptAckTimeoutMs,
1791
+ { failOnTimeout: false },
1792
+ );
1793
+ if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) continue;
1794
+ if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1795
+ const session = asRecord(await readJsonFile(sessionFile(resolvedTurn.session_id)));
1796
+ if (
1797
+ sessionState &&
1798
+ sessionState.current_turn_id === resolvedTurn.turn_id &&
1799
+ sessionState.state === "stale" &&
1800
+ sessionState.reason === "tmux_delivery_unavailable" &&
1801
+ resolvedTurn.delivery.state === "unavailable" &&
1802
+ session &&
1803
+ hasTmuxIdentity(session)
1804
+ ) {
1805
+ await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "tmux_delivery_unavailable");
1806
+ continue;
1807
+ }
1808
+ if (!session) {
1809
+ await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "session_record_missing");
1810
+ continue;
1811
+ }
1812
+ if (hasTmuxIdentity(session) && (await hasTmuxSession(session, commandRunner)) === false) {
1813
+ await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "tmux_session_missing");
1814
+ continue;
1815
+ }
1816
+ await reconcileRuntimeAcknowledgement(namespaceDir, resolvedTurn, sessionState, promptAckTimeoutMs);
1817
+ }
1818
+ }
1819
+
1582
1820
  async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
1583
1821
  try {
1584
1822
  if (name === "gjc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
@@ -1619,6 +1857,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1619
1857
  };
1620
1858
  }
1621
1859
  if (name === "gjc_coordinator_read_status") {
1860
+ await reconcileActiveTurnAcknowledgements();
1622
1861
  const sessionId = args.session_id;
1623
1862
  if (sessionId) {
1624
1863
  const session = asRecord(await readJsonFile(sessionFile(sessionId)));
@@ -1651,6 +1890,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1651
1890
  if (name === "gjc_coordinator_read_artifact")
1652
1891
  return await readCoordinatorArtifact(config, { path: args.path });
1653
1892
  if (name === "gjc_coordinator_read_coordination_status") {
1893
+ await reconcileActiveTurnAcknowledgements();
1654
1894
  const sessions = jsonRecords(await listSessions());
1655
1895
  const sessionStates = jsonRecords(await listJsonFiles(path.join(namespaceDir, "session-states")));
1656
1896
  const turns = jsonRecords(await listJsonFiles(turnsDir(namespaceDir)));
@@ -1684,13 +1924,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1684
1924
  };
1685
1925
  }
1686
1926
  if (name === "gjc_coordinator_watch_events") {
1927
+ await reconcileActiveTurnAcknowledgements();
1687
1928
  const limit = boundedEventLimit(args.limit);
1688
- const timeoutMs = boundedTimeoutMs(args.timeout_ms);
1929
+ const timeoutMs = boundedEventWatchTimeoutMs(args.timeout_ms);
1689
1930
  let events = await readCoordinatorEvents(namespaceDir);
1690
1931
  let matched = filterCoordinatorEvents(events, args, limit);
1691
1932
  let timedOut = false;
1692
1933
  if (matched.length === 0 && timeoutMs > 0) {
1693
1934
  await waitForCoordinatorEvents(namespaceDir, timeoutMs);
1935
+ await reconcileActiveTurnAcknowledgements();
1694
1936
  events = await readCoordinatorEvents(namespaceDir);
1695
1937
  matched = filterCoordinatorEvents(events, args, limit);
1696
1938
  timedOut = matched.length === 0;
@@ -1819,7 +2061,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1819
2061
  };
1820
2062
  if (promptAliasIgnored) base.prompt_alias_ignored = true;
1821
2063
  if (args.await_completion === true && !shouldQueue) {
1822
- const timeoutMs = boundedTimeoutMs(args.timeout_ms);
2064
+ const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
1823
2065
  const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
1824
2066
  const deadline = Date.now() + timeoutMs;
1825
2067
  let payload = await readTurnPayload(turn.turn_id, sessionId, args.lines);
@@ -1979,7 +2221,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1979
2221
  return await readTurnPayload(args.turn_id, args.session_id, args.lines);
1980
2222
  }
1981
2223
  if (name === "gjc_coordinator_await_turn") {
1982
- const timeoutMs = boundedTimeoutMs(args.timeout_ms);
2224
+ const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
1983
2225
  const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
1984
2226
  const deadline = Date.now() + timeoutMs;
1985
2227
  let payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
@@ -6,6 +6,7 @@ import type { Args } from "../cli/args";
6
6
  import { tmuxRuntimeSessionPath } from "./session-layout";
7
7
  import { GJC_COORDINATOR_SESSION_ID_ENV, GJC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
8
8
  import {
9
+ buildGjcTmuxExactSessionTarget,
9
10
  buildGjcTmuxProfileCommands,
10
11
  buildGjcTmuxSessionName,
11
12
  buildGjcTmuxSessionSlug,
@@ -21,6 +22,7 @@ import {
21
22
  import { findGjcTmuxSessionByName, findGjcTmuxSessionByScope, type GjcTmuxSessionStatus } from "./tmux-sessions";
22
23
 
23
24
  export {
25
+ buildGjcTmuxExactSessionTarget,
24
26
  buildGjcTmuxProfileCommands,
25
27
  GJC_DEFAULT_TMUX_SESSION,
26
28
  GJC_TMUX_COMMAND_ENV,
@@ -201,12 +203,24 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
201
203
  const envLines = Object.entries({ [GJC_TMUX_LAUNCHED_ENV]: "1", ...(context.extraEnv ?? {}) }).map(
202
204
  ([key, value]) => `$env:${key} = ${powershellQuote(value)}`,
203
205
  );
204
- const invocation = ["&", ...command.map(powershellQuote), ...stripRootTmuxFlag(rawArgs).map(powershellQuote)].join(
205
- " ",
206
- );
206
+ // The inner `&` invocation must wrap the resolved gjc command in a
207
+ // script-block so the trailing PowerShell exec-policy flags from the
208
+ // outer invocation are not forwarded into the bun / node / .bat
209
+ // binary the gjc command resolves to. Without the wrapping, the flags
210
+ // reach the runtime binary and cause an immediate failure that closes
211
+ // the psmux pane before the gjc --tmux attach can land.
212
+ const resolvedCommand = command.map(powershellQuote).join(" ");
213
+ const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
214
+ const invocation = `& { ${resolvedCommand} ${innerArgs} }`;
207
215
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
208
216
  const script = [...envLines, invocation, exitLine].join("\n");
209
- const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
217
+ // PowerShell -EncodedCommand requires a UTF-16LE BOM (0xFF 0xFE) at the
218
+ // start of the decoded buffer; without it pwsh may misinterpret the
219
+ // leading bytes and reject the script with a parse error, which would
220
+ // kill the psmux pane before the gjc --tmux attach could land.
221
+ const bom = Buffer.from([0xff, 0xfe]);
222
+ const body = Buffer.from(script, "utf16le");
223
+ const encodedCommand = Buffer.concat([bom, body]).toString("base64");
210
224
  return `pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
211
225
  }
212
226
 
@@ -395,7 +409,11 @@ function readCurrentBranch(cwd: string): string | null {
395
409
  }
396
410
 
397
411
  function cleanupCreatedTmuxSession(plan: TmuxLaunchPlan, spawnSync: TmuxSpawnSync, options: TmuxSpawnOptions): void {
398
- spawnSync(plan.tmuxCommand, ["kill-session", "-t", `=${plan.sessionName}`], options);
412
+ spawnSync(
413
+ plan.tmuxCommand,
414
+ ["kill-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
415
+ options,
416
+ );
399
417
  }
400
418
  function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
401
419
  if (result.signalCode === "SIGHUP") return true;
@@ -499,7 +517,11 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
499
517
  };
500
518
 
501
519
  if (plan.attachSessionName) {
502
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.attachSessionName}`], options);
520
+ const attached = spawnSync(
521
+ plan.tmuxCommand,
522
+ ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
523
+ options,
524
+ );
503
525
  if (attached.exitCode === 0) return true;
504
526
  }
505
527
 
@@ -510,7 +532,7 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
510
532
  buildGjcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
511
533
  spawnSync,
512
534
  options,
513
- `=${plan.sessionName}`,
535
+ buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
514
536
  );
515
537
 
516
538
  const profile = applyGjcTmuxProfile({
@@ -535,7 +557,11 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
535
557
  }
536
558
  }
537
559
  if (created.exitCode !== 0) return false;
538
- const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.sessionName}`], options);
560
+ const attached = spawnSync(
561
+ plan.tmuxCommand,
562
+ ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
563
+ options,
564
+ );
539
565
  if (attached.exitCode === 0) return true;
540
566
  if (isTmuxAttachDisconnectError(attached)) {
541
567
  (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr));
@@ -118,6 +118,20 @@ function findWorktreeByPath(entries: GitWorktreeEntry[], worktreePath: string):
118
118
  return entries.find(entry => path.resolve(entry.path) === resolved) ?? null;
119
119
  }
120
120
 
121
+ function describeWorktreeEntry(entry: GitWorktreeEntry): string {
122
+ return entry.detached ? `detached HEAD ${entry.head}` : (entry.branchRef ?? `HEAD ${entry.head}`);
123
+ }
124
+
125
+ function formatWorktreeTargetMismatch(plan: GjcLaunchWorktreePlan, existing: GitWorktreeEntry): string {
126
+ const expected = plan.detached ? `detached HEAD ${plan.baseRef}` : `branch refs/heads/${plan.branchName ?? ""}`;
127
+ return [
128
+ `worktree_target_mismatch:${plan.worktreePath}`,
129
+ `GJC launch worktree target is already registered for ${describeWorktreeEntry(existing)}, but this launch expects ${expected}.`,
130
+ `Path: ${plan.worktreePath}`,
131
+ "Refusing to delete or reuse the conflicting worktree automatically. Safe remediation: inspect the path, commit/stash any work, then remove or prune the stale worktree with git worktree remove <path> when it is no longer needed, or choose a different --worktree name.",
132
+ ].join("\n");
133
+ }
134
+
121
135
  function hasBranchInUse(entries: GitWorktreeEntry[], branchName: string, worktreePath: string): boolean {
122
136
  const expectedRef = `refs/heads/${branchName}`;
123
137
  const resolvedPath = path.resolve(worktreePath);
@@ -228,7 +242,7 @@ export function ensureLaunchWorktree(
228
242
  let dirty = isWorktreeDirty(plan.worktreePath);
229
243
  if (plan.detached) {
230
244
  if (!existingAtPath.detached) {
231
- throw new Error(`worktree_target_mismatch:${plan.worktreePath}`);
245
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
232
246
  }
233
247
  if (existingAtPath.head !== plan.baseRef) {
234
248
  if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
@@ -236,7 +250,7 @@ export function ensureLaunchWorktree(
236
250
  dirty = false;
237
251
  }
238
252
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
239
- throw new Error(`worktree_target_mismatch:${plan.worktreePath}`);
253
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
240
254
  }
241
255
  return {
242
256
  ...plan,
@@ -1896,13 +1896,27 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
1896
1896
  }
1897
1897
  return { sessionName, windowIndex, leaderPaneId, target: `${sessionName}:${windowIndex}` };
1898
1898
  }
1899
- export function resolveGjcWorkerCommand(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string {
1899
+ export function resolveGjcWorkerCommand(
1900
+ cwd = process.cwd(),
1901
+ env: NodeJS.ProcessEnv = process.env,
1902
+ platform: NodeJS.Platform = process.platform,
1903
+ argv: string[] = process.argv,
1904
+ execPath = process.execPath,
1905
+ ): string {
1900
1906
  const explicit = env.GJC_TEAM_WORKER_COMMAND?.trim();
1901
1907
  if (explicit) return explicit;
1902
- const entrypoint = process.argv[1];
1903
- if (entrypoint?.endsWith(".ts"))
1904
- return `${shellQuote(process.execPath)} ${shellQuote(path.resolve(cwd, entrypoint))}`;
1905
- if (entrypoint && path.basename(entrypoint).startsWith("gjc")) return shellQuote(path.resolve(cwd, entrypoint));
1908
+ const entrypoint = argv[1];
1909
+ if (!entrypoint) return "gjc";
1910
+ const pathModule = platform === "win32" ? path.win32 : path;
1911
+ const resolvedEntrypoint = pathModule.isAbsolute(entrypoint) ? entrypoint : pathModule.resolve(cwd, entrypoint);
1912
+ if (platform === "win32") {
1913
+ if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs"))
1914
+ return `${powershellQuote(execPath)} ${powershellQuote(resolvedEntrypoint)}`;
1915
+ if (pathModule.basename(entrypoint).startsWith("gjc")) return powershellQuote(resolvedEntrypoint);
1916
+ return "gjc";
1917
+ }
1918
+ if (entrypoint.endsWith(".ts")) return `${shellQuote(execPath)} ${shellQuote(resolvedEntrypoint)}`;
1919
+ if (path.basename(entrypoint).startsWith("gjc")) return shellQuote(path.resolve(cwd, entrypoint));
1906
1920
  return "gjc";
1907
1921
  }
1908
1922
  /** @internal Exported for unit tests. */
@@ -1937,7 +1951,8 @@ export function buildWorkerCommand(
1937
1951
  ...(worker.worktree_path ? [envAssignment("GJC_TEAM_WORKTREE_PATH", worker.worktree_path)] : []),
1938
1952
  ];
1939
1953
  const joined = platform === "win32" ? envLines.join(" ") : envLines.join(" ");
1940
- return `${joined} ${config.worker_command} ${quote(prompt)}`;
1954
+ const invocation = platform === "win32" ? `& ${config.worker_command}` : config.worker_command;
1955
+ return `${joined} ${invocation} ${quote(prompt)}`;
1941
1956
  }
1942
1957
  interface GjcTeamInitialLane {
1943
1958
  label: string;
@@ -86,6 +86,22 @@ export function buildGjcTmuxExactOptionTarget(
86
86
  return `=${sessionName}:`;
87
87
  }
88
88
 
89
+ /**
90
+ * Build the exact-session target for tmux *session-scoped* commands such as
91
+ * `attach-session` and `kill-session`. Native tmux accepts `=NAME` for an
92
+ * exact session match, but Windows psmux 3.3.x rejects that target form for
93
+ * session commands even though the bare `NAME` resolves. Keep native tmux on
94
+ * exact targets and intentionally use the bare session name for psmux.
95
+ */
96
+ export function buildGjcTmuxExactSessionTarget(
97
+ sessionName: string,
98
+ opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {},
99
+ ): string {
100
+ const binary = opts.binary ?? resolveGjcTmuxBinary({ env: opts.env, platform: opts.platform });
101
+ if (binary.isPsmux) return sessionName;
102
+ return `=${sessionName}`;
103
+ }
104
+
89
105
  export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
90
106
 
91
107
  export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {