@gajae-code/coding-agent 0.4.3 → 0.4.4

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 (45) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/types/async/job-manager.d.ts +19 -1
  3. package/dist/types/cli/setup-cli.d.ts +14 -1
  4. package/dist/types/commands/coordinator.d.ts +19 -0
  5. package/dist/types/commands/mcp-serve.d.ts +24 -0
  6. package/dist/types/commands/setup.d.ts +41 -0
  7. package/dist/types/coordinator/contract.d.ts +4 -0
  8. package/dist/types/coordinator-mcp/policy.d.ts +24 -0
  9. package/dist/types/coordinator-mcp/safety.d.ts +26 -0
  10. package/dist/types/coordinator-mcp/server.d.ts +52 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +13 -0
  12. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  13. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  14. package/dist/types/setup/hermes-setup.d.ts +71 -0
  15. package/dist/types/task/render.d.ts +7 -1
  16. package/dist/types/tools/subagent-render.d.ts +25 -0
  17. package/dist/types/tools/subagent.d.ts +5 -1
  18. package/package.json +7 -7
  19. package/src/async/job-manager.ts +43 -1
  20. package/src/cli/setup-cli.ts +86 -2
  21. package/src/cli.ts +2 -0
  22. package/src/commands/coordinator.ts +70 -0
  23. package/src/commands/mcp-serve.ts +62 -0
  24. package/src/commands/setup.ts +30 -1
  25. package/src/coordinator/contract.ts +20 -0
  26. package/src/coordinator-mcp/policy.ts +160 -0
  27. package/src/coordinator-mcp/safety.ts +80 -0
  28. package/src/coordinator-mcp/server.ts +1316 -0
  29. package/src/extensibility/extensions/types.ts +13 -0
  30. package/src/gjc-runtime/session-state-sidecar.ts +79 -0
  31. package/src/internal-urls/docs-index.generated.ts +3 -2
  32. package/src/modes/components/hook-selector.ts +109 -5
  33. package/src/modes/controllers/extension-ui-controller.ts +16 -1
  34. package/src/prompts/agents/architect.md +6 -0
  35. package/src/prompts/agents/critic.md +6 -0
  36. package/src/prompts/agents/planner.md +8 -1
  37. package/src/session/agent-session.ts +6 -0
  38. package/src/setup/hermes/templates/operator-instructions.v1.md +29 -0
  39. package/src/setup/hermes-setup.ts +429 -0
  40. package/src/task/index.ts +2 -0
  41. package/src/task/render.ts +14 -0
  42. package/src/tools/ask.ts +30 -10
  43. package/src/tools/renderers.ts +2 -0
  44. package/src/tools/subagent-render.ts +160 -0
  45. package/src/tools/subagent.ts +49 -7
@@ -0,0 +1,1316 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as nodeFs from "node:fs";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { VERSION } from "@gajae-code/utils/dirs";
6
+ import {
7
+ COORDINATOR_MCP_PROTOCOL_VERSION,
8
+ COORDINATOR_MCP_SERVER_NAME,
9
+ COORDINATOR_MCP_TOOL_NAMES,
10
+ type CoordinatorToolName,
11
+ } from "../coordinator/contract";
12
+ import {
13
+ GJC_COORDINATOR_SESSION_ID_ENV,
14
+ GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
15
+ } from "../gjc-runtime/session-state-sidecar";
16
+ import {
17
+ assertCoordinatorArtifactPath,
18
+ assertCoordinatorWorkdir,
19
+ buildCoordinatorMcpConfig,
20
+ type CoordinatorMcpConfig,
21
+ coordinatorNamespacePath,
22
+ requireCoordinatorMutation,
23
+ } from "./policy";
24
+
25
+ export type { CoordinatorToolName };
26
+ export { COORDINATOR_MCP_PROTOCOL_VERSION, COORDINATOR_MCP_SERVER_NAME, COORDINATOR_MCP_TOOL_NAMES };
27
+
28
+ interface JsonRpcRequest {
29
+ jsonrpc: "2.0";
30
+ id?: string | number | null;
31
+ method: string;
32
+ params?: unknown;
33
+ }
34
+
35
+ interface JsonRpcResponse {
36
+ jsonrpc: "2.0";
37
+ id: string | number | null;
38
+ result?: any;
39
+ error?: { code: number; message: string; data?: unknown };
40
+ }
41
+
42
+ interface SessionStartInput {
43
+ cwd: string;
44
+ prompt?: string;
45
+ namespace: { profile: string | null; repo: string | null };
46
+ worktree: true;
47
+ }
48
+
49
+ interface CoordinatorServices {
50
+ listSessions?: () => unknown[] | Promise<unknown[]>;
51
+ startSession?: (input: SessionStartInput) => unknown | Promise<unknown>;
52
+ }
53
+
54
+ interface CoordinatorMcpServerOptions {
55
+ env?: NodeJS.ProcessEnv;
56
+ services?: CoordinatorServices;
57
+ }
58
+
59
+ interface LegacyHandlerOptions {
60
+ env?: NodeJS.ProcessEnv;
61
+ createSession?: () => unknown;
62
+ }
63
+
64
+ type TurnStatus =
65
+ | "queued"
66
+ | "delivering"
67
+ | "active"
68
+ | "waiting_for_answer"
69
+ | "completing"
70
+ | "completed"
71
+ | "failed"
72
+ | "cancelled"
73
+ | "superseded";
74
+
75
+ interface TurnRecord {
76
+ schema_version: 1;
77
+ turn_id: string;
78
+ session_id: string;
79
+ namespace: { profile: string | null; repo: string | null };
80
+ status: TurnStatus;
81
+ prompt: { text: string; created_at: string; source: "mcp" | "question_answer" };
82
+ delivery: {
83
+ delivered: boolean;
84
+ queued: boolean;
85
+ target: string | null;
86
+ tmux_keys_sent?: boolean;
87
+ prompt_acknowledged?: boolean;
88
+ state?: "queued" | "tmux_keys_sent" | "acknowledged" | "unavailable";
89
+ attempts: Array<{
90
+ delivered: boolean;
91
+ created_at: string;
92
+ reason: string | null;
93
+ channel?: "tmux_keys" | "runtime_ack";
94
+ tmux_keys_sent?: boolean;
95
+ }>;
96
+ };
97
+ question_ids: string[];
98
+ final_response: {
99
+ text: string | null;
100
+ format: "markdown";
101
+ source: string | null;
102
+ artifact_path: string | null;
103
+ truncated: boolean;
104
+ };
105
+ evidence: Array<Record<string, unknown>>;
106
+ error: { code: string; message: string; recoverable: boolean } | null;
107
+ liveness: { checked_at: string | null; live: boolean | null; reason: string | null };
108
+ created_at: string;
109
+ updated_at: string;
110
+ started_at: string | null;
111
+ completed_at: string | null;
112
+ }
113
+
114
+ type CoordinatorSessionStateValue =
115
+ | "booting"
116
+ | "ready_for_input"
117
+ | "running"
118
+ | "needs_user_input"
119
+ | "completed"
120
+ | "errored"
121
+ | "stale"
122
+ | "unknown";
123
+
124
+ interface CoordinatorSessionState {
125
+ schema_version: 1;
126
+ session_id: string;
127
+ state: CoordinatorSessionStateValue;
128
+ ready_for_input: boolean;
129
+ current_turn_id: string | null;
130
+ last_turn_id: string | null;
131
+ updated_at: string;
132
+ source: "coordinator" | "agent_session_event";
133
+ live: boolean | null;
134
+ reason: string | null;
135
+ }
136
+
137
+ const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
138
+ const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
139
+ 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;
140
+ const SAFE_EXTERNAL_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
141
+
142
+ function textResult(
143
+ payload: unknown,
144
+ isError = false,
145
+ ): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
146
+ return {
147
+ content: [{ type: "text", text: typeof payload === "string" ? payload : JSON.stringify(payload) }],
148
+ isError,
149
+ };
150
+ }
151
+
152
+ function toolSchema(name: CoordinatorToolName): {
153
+ name: CoordinatorToolName;
154
+ description: string;
155
+ inputSchema: Record<string, unknown>;
156
+ } {
157
+ const allowMutation = { type: "boolean", description: "Required and must be true for mutating tools." };
158
+ const cwd = {
159
+ type: "string",
160
+ description: "Canonicalized GJC worktree or project directory inside configured roots.",
161
+ };
162
+ const sessionId = { type: "string", description: "GJC coordinator bridge session id." };
163
+ const pathField = { type: "string", description: "Artifact path inside configured safe roots." };
164
+ const common = { type: "object", properties: {} as Record<string, unknown> };
165
+ if (name === "gjc_coordinator_start_session") {
166
+ return {
167
+ name,
168
+ description: "Start a GJC worktree/tmux oriented session through the coordinator bridge.",
169
+ inputSchema: {
170
+ type: "object",
171
+ properties: { cwd, prompt: { type: "string" }, allow_mutation: allowMutation },
172
+ required: ["cwd", "allow_mutation"],
173
+ },
174
+ };
175
+ }
176
+ if (name === "gjc_coordinator_send_prompt") {
177
+ return {
178
+ name,
179
+ description:
180
+ "Create a durable turn and deliver a bounded follow-up prompt for a selected coordinator bridge session.",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ session_id: sessionId,
185
+ prompt: { type: "string" },
186
+ queue: { type: "boolean" },
187
+ force: { type: "boolean" },
188
+ allow_mutation: allowMutation,
189
+ },
190
+ required: ["session_id", "prompt", "allow_mutation"],
191
+ },
192
+ };
193
+ }
194
+ if (name === "gjc_coordinator_read_turn") {
195
+ return {
196
+ name,
197
+ description: "Read authoritative durable turn state plus bounded advisory tmux status.",
198
+ inputSchema: {
199
+ type: "object",
200
+ properties: { session_id: sessionId, turn_id: { type: "string" }, lines: { type: "number" } },
201
+ required: ["turn_id"],
202
+ },
203
+ };
204
+ }
205
+ if (name === "gjc_coordinator_await_turn") {
206
+ return {
207
+ name,
208
+ description: "Poll a durable turn for a bounded time and return the same shape as read_turn.",
209
+ inputSchema: {
210
+ type: "object",
211
+ properties: {
212
+ session_id: sessionId,
213
+ turn_id: { type: "string" },
214
+ timeout_ms: { type: "number" },
215
+ poll_interval_ms: { type: "number" },
216
+ lines: { type: "number" },
217
+ },
218
+ required: ["turn_id"],
219
+ },
220
+ };
221
+ }
222
+ if (name === "gjc_coordinator_submit_question_answer") {
223
+ return {
224
+ name,
225
+ description: "Submit a bounded structured answer by question id.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ session_id: sessionId,
230
+ turn_id: { type: "string" },
231
+ question_id: { type: "string" },
232
+ answer: {},
233
+ allow_mutation: allowMutation,
234
+ },
235
+ required: ["question_id", "answer", "allow_mutation"],
236
+ },
237
+ };
238
+ }
239
+ if (name === "gjc_coordinator_report_status") {
240
+ return {
241
+ name,
242
+ description: "Write a bounded coordinator coordination status report.",
243
+ inputSchema: {
244
+ type: "object",
245
+ properties: {
246
+ session_id: sessionId,
247
+ turn_id: { type: "string" },
248
+ status: { type: "string" },
249
+ summary: { type: "string" },
250
+ blocker: { type: "string" },
251
+ pr_url: { type: "string" },
252
+ evidence_paths: { type: "array", items: { type: "string" } },
253
+ allow_mutation: allowMutation,
254
+ },
255
+ required: ["status", "allow_mutation"],
256
+ },
257
+ };
258
+ }
259
+ if (name === "gjc_coordinator_read_artifact") {
260
+ return {
261
+ name,
262
+ description: "Read one bounded artifact from configured safe roots.",
263
+ inputSchema: { type: "object", properties: { path: pathField }, required: ["path"] },
264
+ };
265
+ }
266
+ if (name === "gjc_coordinator_read_status") {
267
+ return {
268
+ name,
269
+ description: "Read selected coordinator bridge session status.",
270
+ inputSchema: { type: "object", properties: { session_id: sessionId } },
271
+ };
272
+ }
273
+ if (name === "gjc_coordinator_read_tail") {
274
+ return {
275
+ name,
276
+ description: "Read a bounded structured session tail, not tmux scrollback.",
277
+ inputSchema: { type: "object", properties: { session_id: sessionId, lines: { type: "number" } } },
278
+ };
279
+ }
280
+ if (name === "gjc_coordinator_list_questions") {
281
+ return {
282
+ name,
283
+ description: "List bounded structured questions for coordinator coordination.",
284
+ inputSchema: { type: "object", properties: { session_id: sessionId, status: { type: "string" } } },
285
+ };
286
+ }
287
+ if (name === "gjc_coordinator_list_artifacts") {
288
+ return { name, description: "List known safe artifact roots for coordinator coordination.", inputSchema: common };
289
+ }
290
+ if (name === "gjc_coordinator_read_coordination_status") {
291
+ return { name, description: "Read coordinator coordination reports.", inputSchema: common };
292
+ }
293
+ return { name, description: "List known scoped GJC coordinator bridge sessions.", inputSchema: common };
294
+ }
295
+
296
+ function normalizeSession(session: any): Record<string, unknown> {
297
+ return {
298
+ session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
299
+ ...(session.tmuxSession ? { tmux_session: session.tmuxSession } : {}),
300
+ ...(session.cwd ? { cwd: session.cwd } : {}),
301
+ ...(session.createdAt ? { created_at: session.createdAt } : {}),
302
+ ...session,
303
+ };
304
+ }
305
+
306
+ async function ensureDir(dir: string): Promise<void> {
307
+ await fs.mkdir(dir, { recursive: true });
308
+ }
309
+
310
+ async function readJsonFile(file: string): Promise<any | null> {
311
+ try {
312
+ return JSON.parse(await fs.readFile(file, "utf8"));
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ async function writeJsonFile(file: string, value: unknown): Promise<void> {
319
+ await ensureDir(path.dirname(file));
320
+ await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
321
+ }
322
+
323
+ async function listJsonFiles(dir: string): Promise<any[]> {
324
+ try {
325
+ const entries = await fs.readdir(dir);
326
+ const values = await Promise.all(
327
+ entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
328
+ );
329
+ return values.filter(value => value !== null);
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ function safeExternalId(kind: "session" | "question", value: unknown): string {
336
+ if (typeof value !== "string" || !SAFE_EXTERNAL_ID_PATTERN.test(value)) throw new Error(`invalid_${kind}_id`);
337
+ return value;
338
+ }
339
+
340
+ function safeTurnId(value: unknown): string {
341
+ if (typeof value !== "string" || !TURN_ID_PATTERN.test(value)) throw new Error("invalid_turn_id");
342
+ return value;
343
+ }
344
+
345
+ function turnsDir(namespaceDir: string): string {
346
+ return path.join(namespaceDir, "turns");
347
+ }
348
+
349
+ function activeTurnFile(namespaceDir: string, sessionId: string): string {
350
+ return path.join(namespaceDir, "active-turns", `${safeExternalId("session", sessionId)}.json`);
351
+ }
352
+
353
+ function turnFile(namespaceDir: string, turnId: string): string {
354
+ return path.join(turnsDir(namespaceDir), `${safeTurnId(turnId)}.json`);
355
+ }
356
+
357
+ function questionFile(namespaceDir: string, questionId: string): string {
358
+ return path.join(namespaceDir, "questions", `${safeExternalId("question", questionId)}.json`);
359
+ }
360
+
361
+ function sessionStateFile(namespaceDir: string, sessionId: string): string {
362
+ return path.join(namespaceDir, "session-states", `${safeExternalId("session", sessionId)}.json`);
363
+ }
364
+
365
+ async function readTurnRecord(namespaceDir: string, turnId: unknown): Promise<TurnRecord | null> {
366
+ return (await readJsonFile(turnFile(namespaceDir, safeTurnId(turnId)))) as TurnRecord | null;
367
+ }
368
+
369
+ async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<void> {
370
+ await writeJsonFile(turnFile(namespaceDir, turn.turn_id), turn);
371
+ }
372
+
373
+ async function readActiveTurn(namespaceDir: string, sessionId: string): Promise<TurnRecord | null> {
374
+ const active = await readJsonFile(activeTurnFile(namespaceDir, sessionId));
375
+ if (!active || typeof active.turn_id !== "string") return null;
376
+ const turn = await readTurnRecord(namespaceDir, active.turn_id);
377
+ if (!turn || turn.session_id !== sessionId || !ACTIVE_TURN_STATUSES.has(turn.status)) return null;
378
+ return turn;
379
+ }
380
+
381
+ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
382
+ await writeJsonFile(activeTurnFile(namespaceDir, turn.session_id), {
383
+ session_id: turn.session_id,
384
+ turn_id: turn.turn_id,
385
+ status: turn.status,
386
+ updated_at: turn.updated_at,
387
+ });
388
+ }
389
+
390
+ async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
391
+ const active = await readJsonFile(activeTurnFile(namespaceDir, turn.session_id));
392
+ if (active?.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
393
+ }
394
+
395
+ async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
396
+ return (await readJsonFile(sessionStateFile(namespaceDir, sessionId))) as CoordinatorSessionState | null;
397
+ }
398
+
399
+ async function writeSessionState(
400
+ namespaceDir: string,
401
+ sessionId: string,
402
+ state: CoordinatorSessionStateValue,
403
+ options: {
404
+ currentTurnId?: string | null;
405
+ lastTurnId?: string | null;
406
+ live?: boolean | null;
407
+ reason?: string | null;
408
+ source?: CoordinatorSessionState["source"];
409
+ } = {},
410
+ ): Promise<CoordinatorSessionState> {
411
+ const previous = await readSessionState(namespaceDir, sessionId);
412
+ const payload: CoordinatorSessionState = {
413
+ schema_version: 1,
414
+ session_id: sessionId,
415
+ state,
416
+ ready_for_input: state === "ready_for_input" || state === "completed",
417
+ current_turn_id: options.currentTurnId ?? (state === "running" ? (previous?.current_turn_id ?? null) : null),
418
+ last_turn_id: options.lastTurnId ?? previous?.last_turn_id ?? null,
419
+ updated_at: new Date().toISOString(),
420
+ source: options.source ?? "coordinator",
421
+ live: options.live ?? previous?.live ?? null,
422
+ reason: options.reason ?? null,
423
+ };
424
+ await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
425
+ return payload;
426
+ }
427
+
428
+ function hasTmuxIdentity(session: Record<string, unknown>): boolean {
429
+ return (
430
+ (typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
431
+ (typeof session.tmuxSession === "string" && session.tmuxSession.length > 0)
432
+ );
433
+ }
434
+
435
+ async function markTurnFailedForUnavailableSession(
436
+ namespaceDir: string,
437
+ turn: TurnRecord,
438
+ reason: string,
439
+ ): Promise<TurnRecord> {
440
+ const timestamp = new Date().toISOString();
441
+ const failed: TurnRecord = {
442
+ ...turn,
443
+ status: "failed",
444
+ final_response: {
445
+ text: `Coordinator session unavailable: ${reason}`,
446
+ format: "markdown",
447
+ source: "coordinator_liveness",
448
+ artifact_path: null,
449
+ truncated: false,
450
+ },
451
+ error: { code: "session_unavailable", message: reason, recoverable: true },
452
+ liveness: { checked_at: timestamp, live: false, reason },
453
+ updated_at: timestamp,
454
+ completed_at: timestamp,
455
+ };
456
+ await writeTurnRecord(namespaceDir, failed);
457
+ await clearActiveTurn(namespaceDir, failed);
458
+ await writeSessionState(namespaceDir, failed.session_id, "stale", {
459
+ lastTurnId: failed.turn_id,
460
+ live: false,
461
+ reason,
462
+ });
463
+ return failed;
464
+ }
465
+
466
+ async function markTurnTerminalFromSessionState(
467
+ namespaceDir: string,
468
+ turn: TurnRecord,
469
+ sessionState: CoordinatorSessionState,
470
+ ): Promise<TurnRecord> {
471
+ const terminalStatus: TurnStatus = sessionState.state === "errored" ? "failed" : "completed";
472
+ const timestamp = new Date().toISOString();
473
+ const resolved: TurnRecord = {
474
+ ...turn,
475
+ status: terminalStatus,
476
+ delivery: {
477
+ ...turn.delivery,
478
+ prompt_acknowledged: true,
479
+ state: "acknowledged",
480
+ },
481
+ final_response: {
482
+ text: null,
483
+ format: "markdown",
484
+ source: "runtime_state",
485
+ artifact_path: null,
486
+ truncated: false,
487
+ },
488
+ error:
489
+ terminalStatus === "failed"
490
+ ? { code: "runtime_errored", message: sessionState.reason ?? "runtime_errored", recoverable: true }
491
+ : null,
492
+ updated_at: timestamp,
493
+ completed_at: timestamp,
494
+ };
495
+ await writeTurnRecord(namespaceDir, resolved);
496
+ await clearActiveTurn(namespaceDir, resolved);
497
+ await writeSessionState(namespaceDir, resolved.session_id, sessionState.state, {
498
+ lastTurnId: resolved.turn_id,
499
+ live: sessionState.live,
500
+ reason: sessionState.reason,
501
+ });
502
+ return resolved;
503
+ }
504
+
505
+ function shellQuote(value: string): string {
506
+ return `'${value.replaceAll("'", "'\\''")}'`;
507
+ }
508
+
509
+ function makeTurnRecord(
510
+ config: CoordinatorMcpConfig,
511
+ sessionId: string,
512
+ prompt: string,
513
+ status: TurnStatus,
514
+ ): TurnRecord {
515
+ const timestamp = new Date().toISOString();
516
+ return {
517
+ schema_version: 1,
518
+ turn_id: `turn-${randomUUID()}`,
519
+ session_id: sessionId,
520
+ namespace: config.namespace,
521
+ status,
522
+ prompt: { text: prompt, created_at: timestamp, source: "mcp" },
523
+ delivery: {
524
+ delivered: false,
525
+ queued: true,
526
+ target: null,
527
+ tmux_keys_sent: false,
528
+ prompt_acknowledged: false,
529
+ state: "queued",
530
+ attempts: [],
531
+ },
532
+ question_ids: [],
533
+ final_response: { text: null, format: "markdown", source: null, artifact_path: null, truncated: false },
534
+ evidence: [],
535
+ error: null,
536
+ liveness: { checked_at: null, live: null, reason: null },
537
+ created_at: timestamp,
538
+ updated_at: timestamp,
539
+ started_at: status === "queued" ? null : timestamp,
540
+ completed_at: null,
541
+ };
542
+ }
543
+
544
+ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
545
+ const normalized = String(status ?? "")
546
+ .trim()
547
+ .toLowerCase();
548
+ if (TERMINAL_TURN_STATUSES.has(normalized as TurnStatus)) return normalized as TurnStatus;
549
+ if (normalized === "blocked") return "failed";
550
+ return null;
551
+ }
552
+
553
+ function boundedTimeoutMs(value: unknown): number {
554
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
555
+ if (!Number.isFinite(parsed) || parsed <= 0) return 1000;
556
+ return Math.min(parsed, 30_000);
557
+ }
558
+
559
+ function boundedPollIntervalMs(value: unknown): number {
560
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
561
+ if (!Number.isFinite(parsed) || parsed <= 0) return 100;
562
+ return Math.min(Math.max(parsed, 10), 1000);
563
+ }
564
+ async function runCommand(command: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
565
+ const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
566
+ const [stdout, stderr, exitCode] = await Promise.all([
567
+ new Response(proc.stdout).text(),
568
+ new Response(proc.stderr).text(),
569
+ proc.exited,
570
+ ]);
571
+ return { exitCode, stdout, stderr };
572
+ }
573
+
574
+ async function sendTmuxPromptKeys(target: string, prompt: string): Promise<boolean> {
575
+ const sent = await runCommand(["tmux", "send-keys", "-t", target, prompt, "C-m", "C-m"]);
576
+ return sent.exitCode === 0;
577
+ }
578
+
579
+ function boundedLineCount(value: unknown): number {
580
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
581
+ if (!Number.isFinite(parsed) || parsed <= 0) return 80;
582
+ return Math.min(parsed, 400);
583
+ }
584
+
585
+ async function startTmuxSession(
586
+ config: CoordinatorMcpConfig,
587
+ input: SessionStartInput,
588
+ namespaceDir: string,
589
+ ): Promise<Record<string, unknown> | null> {
590
+ if (!config.sessionCommand) return null;
591
+ const sessionName = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
592
+ const runtimeStateFile = sessionStateFile(namespaceDir, sessionName);
593
+ const sessionCommand = [
594
+ "exec env",
595
+ `${GJC_COORDINATOR_SESSION_STATE_FILE_ENV}=${shellQuote(runtimeStateFile)}`,
596
+ `${GJC_COORDINATOR_SESSION_ID_ENV}=${shellQuote(sessionName)}`,
597
+ config.sessionCommand,
598
+ ].join(" ");
599
+ const started = await runCommand([
600
+ "tmux",
601
+ "new-session",
602
+ "-d",
603
+ "-P",
604
+ "-F",
605
+ "#{session_name}:#{window_index}.#{pane_index} #{pane_id}",
606
+ "-s",
607
+ sessionName,
608
+ "-c",
609
+ input.cwd,
610
+ sessionCommand,
611
+ ]);
612
+ if (started.exitCode !== 0) throw new Error(`coordinator_tmux_start_failed:${started.stderr || started.stdout}`);
613
+ const [tmuxTarget, paneId] = started.stdout.trim().split(/\s+/, 2);
614
+ const initialPromptTmuxKeysSent = input.prompt
615
+ ? await sendTmuxPromptKeys(tmuxTarget || sessionName, input.prompt)
616
+ : false;
617
+ return {
618
+ sessionId: sessionName,
619
+ tmuxSession: sessionName,
620
+ tmuxTarget: tmuxTarget || sessionName,
621
+ paneId,
622
+ cwd: input.cwd,
623
+ createdAt: new Date().toISOString(),
624
+ sessionCommand: config.sessionCommand,
625
+ runtimeStateFile,
626
+ initialPromptTmuxKeysSent,
627
+ };
628
+ }
629
+
630
+ async function captureTmuxTail(session: Record<string, unknown>, lines: number): Promise<string[]> {
631
+ const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
632
+ if (typeof target !== "string" || target.length === 0) return [];
633
+ const captured = await runCommand(["tmux", "capture-pane", "-t", target, "-p", "-S", `-${lines}`]);
634
+ if (captured.exitCode !== 0) return [];
635
+ return captured.stdout.split("\n").slice(-lines);
636
+ }
637
+
638
+ async function sendTmuxPrompt(session: Record<string, unknown>, prompt: string): Promise<boolean> {
639
+ const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
640
+ if (typeof target !== "string" || target.length === 0) return false;
641
+ return await sendTmuxPromptKeys(target, prompt);
642
+ }
643
+
644
+ async function hasTmuxSession(session: Record<string, unknown>): Promise<boolean | null> {
645
+ const tmuxSession = typeof session.tmux_session === "string" ? session.tmux_session : session.tmuxSession;
646
+ if (typeof tmuxSession !== "string" || tmuxSession.length === 0) return null;
647
+ const checked = await runCommand(["tmux", "has-session", "-t", tmuxSession]);
648
+ return checked.exitCode === 0;
649
+ }
650
+
651
+ function lastMatchingLine(lines: string[], pattern: RegExp): string | null {
652
+ for (let index = lines.length - 1; index >= 0; index--) {
653
+ const line = lines[index]?.trim();
654
+ if (line && pattern.test(line)) return line;
655
+ }
656
+ return null;
657
+ }
658
+
659
+ function summarizePaneTail(lines: string[]): Record<string, unknown> {
660
+ const nonEmpty = lines.map(line => line.trim()).filter(Boolean);
661
+ const spinnerLine = lastMatchingLine(nonEmpty, /^[⠁-⣿]\s+/u);
662
+ const hudLine = lastMatchingLine(nonEmpty, /\/ 📁 | PR \d+|Status Review|Tracking/i);
663
+ const errorLine = lastMatchingLine(nonEmpty, /\b(error|failed|exception|404|not_found)\b/i);
664
+ const assistantLine = lastMatchingLine(nonEmpty, /^(gajae|assistant)\b/i);
665
+ const lastContent = nonEmpty.at(-1) ?? null;
666
+ return {
667
+ state: spinnerLine ? "working" : errorLine ? "error_or_warning" : "idle_or_unknown",
668
+ activity: spinnerLine ?? hudLine ?? lastContent,
669
+ hud: hudLine,
670
+ last_error: errorLine,
671
+ last_speaker: assistantLine,
672
+ last_content: lastContent,
673
+ };
674
+ }
675
+
676
+ async function inspectTmuxSession(session: Record<string, unknown>, lines = 80): Promise<Record<string, unknown>> {
677
+ const live = await hasTmuxSession(session);
678
+ const tail = live ? await captureTmuxTail(session, lines) : [];
679
+ return {
680
+ live,
681
+ ...summarizePaneTail(tail),
682
+ tail_preview: tail.slice(-20),
683
+ };
684
+ }
685
+
686
+ function waitForTurnStateChange(namespaceDir: string, turn: TurnRecord, timeoutMs: number): Promise<void> {
687
+ const deferred = Promise.withResolvers<void>();
688
+ const watchers: nodeFs.FSWatcher[] = [];
689
+ const watchedFiles = new Map<string, Set<string>>([
690
+ [turnsDir(namespaceDir), new Set([`${turn.turn_id}.json`])],
691
+ [path.join(namespaceDir, "active-turns"), new Set([`${turn.session_id}.json`])],
692
+ [path.join(namespaceDir, "session-states"), new Set([`${turn.session_id}.json`])],
693
+ ]);
694
+ let settled = false;
695
+ const finish = () => {
696
+ if (settled) return;
697
+ settled = true;
698
+ for (const watcher of watchers) watcher.close();
699
+ clearTimeout(timer);
700
+ deferred.resolve();
701
+ };
702
+ const timer = setTimeout(finish, Math.max(timeoutMs, 0));
703
+ timer.unref?.();
704
+
705
+ for (const [dir, filenames] of watchedFiles) {
706
+ try {
707
+ const watcher = nodeFs.watch(dir, (_eventType, filename) => {
708
+ if (typeof filename === "string" && filenames.has(filename)) finish();
709
+ });
710
+ watchers.push(watcher);
711
+ } catch {
712
+ // Directory may not exist yet; the timeout remains a bounded fallback.
713
+ }
714
+ }
715
+
716
+ return deferred.promise;
717
+ }
718
+
719
+ function decodeUtf8WithinByteCap(bytes: Buffer, byteCap: number): string {
720
+ const decoder = new TextDecoder("utf-8", { fatal: true });
721
+ for (let end = Math.min(bytes.length, byteCap); end >= 0; end--) {
722
+ try {
723
+ const text = decoder.decode(bytes.subarray(0, end));
724
+ if (Buffer.byteLength(text) <= byteCap) return text;
725
+ } catch {
726
+ // Keep trimming until the byte slice ends on a valid UTF-8 boundary.
727
+ }
728
+ }
729
+ return "";
730
+ }
731
+
732
+ export async function readCoordinatorArtifact(
733
+ config: CoordinatorMcpConfig,
734
+ args: { path: unknown },
735
+ ): Promise<Record<string, unknown>> {
736
+ let handle: fs.FileHandle | null = null;
737
+ try {
738
+ const resolved = await assertCoordinatorArtifactPath(config, args.path);
739
+ handle = await fs.open(resolved.path, "r");
740
+ const readLimit = resolved.byteCap + 1;
741
+ const buffer = Buffer.alloc(readLimit);
742
+ const { bytesRead } = await handle.read(buffer, 0, readLimit, 0);
743
+ const boundedBytes = buffer.subarray(0, Math.min(bytesRead, resolved.byteCap));
744
+ const text = decodeUtf8WithinByteCap(boundedBytes, resolved.byteCap);
745
+ return {
746
+ ok: true,
747
+ path: resolved.path,
748
+ text,
749
+ bytes: Buffer.byteLength(text),
750
+ truncated: bytesRead > resolved.byteCap,
751
+ };
752
+ } catch (error) {
753
+ return {
754
+ ok: false,
755
+ reason: (error instanceof Error ? error.message.split(":")[0] : String(error)).replace(/^coordinator_/, ""),
756
+ };
757
+ } finally {
758
+ await handle?.close();
759
+ }
760
+ }
761
+
762
+ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions = {}) {
763
+ const config = buildCoordinatorMcpConfig(options.env ?? process.env);
764
+ const services = options.services ?? {};
765
+ const namespaceDir = coordinatorNamespacePath(config);
766
+
767
+ async function listSessions(): Promise<unknown[]> {
768
+ if (!config.namespace.profile || !config.namespace.repo) return [];
769
+ if (services.listSessions) return await services.listSessions();
770
+ return await listJsonFiles(path.join(namespaceDir, "sessions"));
771
+ }
772
+ function sessionFile(sessionId: unknown): string {
773
+ return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
774
+ }
775
+
776
+ async function readTurnPayload(
777
+ turnId: unknown,
778
+ sessionId: unknown,
779
+ lines: unknown,
780
+ ): Promise<Record<string, unknown>> {
781
+ const turn = await readTurnRecord(namespaceDir, turnId);
782
+ if (!turn) return { ok: false, reason: "unknown_turn" };
783
+ if (sessionId != null && turn.session_id !== safeExternalId("session", sessionId)) {
784
+ return { ok: false, reason: "turn_session_mismatch" };
785
+ }
786
+ const session = await readJsonFile(sessionFile(turn.session_id));
787
+ let resolvedTurn = turn;
788
+ let advisoryStatus: Record<string, unknown> = { live: false };
789
+ let sessionState = await readSessionState(namespaceDir, turn.session_id);
790
+ if (
791
+ sessionState &&
792
+ ACTIVE_TURN_STATUSES.has(turn.status) &&
793
+ sessionState.current_turn_id === turn.turn_id &&
794
+ (sessionState.state === "completed" || sessionState.state === "errored")
795
+ ) {
796
+ resolvedTurn = await markTurnTerminalFromSessionState(namespaceDir, turn, sessionState);
797
+ sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
798
+ } else if (
799
+ sessionState &&
800
+ ACTIVE_TURN_STATUSES.has(turn.status) &&
801
+ sessionState.current_turn_id === turn.turn_id &&
802
+ sessionState.state === "stale" &&
803
+ sessionState.reason === "tmux_delivery_unavailable" &&
804
+ turn.delivery.state === "unavailable" &&
805
+ session &&
806
+ hasTmuxIdentity(session)
807
+ ) {
808
+ resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, turn, "tmux_delivery_unavailable");
809
+ sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
810
+ } else if (!session && ACTIVE_TURN_STATUSES.has(turn.status)) {
811
+ resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, turn, "session_record_missing");
812
+ sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
813
+ } else if (session) {
814
+ advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines));
815
+ if (ACTIVE_TURN_STATUSES.has(turn.status) && hasTmuxIdentity(session) && advisoryStatus.live === false) {
816
+ resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, turn, "tmux_session_missing");
817
+ sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
818
+ }
819
+ }
820
+ return {
821
+ ok: true,
822
+ turn: resolvedTurn,
823
+ advisory_status: advisoryStatus,
824
+ session_state: sessionState,
825
+ };
826
+ }
827
+
828
+ async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
829
+ try {
830
+ if (name === "gjc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
831
+ if (name === "gjc_coordinator_read_status") {
832
+ const sessionId = args.session_id;
833
+ if (sessionId) {
834
+ const session = await readJsonFile(sessionFile(sessionId));
835
+ return {
836
+ ok: true,
837
+ session,
838
+ status: session ? await inspectTmuxSession(session) : { live: false },
839
+ session_state: await readSessionState(namespaceDir, safeExternalId("session", sessionId)),
840
+ };
841
+ }
842
+ const sessions = await listSessions();
843
+ const statuses = await Promise.all(
844
+ sessions.map(async session => {
845
+ if (typeof session !== "object" || session === null) return { session, status: { live: null } };
846
+ const normalized = session as Record<string, unknown>;
847
+ const listedSessionId =
848
+ typeof normalized.session_id === "string"
849
+ ? normalized.session_id
850
+ : normalizeSession(normalized).session_id;
851
+ return {
852
+ session,
853
+ status: await inspectTmuxSession(normalized, 40),
854
+ session_state: await readSessionState(namespaceDir, listedSessionId as string),
855
+ };
856
+ }),
857
+ );
858
+ return { ok: true, sessions, statuses };
859
+ }
860
+ if (name === "gjc_coordinator_read_tail") {
861
+ const session = await readJsonFile(sessionFile(args.session_id));
862
+ return { ok: true, lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines)) : [] };
863
+ }
864
+ if (name === "gjc_coordinator_list_questions") {
865
+ const questions = await listJsonFiles(path.join(namespaceDir, "questions"));
866
+ const sessionId = typeof args.session_id === "string" ? safeExternalId("session", args.session_id) : null;
867
+ if (sessionId) {
868
+ const openQuestion = questions.find(
869
+ question =>
870
+ question &&
871
+ typeof question === "object" &&
872
+ (question as { session_id?: unknown }).session_id === sessionId &&
873
+ (question as { status?: unknown }).status === "open",
874
+ ) as { turn_id?: unknown } | undefined;
875
+ if (openQuestion) {
876
+ await writeSessionState(namespaceDir, sessionId, "needs_user_input", {
877
+ currentTurnId: typeof openQuestion.turn_id === "string" ? openQuestion.turn_id : null,
878
+ live: null,
879
+ reason: "open_question",
880
+ });
881
+ }
882
+ }
883
+ return { ok: true, questions };
884
+ }
885
+ if (name === "gjc_coordinator_list_artifacts") return { ok: true, roots: config.allowedRoots };
886
+ if (name === "gjc_coordinator_read_artifact")
887
+ return await readCoordinatorArtifact(config, { path: args.path });
888
+ if (name === "gjc_coordinator_read_coordination_status")
889
+ return { ok: true, reports: await listJsonFiles(path.join(namespaceDir, "reports")) };
890
+ if (name === "gjc_coordinator_start_session") {
891
+ requireCoordinatorMutation(config, "sessions", args);
892
+ const cwd = await assertCoordinatorWorkdir(config, args.cwd);
893
+ const input = {
894
+ cwd,
895
+ prompt: typeof args.prompt === "string" ? args.prompt : undefined,
896
+ namespace: config.namespace,
897
+ worktree: true as const,
898
+ };
899
+ const started = services.startSession
900
+ ? await services.startSession(input)
901
+ : await startTmuxSession(config, input, namespaceDir);
902
+ const session = normalizeSession(
903
+ started ?? { sessionId: `gjc-coordinator-${Date.now()}`, cwd, createdAt: new Date().toISOString() },
904
+ );
905
+ await writeJsonFile(sessionFile(session.session_id), session);
906
+ const live = hasTmuxIdentity(session) ? await hasTmuxSession(session) : null;
907
+ let turn: TurnRecord | null = null;
908
+ let sessionState = await writeSessionState(
909
+ namespaceDir,
910
+ session.session_id as string,
911
+ input.prompt ? "running" : "ready_for_input",
912
+ { live, reason: null },
913
+ );
914
+ if (input.prompt) {
915
+ const tmuxKeysSent = session.initialPromptTmuxKeysSent === true;
916
+ turn = makeTurnRecord(config, session.session_id as string, input.prompt, "active");
917
+ const timestamp = new Date().toISOString();
918
+ turn.delivery = {
919
+ delivered: false,
920
+ queued: !tmuxKeysSent,
921
+ target:
922
+ typeof session.tmux_target === "string"
923
+ ? session.tmux_target
924
+ : typeof session.tmuxTarget === "string"
925
+ ? session.tmuxTarget
926
+ : null,
927
+ tmux_keys_sent: tmuxKeysSent,
928
+ prompt_acknowledged: false,
929
+ state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
930
+ attempts: [
931
+ {
932
+ delivered: false,
933
+ tmux_keys_sent: tmuxKeysSent,
934
+ channel: "tmux_keys",
935
+ created_at: timestamp,
936
+ reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
937
+ },
938
+ ],
939
+ };
940
+ turn.liveness = { checked_at: timestamp, live, reason: live === false ? "tmux_session_missing" : null };
941
+ turn.updated_at = timestamp;
942
+ await writeTurnRecord(namespaceDir, turn);
943
+ await writeActiveTurn(namespaceDir, turn);
944
+ await writeJsonFile(path.join(namespaceDir, "prompts", `${Date.now()}.json`), {
945
+ session_id: turn.session_id,
946
+ turn_id: turn.turn_id,
947
+ prompt: input.prompt,
948
+ queued: turn.delivery.queued,
949
+ delivered: turn.delivery.delivered,
950
+ tmux_keys_sent: turn.delivery.tmux_keys_sent ?? false,
951
+ prompt_acknowledged: turn.delivery.prompt_acknowledged ?? false,
952
+ created_at: turn.created_at,
953
+ });
954
+ sessionState = await writeSessionState(
955
+ namespaceDir,
956
+ turn.session_id,
957
+ tmuxKeysSent ? "running" : "stale",
958
+ {
959
+ currentTurnId: turn.turn_id,
960
+ live,
961
+ reason: tmuxKeysSent ? null : "tmux_delivery_unavailable",
962
+ },
963
+ );
964
+ }
965
+ return { ok: true, session, session_state: sessionState, ...(turn ? { turn, turn_id: turn.turn_id } : {}) };
966
+ }
967
+ if (name === "gjc_coordinator_send_prompt") {
968
+ requireCoordinatorMutation(config, "sessions", args);
969
+ const sessionId = safeExternalId("session", args.session_id);
970
+ const session = await readJsonFile(sessionFile(sessionId));
971
+ if (!session) return { ok: false, reason: "unknown_session", session_id: sessionId };
972
+ if (typeof args.prompt !== "string" || args.prompt.length === 0)
973
+ return { ok: false, reason: "prompt_required" };
974
+ let activeTurn = await readActiveTurn(namespaceDir, sessionId);
975
+ if (activeTurn && hasTmuxIdentity(session) && (await hasTmuxSession(session)) === false) {
976
+ activeTurn = await markTurnFailedForUnavailableSession(namespaceDir, activeTurn, "tmux_session_missing");
977
+ activeTurn = null;
978
+ }
979
+ if (activeTurn && args.force !== true && args.queue !== true) {
980
+ return {
981
+ ok: false,
982
+ reason: "active_turn_exists",
983
+ session_id: sessionId,
984
+ active_turn_id: activeTurn.turn_id,
985
+ };
986
+ }
987
+ if (activeTurn && args.force === true) {
988
+ const timestamp = new Date().toISOString();
989
+ const superseded = {
990
+ ...activeTurn,
991
+ status: "superseded" as const,
992
+ updated_at: timestamp,
993
+ completed_at: timestamp,
994
+ };
995
+ await writeTurnRecord(namespaceDir, superseded);
996
+ await clearActiveTurn(namespaceDir, superseded);
997
+ }
998
+ const shouldQueue = args.queue === true && args.force !== true;
999
+ const turn = makeTurnRecord(config, sessionId, args.prompt, shouldQueue ? "queued" : "active");
1000
+ if (!shouldQueue) {
1001
+ const tmuxKeysSent = await sendTmuxPrompt(session, args.prompt);
1002
+ const timestamp = new Date().toISOString();
1003
+ const live = hasTmuxIdentity(session) ? await hasTmuxSession(session) : null;
1004
+ turn.delivery = {
1005
+ delivered: false,
1006
+ queued: !tmuxKeysSent,
1007
+ target: typeof session.tmux_target === "string" ? session.tmux_target : null,
1008
+ tmux_keys_sent: tmuxKeysSent,
1009
+ prompt_acknowledged: false,
1010
+ state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
1011
+ attempts: [
1012
+ {
1013
+ delivered: false,
1014
+ tmux_keys_sent: tmuxKeysSent,
1015
+ channel: "tmux_keys",
1016
+ created_at: timestamp,
1017
+ reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
1018
+ },
1019
+ ],
1020
+ };
1021
+ turn.liveness = { checked_at: timestamp, live, reason: live === false ? "tmux_session_missing" : null };
1022
+ turn.updated_at = timestamp;
1023
+ await writeActiveTurn(namespaceDir, turn);
1024
+ await writeSessionState(namespaceDir, sessionId, tmuxKeysSent ? "running" : "stale", {
1025
+ currentTurnId: turn.turn_id,
1026
+ live,
1027
+ reason: tmuxKeysSent ? null : "tmux_delivery_unavailable",
1028
+ });
1029
+ }
1030
+ await writeTurnRecord(namespaceDir, turn);
1031
+ const queued = {
1032
+ session_id: sessionId,
1033
+ turn_id: turn.turn_id,
1034
+ prompt: args.prompt,
1035
+ queued: turn.delivery.queued,
1036
+ delivered: turn.delivery.delivered,
1037
+ tmux_keys_sent: turn.delivery.tmux_keys_sent ?? false,
1038
+ prompt_acknowledged: turn.delivery.prompt_acknowledged ?? false,
1039
+ created_at: turn.created_at,
1040
+ };
1041
+ await writeJsonFile(path.join(namespaceDir, "prompts", `${Date.now()}.json`), queued);
1042
+ return {
1043
+ ok: true,
1044
+ session_id: sessionId,
1045
+ turn_id: turn.turn_id,
1046
+ active_turn_id: shouldQueue ? activeTurn?.turn_id : turn.turn_id,
1047
+ status: turn.status,
1048
+ queued: turn.delivery.queued,
1049
+ delivered: turn.delivery.delivered,
1050
+ delivery: turn.delivery,
1051
+ prompt: queued,
1052
+ tmux_keys_sent: turn.delivery.tmux_keys_sent ?? false,
1053
+ prompt_acknowledged: turn.delivery.prompt_acknowledged ?? false,
1054
+ session_state: await readSessionState(namespaceDir, sessionId),
1055
+ };
1056
+ }
1057
+ if (name === "gjc_coordinator_read_turn") {
1058
+ return await readTurnPayload(args.turn_id, args.session_id, args.lines);
1059
+ }
1060
+ if (name === "gjc_coordinator_await_turn") {
1061
+ const timeoutMs = boundedTimeoutMs(args.timeout_ms);
1062
+ const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
1063
+ const deadline = Date.now() + timeoutMs;
1064
+ let payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
1065
+ while (
1066
+ payload.ok === true &&
1067
+ !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
1068
+ Date.now() < deadline
1069
+ ) {
1070
+ const remainingMs = deadline - Date.now();
1071
+ await waitForTurnStateChange(
1072
+ namespaceDir,
1073
+ payload.turn as TurnRecord,
1074
+ Math.min(pollIntervalMs, remainingMs),
1075
+ );
1076
+ payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
1077
+ }
1078
+ if (payload.ok === true && !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status)) {
1079
+ return {
1080
+ ok: false,
1081
+ reason: "timeout",
1082
+ turn: payload.turn,
1083
+ advisory_status: payload.advisory_status,
1084
+ session_state: payload.session_state,
1085
+ };
1086
+ }
1087
+ return payload;
1088
+ }
1089
+ if (name === "gjc_coordinator_submit_question_answer") {
1090
+ requireCoordinatorMutation(config, "questions", args);
1091
+ const questionId = safeExternalId("question", args.question_id);
1092
+ const questionPath = questionFile(namespaceDir, questionId);
1093
+ const question = await readJsonFile(questionPath);
1094
+ if (!question) return { ok: false, reason: "unknown_question" };
1095
+ if (args.session_id != null && question.session_id !== safeExternalId("session", args.session_id)) {
1096
+ return { ok: false, reason: "question_session_mismatch" };
1097
+ }
1098
+ if (args.turn_id != null && question.turn_id !== safeTurnId(args.turn_id)) {
1099
+ return { ok: false, reason: "question_turn_mismatch" };
1100
+ }
1101
+ const answered = {
1102
+ ...question,
1103
+ status: "answered",
1104
+ answer: args.answer,
1105
+ answered_at: new Date().toISOString(),
1106
+ };
1107
+ await writeJsonFile(questionPath, answered);
1108
+ let turn: TurnRecord | null = null;
1109
+ if (typeof answered.turn_id === "string") {
1110
+ turn = await readTurnRecord(namespaceDir, answered.turn_id);
1111
+ if (turn) {
1112
+ const timestamp = new Date().toISOString();
1113
+ turn = {
1114
+ ...turn,
1115
+ status: "active",
1116
+ question_ids: [...new Set([...turn.question_ids, questionId])],
1117
+ updated_at: timestamp,
1118
+ };
1119
+ await writeTurnRecord(namespaceDir, turn);
1120
+ await writeActiveTurn(namespaceDir, turn);
1121
+ await writeSessionState(namespaceDir, turn.session_id, "running", {
1122
+ currentTurnId: turn.turn_id,
1123
+ live: null,
1124
+ reason: null,
1125
+ });
1126
+ const session = await readJsonFile(sessionFile(turn.session_id));
1127
+ if (session && typeof args.answer === "string") await sendTmuxPrompt(session, args.answer);
1128
+ }
1129
+ }
1130
+ return { ok: true, question: answered, ...(turn ? { turn } : {}) };
1131
+ }
1132
+ if (name === "gjc_coordinator_report_status") {
1133
+ requireCoordinatorMutation(config, "reports", args);
1134
+ const report = {
1135
+ session_id: args.session_id,
1136
+ turn_id: args.turn_id,
1137
+ status: args.status,
1138
+ summary: args.summary,
1139
+ blocker: args.blocker,
1140
+ pr_url: args.pr_url,
1141
+ evidence_paths: args.evidence_paths ?? [],
1142
+ created_at: new Date().toISOString(),
1143
+ };
1144
+ let turn: TurnRecord | null = null;
1145
+ if (args.turn_id != null) {
1146
+ turn = await readTurnRecord(namespaceDir, args.turn_id);
1147
+ if (!turn) return { ok: false, reason: "unknown_turn" };
1148
+ if (args.session_id != null && turn.session_id !== safeExternalId("session", args.session_id)) {
1149
+ return { ok: false, reason: "turn_session_mismatch" };
1150
+ }
1151
+ const terminalStatus = asTerminalTurnStatus(args.status);
1152
+ if (terminalStatus) {
1153
+ const timestamp = new Date().toISOString();
1154
+ turn = {
1155
+ ...turn,
1156
+ status: terminalStatus,
1157
+ delivery: {
1158
+ ...turn.delivery,
1159
+ prompt_acknowledged: true,
1160
+ state: "acknowledged",
1161
+ },
1162
+ final_response: {
1163
+ text:
1164
+ typeof args.summary === "string"
1165
+ ? args.summary
1166
+ : typeof args.blocker === "string"
1167
+ ? args.blocker
1168
+ : null,
1169
+ format: "markdown",
1170
+ source: "report_status",
1171
+ artifact_path: null,
1172
+ truncated: false,
1173
+ },
1174
+ evidence: Array.isArray(args.evidence_paths)
1175
+ ? args.evidence_paths.map(evidencePath => ({ path: evidencePath }))
1176
+ : [],
1177
+ error:
1178
+ terminalStatus === "failed"
1179
+ ? {
1180
+ code: "reported_failure",
1181
+ message:
1182
+ typeof args.blocker === "string" ? args.blocker : String(args.summary ?? "failed"),
1183
+ recoverable: true,
1184
+ }
1185
+ : null,
1186
+ updated_at: timestamp,
1187
+ completed_at: timestamp,
1188
+ };
1189
+ await writeTurnRecord(namespaceDir, turn);
1190
+ await clearActiveTurn(namespaceDir, turn);
1191
+ await writeSessionState(
1192
+ namespaceDir,
1193
+ turn.session_id,
1194
+ terminalStatus === "failed" ? "errored" : "completed",
1195
+ {
1196
+ lastTurnId: turn.turn_id,
1197
+ live: null,
1198
+ reason: terminalStatus === "failed" ? "reported_failure" : null,
1199
+ },
1200
+ );
1201
+ }
1202
+ }
1203
+ await writeJsonFile(path.join(namespaceDir, "reports", `${Date.now()}.json`), report);
1204
+ return {
1205
+ ok: true,
1206
+ report,
1207
+ ...(turn ? { turn, session_state: await readSessionState(namespaceDir, turn.session_id) } : {}),
1208
+ };
1209
+ }
1210
+ return { ok: false, reason: "unknown_tool", tool: name };
1211
+ } catch (error) {
1212
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
1213
+ }
1214
+ }
1215
+
1216
+ async function handleJsonRpc(request: JsonRpcRequest): Promise<JsonRpcResponse> {
1217
+ const id = request.id ?? null;
1218
+ if (request.method === "initialize") {
1219
+ return {
1220
+ jsonrpc: "2.0",
1221
+ id,
1222
+ result: {
1223
+ protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION,
1224
+ capabilities: { tools: {}, prompts: {}, resources: {} },
1225
+ serverInfo: { name: COORDINATOR_MCP_SERVER_NAME, version: VERSION },
1226
+ },
1227
+ };
1228
+ }
1229
+ if (request.method === "tools/list") {
1230
+ return { jsonrpc: "2.0", id, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } };
1231
+ }
1232
+ if (request.method === "prompts/list") {
1233
+ return { jsonrpc: "2.0", id, result: { prompts: [] } };
1234
+ }
1235
+ if (request.method === "resources/list") {
1236
+ return { jsonrpc: "2.0", id, result: { resources: [] } };
1237
+ }
1238
+ if (request.method === "tools/call") {
1239
+ const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
1240
+ const payload = await callTool(params.name ?? "", params.arguments ?? {});
1241
+ return { jsonrpc: "2.0", id, result: textResult(payload, payload.ok === false) };
1242
+ }
1243
+ return { jsonrpc: "2.0", id, error: { code: -32601, message: `unknown_method:${request.method}` } };
1244
+ }
1245
+
1246
+ return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
1247
+ }
1248
+
1249
+ function legacyToolResult(payload: unknown): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
1250
+ const failed = typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === false;
1251
+ return textResult(payload, failed);
1252
+ }
1253
+
1254
+ export async function handleCoordinatorMcpRequest(
1255
+ request: JsonRpcRequest,
1256
+ options: LegacyHandlerOptions = {},
1257
+ ): Promise<any> {
1258
+ if (request.method === "initialize") {
1259
+ return {
1260
+ jsonrpc: "2.0",
1261
+ id: request.id ?? null,
1262
+ result: {
1263
+ protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION,
1264
+ capabilities: { tools: {}, prompts: {}, resources: {} },
1265
+ serverInfo: { name: COORDINATOR_MCP_SERVER_NAME, version: VERSION },
1266
+ },
1267
+ };
1268
+ }
1269
+ if (request.method === "tools/list") {
1270
+ return { jsonrpc: "2.0", id: request.id ?? null, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } };
1271
+ }
1272
+ if (request.method === "prompts/list") {
1273
+ return { jsonrpc: "2.0", id: request.id ?? null, result: { prompts: [] } };
1274
+ }
1275
+ if (request.method === "resources/list") {
1276
+ return { jsonrpc: "2.0", id: request.id ?? null, result: { resources: [] } };
1277
+ }
1278
+ if (request.method !== "tools/call")
1279
+ return {
1280
+ jsonrpc: "2.0",
1281
+ id: request.id ?? null,
1282
+ error: { code: -32601, message: `unknown_method:${request.method}` },
1283
+ };
1284
+ const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
1285
+ const args = params.arguments ?? {};
1286
+ const server = createCoordinatorMcpServer({
1287
+ env: options.env ?? process.env,
1288
+ services: options.createSession ? { startSession: () => options.createSession?.() } : undefined,
1289
+ });
1290
+ return {
1291
+ jsonrpc: "2.0",
1292
+ id: request.id ?? null,
1293
+ result: legacyToolResult(await server.callTool(params.name ?? "", args)),
1294
+ };
1295
+ }
1296
+
1297
+ export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
1298
+ const server = createCoordinatorMcpServer(options);
1299
+ let buffer = "";
1300
+ for await (const chunk of process.stdin) {
1301
+ buffer += chunk.toString();
1302
+ let newline = buffer.indexOf("\n");
1303
+ while (newline >= 0) {
1304
+ const line = buffer.slice(0, newline).trim();
1305
+ buffer = buffer.slice(newline + 1);
1306
+ if (line.length > 0) {
1307
+ const request = JSON.parse(line) as JsonRpcRequest;
1308
+ if (request.id !== undefined && request.id !== null) {
1309
+ const response = await server.handleJsonRpc(request);
1310
+ process.stdout.write(`${JSON.stringify(response)}\n`);
1311
+ }
1312
+ }
1313
+ newline = buffer.indexOf("\n");
1314
+ }
1315
+ }
1316
+ }