@evo-dev/core 0.0.1-alpha → 0.0.1-alpha.2

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 (41) hide show
  1. package/assets/agents/review/code-reviewer/examples.md +1 -1
  2. package/assets/agents/review/code-reviewer/prompt.md +1 -1
  3. package/assets/agents/review/code-reviewer/verification.md +1 -1
  4. package/assets/skills/coding/knowledge-distillation/SKILL.md +249 -0
  5. package/assets/skills/coding/knowledge-distillation/manifest.json +10 -0
  6. package/assets/skills/coding/knowledge-distillation/references/knowledge-distillation-methods.md +126 -0
  7. package/assets/workflows/rd-bug-fix/WORKFLOW.json +1 -1
  8. package/assets/workflows/rd-code-review/WORKFLOW.json +1 -1
  9. package/assets/workflows/rd-docs-update/WORKFLOW.json +1 -1
  10. package/assets/workflows/rd-feature-implementation/WORKFLOW.json +1 -1
  11. package/assets/workflows/rd-refactor/WORKFLOW.json +1 -1
  12. package/assets/workflows/rd-release-readiness/WORKFLOW.json +1 -1
  13. package/assets/workflows/rd-security-boundary-review/WORKFLOW.json +2 -2
  14. package/assets/workflows/rd-test-generation/WORKFLOW.json +1 -1
  15. package/dist/config/index.js +968 -39
  16. package/dist/index.js +10914 -1476
  17. package/dist/plugins/index.js +32 -32
  18. package/package.json +5 -1
  19. package/src/agents/index.ts +84 -49
  20. package/src/code-agent-traces/index.ts +521 -0
  21. package/src/config/index.ts +5 -0
  22. package/src/config/paths.ts +30 -0
  23. package/src/config/settings.ts +130 -0
  24. package/src/config/store.ts +152 -0
  25. package/src/daemon/index.ts +465 -3
  26. package/src/evolution/index.ts +2827 -0
  27. package/src/hooks/index.ts +543 -247
  28. package/src/index.ts +6 -0
  29. package/src/knowledge/index.ts +4784 -0
  30. package/src/pack/index.ts +13 -13
  31. package/src/plugins/capabilities.ts +40 -42
  32. package/src/plugins/index.ts +0 -1
  33. package/src/plugins/types.ts +4 -0
  34. package/src/protected-zones/index.ts +29 -11
  35. package/src/runtime-logs/index.ts +798 -0
  36. package/src/sync/orchestrator.ts +6 -0
  37. package/src/task/index.ts +3 -3
  38. package/src/team/index.ts +3069 -0
  39. package/src/team/mcp.ts +405 -0
  40. package/src/team/prompts.ts +141 -0
  41. package/src/workflow/index.ts +6 -6
@@ -0,0 +1,3069 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFile, cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { resolveEvoDevPaths } from "../config/paths.ts";
5
+ import {
6
+ createDefaultSettings,
7
+ parseSettings,
8
+ readRuntimeInjectionSettings,
9
+ } from "../config/settings.ts";
10
+ import {
11
+ createScopedKnowledgeContextPack,
12
+ formatScopedKnowledgePromptBlock,
13
+ hasContextInjectionReceipt,
14
+ writeContextInjectionReceipt,
15
+ } from "../knowledge/index.ts";
16
+ import { resolveProjectLogKey } from "../runtime-logs/index.ts";
17
+ import { renderTeamRoleStartupPrompt } from "./prompts.ts";
18
+
19
+ export type TeamAgentRuntime = "codex" | "claude";
20
+ export type TeamAgentStatus =
21
+ | "starting"
22
+ | "running"
23
+ | "busy"
24
+ | "idle"
25
+ | "waiting-input"
26
+ | "recovering"
27
+ | "recreated"
28
+ | "stopped"
29
+ | "exited"
30
+ | "needs-user-attention"
31
+ | "failed"
32
+ | "unknown";
33
+ export type TeamRunStatus = "running" | "stopped" | "failed";
34
+ export type TeamWriteMode = "read-only" | "repo-write" | "worktree-write" | "disabled";
35
+ export type TeamMessageType = "request" | "result" | "issue" | "notice";
36
+ export type TeamMessageDeliveryState = "pending" | "claimed" | "wakeup-sent" | "failed";
37
+ export type TeamRuntimeAgentRecoveryMode =
38
+ | { type: "resume"; sessionId: string }
39
+ | { type: "resume-latest" }
40
+ | { type: "fresh" };
41
+ export type TeamSessionMissingDecision = "ask" | "fail" | "recreate" | "resume-latest";
42
+
43
+ export interface TeamRuntimeCommandResult {
44
+ exitCode: number;
45
+ stdout: string;
46
+ stderr: string;
47
+ }
48
+
49
+ export interface TeamRuntimeCommandRunner {
50
+ run(
51
+ command: string,
52
+ args: string[],
53
+ options?: { input?: string },
54
+ ): Promise<TeamRuntimeCommandResult>;
55
+ }
56
+
57
+ export interface TmuxRuntimeAdapterOptions {
58
+ runtimeCommands?: Partial<Record<TeamAgentRuntime, string>>;
59
+ environment?: Record<string, string | undefined>;
60
+ }
61
+
62
+ export interface TeamRuntimeAdapter {
63
+ createRun(input: TeamRuntimeCreateRunInput): Promise<TeamRuntimeAgentHandle>;
64
+ spawnAgent(input: TeamRuntimeSpawnAgentInput): Promise<TeamRuntimeAgentHandle>;
65
+ recoverAgent(input: TeamRuntimeRecoverAgentInput): Promise<TeamRuntimeAgentHandle>;
66
+ sendInput(input: TeamRuntimeSendInputInput): Promise<void>;
67
+ listPanes(input: TeamRuntimeListPanesInput): Promise<TeamRuntimePaneInfo[]>;
68
+ stopAgent(input: TeamRuntimeStopAgentInput): Promise<void>;
69
+ stopRun(input: TeamRuntimeStopRunInput): Promise<void>;
70
+ formatAttachCommand(input: TeamRuntimeAttachInput): string;
71
+ }
72
+
73
+ export interface TeamRuntimeCreateRunInput {
74
+ runId: string;
75
+ sessionName: string;
76
+ repoRoot: string;
77
+ role: ResolvedTeamRole;
78
+ startupPrompt: string;
79
+ }
80
+
81
+ export interface TeamRuntimeSpawnAgentInput {
82
+ runId: string;
83
+ sessionName: string;
84
+ repoRoot: string;
85
+ role: ResolvedTeamRole;
86
+ startupPrompt: string;
87
+ targetPaneId?: string;
88
+ }
89
+
90
+ export interface TeamRuntimeRecoverAgentInput {
91
+ runId: string;
92
+ sessionName: string;
93
+ repoRoot: string;
94
+ role: ResolvedTeamRole;
95
+ startupPrompt: string;
96
+ mode: TeamRuntimeAgentRecoveryMode;
97
+ targetPaneId?: string;
98
+ }
99
+
100
+ export interface TeamRuntimeAgentHandle {
101
+ session: string;
102
+ window: string;
103
+ paneId: string;
104
+ nativeSessionId?: string | null;
105
+ }
106
+
107
+ export interface TeamRuntimeSendInputInput {
108
+ session: string;
109
+ paneId: string;
110
+ text: string;
111
+ }
112
+
113
+ export interface TeamRuntimeListPanesInput {
114
+ session: string;
115
+ }
116
+
117
+ export interface TeamRuntimePaneInfo {
118
+ paneId: string;
119
+ }
120
+
121
+ export interface TeamRuntimeStopRunInput {
122
+ session: string;
123
+ }
124
+
125
+ export interface TeamRuntimeStopAgentInput {
126
+ session: string;
127
+ paneId: string;
128
+ }
129
+
130
+ export interface TeamRuntimeAttachInput {
131
+ session: string;
132
+ paneId?: string;
133
+ }
134
+
135
+ export interface TeamRoleRuntimeContextInput {
136
+ homeDir?: string;
137
+ runId: string;
138
+ roleId: string;
139
+ }
140
+
141
+ export interface TeamRolePermissions {
142
+ writeMode: TeamWriteMode;
143
+ canUseTeamsMcp: boolean;
144
+ canSpawnAgents: boolean;
145
+ canStopAgents: boolean;
146
+ }
147
+
148
+ export interface TeamRolePolicy {
149
+ roleInstancePolicy: "single-per-role";
150
+ recordTranscript: boolean;
151
+ }
152
+
153
+ export interface TeamRoleDefinition {
154
+ version: 1;
155
+ roleId: string;
156
+ roleName: string;
157
+ description: string;
158
+ runtime: TeamAgentRuntime;
159
+ model: string | null;
160
+ thinkingLevel: string | null;
161
+ prompt: string;
162
+ permissions: TeamRolePermissions;
163
+ teamPolicy: TeamRolePolicy;
164
+ source: "builtin" | "global";
165
+ }
166
+
167
+ export interface ResolvedTeamRole extends TeamRoleDefinition {
168
+ sourcePath: string | null;
169
+ nativeAgent: TeamNativeAgentBinding | null;
170
+ }
171
+
172
+ export interface TeamNativeAgentBinding {
173
+ roleId: string;
174
+ target: TeamAgentRuntime;
175
+ agentName: string;
176
+ scope: "global" | "project";
177
+ projectKey: string | null;
178
+ updatedAt: string;
179
+ }
180
+
181
+ export interface TeamRoleBindingRecord {
182
+ version: 1;
183
+ roleId: string;
184
+ target: TeamAgentRuntime;
185
+ agentName: string;
186
+ updatedAt: string;
187
+ }
188
+
189
+ export interface TeamRoleBindingConfig {
190
+ version: 1;
191
+ updatedAt: string;
192
+ roles: TeamRoleBindingRecord[];
193
+ }
194
+
195
+ export interface TeamRoleBindingScopeInput {
196
+ homeDir?: string;
197
+ repoRoot?: string;
198
+ scope: "global" | "project";
199
+ }
200
+
201
+ export interface TeamRunRecord {
202
+ version: 1;
203
+ runId: string;
204
+ repoRoot: string;
205
+ status: TeamRunStatus;
206
+ mainAgentId: string;
207
+ roleInstancePolicy: "single-per-role";
208
+ tmux: {
209
+ session: string;
210
+ };
211
+ roles: Record<string, string>;
212
+ createdAt: string;
213
+ updatedAt: string;
214
+ }
215
+
216
+ export interface TeamAgentRecord {
217
+ version: 1;
218
+ agentId: string;
219
+ roleId: string;
220
+ roleName: string;
221
+ runId: string;
222
+ runtime: TeamAgentRuntime;
223
+ model: string | null;
224
+ thinkingLevel: string | null;
225
+ status: TeamAgentStatus;
226
+ permissions: TeamRolePermissions;
227
+ nativeSession: {
228
+ sessionId: string | null;
229
+ capturedAt: string | null;
230
+ };
231
+ tmux: {
232
+ session: string;
233
+ window: string;
234
+ paneId: string;
235
+ };
236
+ createdAt: string;
237
+ updatedAt: string;
238
+ }
239
+
240
+ export interface TeamMessageRecord {
241
+ version: 1;
242
+ messageId: string;
243
+ runId: string;
244
+ fromRoleId: string;
245
+ toRoleId: string;
246
+ ccRoleIds: string[];
247
+ type: TeamMessageType;
248
+ body: string;
249
+ status: "accepted";
250
+ createdAt: string;
251
+ }
252
+
253
+ export interface TeamPendingMessageRecord extends TeamMessageRecord {
254
+ deliveryState: TeamMessageDeliveryState;
255
+ attemptCount: number;
256
+ lastAttemptAt: string | null;
257
+ }
258
+
259
+ export interface TeamMessageListFile {
260
+ version: 1;
261
+ updatedAt: string | null;
262
+ messages: TeamPendingMessageRecord[];
263
+ }
264
+
265
+ export interface TeamEventRecord {
266
+ version: 1;
267
+ eventId: string;
268
+ runId: string;
269
+ type:
270
+ | "TeamRunStarted"
271
+ | "AgentSpawned"
272
+ | "AgentStopped"
273
+ | "TeamMessageAccepted"
274
+ | "TeamMessageDelivered"
275
+ | "TeamMessageDeliveryFailed"
276
+ | "TeamMessageWakeupSent"
277
+ | "AgentNativeSessionRecorded"
278
+ | "AgentHookStateUpdated"
279
+ | "AgentRecoveryDecisionRequired"
280
+ | "AgentRecovered"
281
+ | "AgentRecreated"
282
+ | "TeamRunStopped";
283
+ roleId?: string;
284
+ agentId?: string;
285
+ messageId?: string;
286
+ summary: string;
287
+ createdAt: string;
288
+ }
289
+
290
+ export interface TeamRunStatusSnapshot {
291
+ version: 1;
292
+ runId: string;
293
+ repoRoot: string;
294
+ runStatus: TeamRunStatus;
295
+ tmux: {
296
+ session: string;
297
+ };
298
+ agents: Array<{
299
+ roleId: string;
300
+ agentId: string;
301
+ roleName: string;
302
+ runtime: TeamAgentRuntime;
303
+ status: TeamAgentStatus;
304
+ paneId: string;
305
+ window: string;
306
+ nativeSessionId: string | null;
307
+ updatedAt: string;
308
+ }>;
309
+ lastEvent: string | null;
310
+ updatedAt: string;
311
+ }
312
+
313
+ export interface StartTeamRunInput {
314
+ homeDir?: string;
315
+ repoRoot: string;
316
+ now?: Date;
317
+ mainOverrides?: Partial<{
318
+ runtime: TeamAgentRuntime;
319
+ model: string | null;
320
+ thinkingLevel: string | null;
321
+ }>;
322
+ runtimeAdapter?: TeamRuntimeAdapter;
323
+ }
324
+
325
+ export interface SpawnTeamRoleInput {
326
+ homeDir?: string;
327
+ repoRoot: string;
328
+ runId?: string;
329
+ roleId: string;
330
+ now?: Date;
331
+ runtimeAdapter?: TeamRuntimeAdapter;
332
+ }
333
+
334
+ export interface StopTeamRoleInput {
335
+ homeDir?: string;
336
+ runId?: string;
337
+ roleId: string;
338
+ now?: Date;
339
+ runtimeAdapter?: TeamRuntimeAdapter;
340
+ }
341
+
342
+ export interface SendTeamMessageInput {
343
+ homeDir?: string;
344
+ runId?: string;
345
+ fromRoleId?: string;
346
+ toRoleId: string;
347
+ message: string;
348
+ type?: TeamMessageType;
349
+ now?: Date;
350
+ runtimeAdapter?: TeamRuntimeAdapter;
351
+ }
352
+
353
+ export interface StopTeamRunInput {
354
+ homeDir?: string;
355
+ runId?: string;
356
+ now?: Date;
357
+ runtimeAdapter?: TeamRuntimeAdapter;
358
+ }
359
+
360
+ export interface TeamStatusInput {
361
+ homeDir?: string;
362
+ runId?: string;
363
+ }
364
+
365
+ export interface ListTeamRunsInput {
366
+ homeDir?: string;
367
+ }
368
+
369
+ export interface ReconcileTeamRunInput {
370
+ homeDir?: string;
371
+ runId?: string;
372
+ now?: Date;
373
+ runtimeAdapter?: TeamRuntimeAdapter;
374
+ notifyMain?: boolean;
375
+ }
376
+
377
+ export interface ResumeTeamRunInput {
378
+ homeDir?: string;
379
+ runId?: string;
380
+ now?: Date;
381
+ runtimeAdapter?: TeamRuntimeAdapter;
382
+ missingSessionDecision?: TeamSessionMissingDecision;
383
+ notifyMain?: boolean;
384
+ }
385
+
386
+ export interface RecordTeamAgentNativeSessionInput {
387
+ homeDir?: string;
388
+ runId?: string;
389
+ roleId: string;
390
+ sessionId: string;
391
+ now?: Date;
392
+ }
393
+
394
+ export interface TeamAttachInput {
395
+ homeDir?: string;
396
+ runId?: string;
397
+ roleId?: string;
398
+ runtimeAdapter?: TeamRuntimeAdapter;
399
+ }
400
+
401
+ export interface TeamRunStartResult {
402
+ run: TeamRunRecord;
403
+ mainAgent: TeamAgentRecord;
404
+ }
405
+
406
+ export interface TeamRoleSpawnResult {
407
+ run: TeamRunRecord;
408
+ agent: TeamAgentRecord;
409
+ created: boolean;
410
+ }
411
+
412
+ export interface TeamRoleStopResult {
413
+ ok: boolean;
414
+ run?: TeamRunRecord;
415
+ agent?: TeamAgentRecord;
416
+ stopped?: boolean;
417
+ error?: string;
418
+ message?: string;
419
+ }
420
+
421
+ export interface TeamRoleLifecycleSpawnResult {
422
+ ok: boolean;
423
+ run?: TeamRunRecord;
424
+ agent?: TeamAgentRecord;
425
+ created?: boolean;
426
+ error?: string;
427
+ message?: string;
428
+ }
429
+
430
+ export async function listTeamRoleBindings(input: {
431
+ homeDir?: string;
432
+ repoRoot?: string;
433
+ }): Promise<TeamNativeAgentBinding[]> {
434
+ const global = (
435
+ await readTeamBindingConfig(resolveGlobalTeamBindingPath(input.homeDir))
436
+ ).roles.map((record) => bindingRecordToNative(record, "global", null));
437
+ if (input.repoRoot === undefined) return global;
438
+
439
+ const projectKey = resolveProjectLogKey(
440
+ resolveEvoDevPaths(input.homeDir).homeDir,
441
+ input.repoRoot,
442
+ );
443
+ const project = (
444
+ await readTeamBindingConfig(resolveProjectTeamBindingPath(input.homeDir, projectKey))
445
+ ).roles.map((record) => bindingRecordToNative(record, "project", projectKey));
446
+ return mergeTeamRoleBindings(global, project);
447
+ }
448
+
449
+ export async function setTeamRoleBinding(input: {
450
+ homeDir?: string;
451
+ repoRoot?: string;
452
+ scope: "global" | "project";
453
+ roleId: string;
454
+ target: TeamAgentRuntime;
455
+ agentName: string;
456
+ now?: Date;
457
+ }): Promise<{ path: string; binding: TeamNativeAgentBinding }> {
458
+ assertSafeId(input.roleId, "roleId");
459
+ assertSafeId(input.agentName, "agentName");
460
+ const { path, projectKey } = resolveTeamBindingWritePath(input);
461
+ const timestamp = (input.now ?? new Date()).toISOString();
462
+ const current = await readTeamBindingConfig(path);
463
+ const nextRecord: TeamRoleBindingRecord = {
464
+ version: 1,
465
+ roleId: input.roleId,
466
+ target: input.target,
467
+ agentName: input.agentName,
468
+ updatedAt: timestamp,
469
+ };
470
+ const roles = [
471
+ ...current.roles.filter((record) => record.roleId !== input.roleId),
472
+ nextRecord,
473
+ ].sort((left, right) => left.roleId.localeCompare(right.roleId));
474
+ await writeJson(path, { version: 1, updatedAt: timestamp, roles });
475
+ return { path, binding: bindingRecordToNative(nextRecord, input.scope, projectKey) };
476
+ }
477
+
478
+ export async function unsetTeamRoleBinding(input: {
479
+ homeDir?: string;
480
+ repoRoot?: string;
481
+ scope: "global" | "project";
482
+ roleId: string;
483
+ now?: Date;
484
+ }): Promise<{ path: string; removed: boolean }> {
485
+ assertSafeId(input.roleId, "roleId");
486
+ const { path } = resolveTeamBindingWritePath(input);
487
+ const current = await readTeamBindingConfig(path);
488
+ const roles = current.roles.filter((record) => record.roleId !== input.roleId);
489
+ const removed = roles.length !== current.roles.length;
490
+ if (removed) {
491
+ await writeJson(path, {
492
+ version: 1,
493
+ updatedAt: (input.now ?? new Date()).toISOString(),
494
+ roles,
495
+ });
496
+ }
497
+ return { path, removed };
498
+ }
499
+
500
+ export interface TeamMessageSendResult {
501
+ ok: boolean;
502
+ messageId?: string;
503
+ delivery?: "queued";
504
+ queuedFor?: string;
505
+ cc?: string[];
506
+ error?: string;
507
+ message?: string;
508
+ }
509
+
510
+ export interface TeamMessageBrokerOptions {
511
+ homeDir?: string;
512
+ runtimeAdapter?: TeamRuntimeAdapter;
513
+ }
514
+
515
+ export type TeamMessageBrokerSendInput = Omit<SendTeamMessageInput, "homeDir" | "runtimeAdapter">;
516
+ export interface ReadPendingTeamMessagesInput {
517
+ homeDir?: string;
518
+ runId: string;
519
+ roleId: string;
520
+ limit?: number;
521
+ }
522
+
523
+ export interface MarkTeamMessagesDeliveredInput {
524
+ homeDir?: string;
525
+ runId: string;
526
+ roleId: string;
527
+ messageIds: string[];
528
+ now?: Date;
529
+ }
530
+
531
+ export interface UpdateTeamAgentHookStateInput {
532
+ homeDir?: string;
533
+ runId: string;
534
+ roleId: string;
535
+ hookEvent: string;
536
+ now?: Date;
537
+ }
538
+
539
+ export interface TeamMessageBrokerSpawnInput {
540
+ runId?: string;
541
+ fromRoleId?: string;
542
+ roleId: string;
543
+ reason?: string;
544
+ now?: Date;
545
+ }
546
+
547
+ export interface TeamMessageBrokerStopInput {
548
+ runId?: string;
549
+ fromRoleId?: string;
550
+ roleId: string;
551
+ reason?: string;
552
+ now?: Date;
553
+ }
554
+
555
+ export interface TeamStatusResult {
556
+ run: TeamRunRecord | null;
557
+ agents: TeamAgentRecord[];
558
+ }
559
+
560
+ export interface TeamRunReconcileResult extends TeamStatusResult {
561
+ stoppedAgents: TeamAgentRecord[];
562
+ notifications: TeamMessageSendResult[];
563
+ runtimeAvailable: boolean;
564
+ }
565
+
566
+ export interface TeamAgentRecoveryResult {
567
+ roleId: string;
568
+ agentId: string;
569
+ outcome: "reused" | "resumed" | "recreated" | "needs-decision" | "failed";
570
+ previousPaneId: string;
571
+ paneId: string | null;
572
+ nativeSessionId: string | null;
573
+ reason?: string;
574
+ decisionOptions?: TeamSessionMissingDecision[];
575
+ }
576
+
577
+ export interface TeamRunResumeResult extends TeamStatusResult {
578
+ outcomes: TeamAgentRecoveryResult[];
579
+ decisionRequired: TeamAgentRecoveryResult[];
580
+ notifications: TeamMessageSendResult[];
581
+ }
582
+
583
+ export interface TeamMessageDeliveryScheduleResult {
584
+ wokenRoleIds: string[];
585
+ recoveredRoleIds: string[];
586
+ needsUserAttentionRoleIds: string[];
587
+ warnings: string[];
588
+ }
589
+
590
+ export interface TeamAgentSummary {
591
+ roleId: string;
592
+ roleName: string;
593
+ status: TeamAgentStatus;
594
+ runtime: TeamAgentRuntime;
595
+ canReceiveMessages: boolean;
596
+ isIdle: boolean;
597
+ isMidTurn: boolean;
598
+ }
599
+
600
+ export interface TeamRunStore {
601
+ paths: ReturnType<typeof resolveTeamRunPaths>;
602
+ createRunDirs(runId: string): Promise<void>;
603
+ writeRun(run: TeamRunRecord): Promise<void>;
604
+ readRun(runId: string): Promise<TeamRunRecord>;
605
+ writeLatestRunId(runId: string): Promise<void>;
606
+ readLatestRunId(): Promise<string | null>;
607
+ writeAgent(agent: TeamAgentRecord): Promise<void>;
608
+ readAgent(runId: string, roleId: string): Promise<TeamAgentRecord>;
609
+ readAgents(runId: string): Promise<TeamAgentRecord[]>;
610
+ readMessages(runId: string): Promise<TeamMessageRecord[]>;
611
+ readEvents(runId: string): Promise<TeamEventRecord[]>;
612
+ readMessageList(runId: string): Promise<TeamPendingMessageRecord[]>;
613
+ writeMessageList(
614
+ runId: string,
615
+ messages: TeamPendingMessageRecord[],
616
+ updatedAt: string,
617
+ ): Promise<void>;
618
+ writeStatus(runId: string, snapshot: TeamRunStatusSnapshot): Promise<void>;
619
+ appendMessage(runId: string, message: TeamMessageRecord): Promise<void>;
620
+ appendEvent(runId: string, event: TeamEventRecord): Promise<void>;
621
+ }
622
+
623
+ const SAFE_ROLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
624
+ export const TEAM_INTERNAL_WAKE_SIGNAL = [
625
+ "[EvoDev internal wake signal]",
626
+ "No user request is included in this message. Continue only from EvoDev team inbox messages injected by hooks.",
627
+ ].join("\n");
628
+ const BUILT_IN_ROLE_PROMPTS: Record<
629
+ string,
630
+ { roleName: string; description: string; prompt: string }
631
+ > = {
632
+ main: {
633
+ roleName: "Main Conductor",
634
+ description: "Coordinates the EvoDev team run and owns final synthesis.",
635
+ prompt:
636
+ "You are the main conductor for this EvoDev team run. Decide whether the user request actually needs team execution. If it does, plan required role agents, role assignments, dependencies, and runnable batches before spawning roles. Coordinate role agents, keep decisions explicit, and synthesize final outcomes. Once work is delegated to role agents, do not perform implementation, testing, or review details yourself.",
637
+ },
638
+ reviewer: {
639
+ roleName: "Code Reviewer",
640
+ description: "Reviews implementation quality, risks, and regressions.",
641
+ prompt:
642
+ "You are the code reviewer for this EvoDev team run. Review assigned changes and report concrete findings to the role that can act on them; notify main only when coordination or final synthesis is needed.",
643
+ },
644
+ tester: {
645
+ roleName: "Test Engineer",
646
+ description: "Verifies behavior and identifies test gaps.",
647
+ prompt:
648
+ "You are the test engineer for this EvoDev team run. Run or recommend focused verification and report gaps to the implementing role when they need action; notify main only when coordination or final synthesis is needed.",
649
+ },
650
+ executor: {
651
+ roleName: "Implementation Executor",
652
+ description: "Implements scoped changes assigned by main.",
653
+ prompt:
654
+ "You are the implementation executor for this EvoDev team run. Keep changes scoped, avoid taking conductor decisions, and report changed files plus verification evidence.",
655
+ },
656
+ };
657
+
658
+ export function createTeamRunStore(homeDir?: string): TeamRunStore {
659
+ const paths = resolveTeamRunPaths(homeDir);
660
+
661
+ return {
662
+ paths,
663
+ async createRunDirs(runId) {
664
+ await migrateLegacyRunDirIfNeeded(paths, runId);
665
+ await mkdir(paths.runDir(runId), { recursive: true });
666
+ await mkdir(paths.agentsDir(runId), { recursive: true });
667
+ },
668
+ async writeRun(run) {
669
+ await this.createRunDirs(run.runId);
670
+ await writeJson(paths.runPath(run.runId), parseTeamRunRecord(run));
671
+ },
672
+ async readRun(runId) {
673
+ await migrateLegacyRunDirIfNeeded(paths, runId);
674
+ return parseTeamRunRecord(JSON.parse(await readFile(paths.runPath(runId), "utf8")));
675
+ },
676
+ async writeLatestRunId(runId) {
677
+ await mkdir(paths.runsDir, { recursive: true });
678
+ await writeJson(paths.latestRunPath, { version: 1, runId });
679
+ },
680
+ async readLatestRunId() {
681
+ try {
682
+ const latest = JSON.parse(await readFile(paths.latestRunPath, "utf8"));
683
+ if (latest?.version === 1 && typeof latest.runId === "string") return latest.runId;
684
+ return null;
685
+ } catch {
686
+ try {
687
+ const latest = JSON.parse(await readFile(paths.legacyLatestRunPath, "utf8"));
688
+ if (latest?.version === 1 && typeof latest.runId === "string") {
689
+ await migrateLegacyRunDirIfNeeded(paths, latest.runId);
690
+ await this.writeLatestRunId(latest.runId);
691
+ return latest.runId;
692
+ }
693
+ return null;
694
+ } catch {
695
+ return null;
696
+ }
697
+ }
698
+ },
699
+ async writeAgent(agent) {
700
+ await this.createRunDirs(agent.runId);
701
+ await writeJson(paths.agentPath(agent.runId, agent.roleId), parseTeamAgentRecord(agent));
702
+ },
703
+ async readAgent(runId, roleId) {
704
+ await migrateLegacyRunDirIfNeeded(paths, runId);
705
+ return parseTeamAgentRecord(
706
+ JSON.parse(await readFile(paths.agentPath(runId, roleId), "utf8")),
707
+ );
708
+ },
709
+ async readAgents(runId) {
710
+ await migrateLegacyRunDirIfNeeded(paths, runId);
711
+ try {
712
+ const entries = await readdir(paths.agentsDir(runId));
713
+ const agents = await Promise.all(
714
+ entries
715
+ .filter((entry) => entry.endsWith(".json"))
716
+ .map((entry) =>
717
+ readFile(join(paths.agentsDir(runId), entry), "utf8").then((raw) =>
718
+ parseTeamAgentRecord(JSON.parse(raw)),
719
+ ),
720
+ ),
721
+ );
722
+ return agents.sort((left, right) => left.roleId.localeCompare(right.roleId));
723
+ } catch {
724
+ return [];
725
+ }
726
+ },
727
+ async readMessages(runId) {
728
+ await migrateLegacyRunDirIfNeeded(paths, runId);
729
+ return readJsonLines(paths.messagesPath(runId), parseTeamMessageRecord);
730
+ },
731
+ async readEvents(runId) {
732
+ await migrateLegacyRunDirIfNeeded(paths, runId);
733
+ return readJsonLines(paths.eventsPath(runId), parseTeamEventRecord);
734
+ },
735
+ async readMessageList(runId) {
736
+ await migrateLegacyRunDirIfNeeded(paths, runId);
737
+ try {
738
+ const file = parseTeamMessageListFile(
739
+ JSON.parse(await readFile(paths.messageListPath(runId), "utf8")),
740
+ );
741
+ return file.messages;
742
+ } catch (error) {
743
+ if (isNotFoundError(error)) return [];
744
+ throw error;
745
+ }
746
+ },
747
+ async writeMessageList(runId, messages, updatedAt) {
748
+ await this.createRunDirs(runId);
749
+ await writeJson(paths.messageListPath(runId), {
750
+ version: 1,
751
+ updatedAt,
752
+ messages: messages.map(parseTeamPendingMessageRecord),
753
+ });
754
+ },
755
+ async writeStatus(runId, snapshot) {
756
+ await this.createRunDirs(runId);
757
+ await writeJson(paths.statusPath(runId), snapshot);
758
+ },
759
+ async appendMessage(runId, message) {
760
+ await this.createRunDirs(runId);
761
+ const parsed = parseTeamMessageRecord(message);
762
+ await appendJsonLine(paths.messagesPath(runId), parsed);
763
+ const pending = await this.readMessageList(runId);
764
+ await this.writeMessageList(
765
+ runId,
766
+ [
767
+ ...pending.filter((item) => item.messageId !== parsed.messageId),
768
+ createPendingMessageRecord(parsed),
769
+ ],
770
+ parsed.createdAt,
771
+ );
772
+ },
773
+ async appendEvent(runId, event) {
774
+ await this.createRunDirs(runId);
775
+ await appendJsonLine(paths.eventsPath(runId), parseTeamEventRecord(event));
776
+ },
777
+ };
778
+ }
779
+
780
+ export function resolveTeamRunPaths(homeDir?: string) {
781
+ const paths = resolveEvoDevPaths(homeDir);
782
+ const legacyRunsDir = join(paths.rootDir, "runs");
783
+ return {
784
+ rootDir: paths.rootDir,
785
+ roleAgentsDir: paths.roleAgentsDir,
786
+ teamsDir: paths.teamsDir,
787
+ runsDir: paths.runsDir,
788
+ legacyRunsDir,
789
+ latestRunPath: paths.latestRunPath,
790
+ legacyLatestRunPath: join(legacyRunsDir, "latest.json"),
791
+ runDir: (runId: string) => join(paths.runsDir, runId),
792
+ legacyRunDir: (runId: string) => join(legacyRunsDir, runId),
793
+ runPath: (runId: string) => join(paths.runsDir, runId, "run.json"),
794
+ legacyRunPath: (runId: string) => join(legacyRunsDir, runId, "run.json"),
795
+ tmuxPath: (runId: string) => join(paths.runsDir, runId, "tmux.json"),
796
+ legacyTmuxPath: (runId: string) => join(legacyRunsDir, runId, "tmux.json"),
797
+ agentsDir: (runId: string) => join(paths.runsDir, runId, "agents"),
798
+ legacyAgentsDir: (runId: string) => join(legacyRunsDir, runId, "agents"),
799
+ agentPath: (runId: string, roleId: string) =>
800
+ join(paths.runsDir, runId, "agents", `${roleId}.json`),
801
+ legacyAgentPath: (runId: string, roleId: string) =>
802
+ join(legacyRunsDir, runId, "agents", `${roleId}.json`),
803
+ messagesPath: (runId: string) => join(paths.runsDir, runId, "messages.jsonl"),
804
+ legacyMessagesPath: (runId: string) => join(legacyRunsDir, runId, "messages.jsonl"),
805
+ eventsPath: (runId: string) => join(paths.runsDir, runId, "events.jsonl"),
806
+ legacyEventsPath: (runId: string) => join(legacyRunsDir, runId, "events.jsonl"),
807
+ statusPath: (runId: string) => join(paths.runsDir, runId, "status.json"),
808
+ messageListPath: (runId: string) => join(paths.runsDir, runId, "message-list.json"),
809
+ };
810
+ }
811
+
812
+ async function migrateLegacyRunDirIfNeeded(
813
+ paths: ReturnType<typeof resolveTeamRunPaths>,
814
+ runId: string,
815
+ ): Promise<void> {
816
+ const nextDir = paths.runDir(runId);
817
+ if (await pathExists(nextDir)) return;
818
+ const legacyDir = paths.legacyRunDir(runId);
819
+ if (!(await pathExists(legacyDir))) return;
820
+ await mkdir(dirname(nextDir), { recursive: true });
821
+ await cp(legacyDir, nextDir, { recursive: true, errorOnExist: false, force: false });
822
+ }
823
+
824
+ async function writeTeamStatusSnapshot(input: {
825
+ store: TeamRunStore;
826
+ runId: string;
827
+ now: string;
828
+ lastEvent?: string | null;
829
+ }): Promise<TeamRunStatusSnapshot | null> {
830
+ try {
831
+ const run = await input.store.readRun(input.runId);
832
+ const agents = await input.store.readAgents(run.runId);
833
+ const snapshot: TeamRunStatusSnapshot = {
834
+ version: 1,
835
+ runId: run.runId,
836
+ repoRoot: run.repoRoot,
837
+ runStatus: run.status,
838
+ tmux: { session: run.tmux.session },
839
+ agents: agents.map((agent) => ({
840
+ roleId: agent.roleId,
841
+ agentId: agent.agentId,
842
+ roleName: agent.roleName,
843
+ runtime: agent.runtime,
844
+ status: agent.status,
845
+ paneId: agent.tmux.paneId,
846
+ window: agent.tmux.window,
847
+ nativeSessionId: agent.nativeSession.sessionId,
848
+ updatedAt: agent.updatedAt,
849
+ })),
850
+ lastEvent: input.lastEvent ?? null,
851
+ updatedAt: input.now,
852
+ };
853
+ await input.store.writeStatus(run.runId, snapshot);
854
+ return snapshot;
855
+ } catch {
856
+ return null;
857
+ }
858
+ }
859
+
860
+ export async function startTeamRun(input: StartTeamRunInput): Promise<TeamRunStartResult> {
861
+ const store = createTeamRunStore(input.homeDir);
862
+ const now = input.now ?? new Date();
863
+ const createdAt = now.toISOString();
864
+ const runId = createRunId(input.repoRoot, now);
865
+ const sessionName = createTmuxSessionName(input.repoRoot, runId);
866
+ const role = await resolveTeamRole({
867
+ homeDir: input.homeDir,
868
+ repoRoot: input.repoRoot,
869
+ roleId: "main",
870
+ overrides: input.mainOverrides,
871
+ });
872
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
873
+ const startupPrompt = await createAgentStartupPrompt({
874
+ homeDir: input.homeDir,
875
+ runId,
876
+ repoRoot: input.repoRoot,
877
+ role,
878
+ roster: [],
879
+ now: createdAt,
880
+ });
881
+ const handle = await runtimeAdapter.createRun({
882
+ runId,
883
+ sessionName,
884
+ repoRoot: input.repoRoot,
885
+ role,
886
+ startupPrompt,
887
+ });
888
+ const agent = createAgentRecord({
889
+ runId,
890
+ role,
891
+ handle,
892
+ now: createdAt,
893
+ });
894
+ const run: TeamRunRecord = {
895
+ version: 1,
896
+ runId,
897
+ repoRoot: input.repoRoot,
898
+ status: "running",
899
+ mainAgentId: agent.agentId,
900
+ roleInstancePolicy: "single-per-role",
901
+ tmux: { session: handle.session },
902
+ roles: { main: agent.agentId },
903
+ createdAt,
904
+ updatedAt: createdAt,
905
+ };
906
+
907
+ await store.writeRun(run);
908
+ await store.writeAgent(agent);
909
+ await store.writeLatestRunId(runId);
910
+ await writeJson(store.paths.tmuxPath(runId), {
911
+ version: 1,
912
+ runtime: "tmux",
913
+ session: handle.session,
914
+ createdAt,
915
+ windows: [{ name: handle.window, roles: ["main"] }],
916
+ });
917
+ await store.appendEvent(
918
+ runId,
919
+ createTeamEvent(runId, "TeamRunStarted", `Started team run ${runId}.`, createdAt, {
920
+ roleId: "main",
921
+ agentId: agent.agentId,
922
+ }),
923
+ );
924
+ await writeTeamStatusSnapshot({
925
+ store,
926
+ runId,
927
+ now: createdAt,
928
+ lastEvent: "TeamRunStarted",
929
+ });
930
+
931
+ return { run, mainAgent: agent };
932
+ }
933
+
934
+ export async function spawnTeamRole(input: SpawnTeamRoleInput): Promise<TeamRoleSpawnResult> {
935
+ const store = createTeamRunStore(input.homeDir);
936
+ const runId = await resolveRequestedRunId(store, input.runId);
937
+ const run = await store.readRun(runId);
938
+ if (run.status !== "running") throw new Error(`Team run ${run.runId} is not running.`);
939
+ const existingAgentId = run.roles[input.roleId];
940
+ if (existingAgentId !== undefined) {
941
+ const existing = await store.readAgent(run.runId, input.roleId);
942
+ if (isActiveTeamAgentStatus(existing.status)) {
943
+ return { run, agent: existing, created: false };
944
+ }
945
+ }
946
+
947
+ const now = (input.now ?? new Date()).toISOString();
948
+ const role = await resolveTeamRole({
949
+ homeDir: input.homeDir,
950
+ repoRoot: input.repoRoot,
951
+ roleId: input.roleId,
952
+ });
953
+ const agents = await store.readAgents(run.runId);
954
+ const mainAgent = agents.find((agent) => agent.roleId === "main");
955
+ const startupPrompt = await createAgentStartupPrompt({
956
+ homeDir: input.homeDir,
957
+ runId: run.runId,
958
+ repoRoot: run.repoRoot,
959
+ role,
960
+ roster: agents,
961
+ now,
962
+ });
963
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
964
+ const handle = await runtimeAdapter.spawnAgent({
965
+ runId: run.runId,
966
+ sessionName: run.tmux.session,
967
+ repoRoot: run.repoRoot,
968
+ role,
969
+ startupPrompt,
970
+ targetPaneId: mainAgent?.tmux.paneId,
971
+ });
972
+ const agent = createAgentRecord({ runId: run.runId, role, handle, now });
973
+ const updatedRun: TeamRunRecord = {
974
+ ...run,
975
+ roles: { ...run.roles, [role.roleId]: agent.agentId },
976
+ updatedAt: now,
977
+ };
978
+
979
+ await store.writeRun(updatedRun);
980
+ await store.writeAgent(agent);
981
+ await store.appendEvent(
982
+ run.runId,
983
+ createTeamEvent(run.runId, "AgentSpawned", `Spawned role ${role.roleId}.`, now, {
984
+ roleId: role.roleId,
985
+ agentId: agent.agentId,
986
+ }),
987
+ );
988
+ await writeTeamStatusSnapshot({
989
+ store,
990
+ runId: run.runId,
991
+ now,
992
+ lastEvent: "AgentSpawned",
993
+ });
994
+
995
+ return { run: updatedRun, agent, created: true };
996
+ }
997
+
998
+ export async function stopTeamRole(input: StopTeamRoleInput): Promise<TeamRoleStopResult> {
999
+ const store = createTeamRunStore(input.homeDir);
1000
+ const runId = await resolveRequestedRunId(store, input.runId);
1001
+ const run = await store.readRun(runId);
1002
+ if (run.status !== "running") {
1003
+ return {
1004
+ ok: false,
1005
+ error: "team-run-not-running",
1006
+ message: `Team run ${run.runId} is not running.`,
1007
+ };
1008
+ }
1009
+ if (input.roleId === "main") {
1010
+ return {
1011
+ ok: false,
1012
+ error: "cannot-stop-main-role",
1013
+ message: "Use evodev team stop to stop the entire team run instead of stopping main.",
1014
+ };
1015
+ }
1016
+
1017
+ const agentId = run.roles[input.roleId];
1018
+ if (agentId === undefined) {
1019
+ return {
1020
+ ok: false,
1021
+ error: "target-role-not-found",
1022
+ message: `Role ${input.roleId} does not exist in run ${run.runId}.`,
1023
+ };
1024
+ }
1025
+
1026
+ const agent = await store.readAgent(run.runId, input.roleId);
1027
+ if (!isActiveTeamAgentStatus(agent.status)) {
1028
+ return {
1029
+ ok: false,
1030
+ error: "target-role-not-running",
1031
+ message: `Role ${input.roleId} is not running in run ${run.runId}.`,
1032
+ };
1033
+ }
1034
+
1035
+ const now = (input.now ?? new Date()).toISOString();
1036
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1037
+ await runtimeAdapter.stopAgent({
1038
+ session: agent.tmux.session,
1039
+ paneId: agent.tmux.paneId,
1040
+ });
1041
+ const stoppedAgent: TeamAgentRecord = { ...agent, status: "stopped", updatedAt: now };
1042
+ const updatedRun: TeamRunRecord = { ...run, updatedAt: now };
1043
+ await store.writeRun(updatedRun);
1044
+ await store.writeAgent(stoppedAgent);
1045
+ await store.appendEvent(
1046
+ run.runId,
1047
+ createTeamEvent(run.runId, "AgentStopped", `Stopped role ${input.roleId}.`, now, {
1048
+ roleId: input.roleId,
1049
+ agentId,
1050
+ }),
1051
+ );
1052
+ await writeTeamStatusSnapshot({
1053
+ store,
1054
+ runId: run.runId,
1055
+ now,
1056
+ lastEvent: "AgentStopped",
1057
+ });
1058
+
1059
+ return { ok: true, run: updatedRun, agent: stoppedAgent, stopped: true };
1060
+ }
1061
+
1062
+ export class TeamMessageBroker {
1063
+ constructor(private readonly options: TeamMessageBrokerOptions = {}) {}
1064
+
1065
+ async listAgents(input: TeamStatusInput = {}): Promise<TeamAgentSummary[]> {
1066
+ return listTeamAgents({ homeDir: this.options.homeDir, runId: input.runId });
1067
+ }
1068
+
1069
+ async send(input: TeamMessageBrokerSendInput): Promise<TeamMessageSendResult> {
1070
+ return sendTeamMessageWithBrokerContext(this.options, input);
1071
+ }
1072
+
1073
+ async spawnRole(input: TeamMessageBrokerSpawnInput): Promise<TeamRoleLifecycleSpawnResult> {
1074
+ return spawnTeamRoleWithBrokerContext(this.options, input);
1075
+ }
1076
+
1077
+ async stopRole(input: TeamMessageBrokerStopInput): Promise<TeamRoleStopResult> {
1078
+ return stopTeamRoleWithBrokerContext(this.options, input);
1079
+ }
1080
+ }
1081
+
1082
+ export async function sendTeamMessage(input: SendTeamMessageInput): Promise<TeamMessageSendResult> {
1083
+ return new TeamMessageBroker({
1084
+ homeDir: input.homeDir,
1085
+ runtimeAdapter: input.runtimeAdapter,
1086
+ }).send(input);
1087
+ }
1088
+
1089
+ export async function readPendingTeamMessagesForRole(
1090
+ input: ReadPendingTeamMessagesInput,
1091
+ ): Promise<TeamMessageRecord[]> {
1092
+ const store = createTeamRunStore(input.homeDir);
1093
+ const messages = await store.readMessageList(input.runId);
1094
+ const pending = messages.filter(
1095
+ (message) => message.toRoleId === input.roleId && message.deliveryState !== "failed",
1096
+ );
1097
+ return pending.slice(0, input.limit ?? 5);
1098
+ }
1099
+
1100
+ export async function markTeamMessagesDelivered(
1101
+ input: MarkTeamMessagesDeliveredInput,
1102
+ ): Promise<void> {
1103
+ if (input.messageIds.length === 0) return;
1104
+ const store = createTeamRunStore(input.homeDir);
1105
+ const now = (input.now ?? new Date()).toISOString();
1106
+ const delivered = new Set(input.messageIds);
1107
+ const pending = await store.readMessageList(input.runId);
1108
+ await store.writeMessageList(
1109
+ input.runId,
1110
+ pending.filter((message) => !delivered.has(message.messageId)),
1111
+ now,
1112
+ );
1113
+ for (const messageId of input.messageIds) {
1114
+ assertSafeId(messageId, "messageId");
1115
+ await store.appendEvent(
1116
+ input.runId,
1117
+ createTeamEvent(input.runId, "TeamMessageDelivered", `Delivered message ${messageId}.`, now, {
1118
+ roleId: input.roleId,
1119
+ messageId,
1120
+ }),
1121
+ );
1122
+ }
1123
+ }
1124
+
1125
+ export async function schedulePendingTeamMessageDelivery(input: {
1126
+ homeDir?: string;
1127
+ runId?: string;
1128
+ roleId?: string;
1129
+ now?: Date;
1130
+ runtimeAdapter?: TeamRuntimeAdapter;
1131
+ }): Promise<TeamMessageDeliveryScheduleResult> {
1132
+ const store = createTeamRunStore(input.homeDir);
1133
+ const runId = await resolveRequestedRunId(store, input.runId);
1134
+ const run = await store.readRun(runId);
1135
+ const pending = await store.readMessageList(run.runId);
1136
+ const targetRoleIds = new Set(
1137
+ pending
1138
+ .filter((message) => input.roleId === undefined || message.toRoleId === input.roleId)
1139
+ .map((message) => message.toRoleId),
1140
+ );
1141
+ const result: TeamMessageDeliveryScheduleResult = {
1142
+ wokenRoleIds: [],
1143
+ recoveredRoleIds: [],
1144
+ needsUserAttentionRoleIds: [],
1145
+ warnings: [],
1146
+ };
1147
+ if (targetRoleIds.size === 0) return result;
1148
+
1149
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1150
+ const livePaneIds = await readLivePaneIds(runtimeAdapter, run.tmux.session);
1151
+ const now = (input.now ?? new Date()).toISOString();
1152
+
1153
+ for (const roleId of targetRoleIds) {
1154
+ const agent = await store.readAgent(run.runId, roleId).catch(() => null);
1155
+ if (agent === null) continue;
1156
+
1157
+ if (
1158
+ agent.roleId === "main" &&
1159
+ (run.status !== "running" || !isActiveTeamAgentStatus(agent.status))
1160
+ ) {
1161
+ const updated: TeamAgentRecord = {
1162
+ ...agent,
1163
+ status: "needs-user-attention",
1164
+ updatedAt: now,
1165
+ };
1166
+ await store.writeAgent(updated);
1167
+ await writeTeamStatusSnapshot({
1168
+ store,
1169
+ runId: run.runId,
1170
+ now,
1171
+ lastEvent: "AgentNeedsUserAttention",
1172
+ });
1173
+ result.needsUserAttentionRoleIds.push(roleId);
1174
+ continue;
1175
+ }
1176
+
1177
+ if (run.status !== "running") continue;
1178
+
1179
+ if (!isActiveTeamAgentStatus(agent.status)) {
1180
+ const resumed = await resumeTeamRun({
1181
+ homeDir: input.homeDir,
1182
+ runId: run.runId,
1183
+ now: new Date(now),
1184
+ runtimeAdapter,
1185
+ missingSessionDecision: "recreate",
1186
+ notifyMain: false,
1187
+ });
1188
+ const recovered = resumed.outcomes.find(
1189
+ (outcome) =>
1190
+ outcome.roleId === roleId &&
1191
+ (outcome.outcome === "resumed" || outcome.outcome === "recreated"),
1192
+ );
1193
+ if (recovered !== undefined) result.recoveredRoleIds.push(roleId);
1194
+ else result.warnings.push(`Role ${roleId} could not be automatically recovered.`);
1195
+ continue;
1196
+ }
1197
+
1198
+ if (isIdleTeamAgentStatus(agent.status) && livePaneIds.has(agent.tmux.paneId)) {
1199
+ await runtimeAdapter.sendInput({
1200
+ session: agent.tmux.session,
1201
+ paneId: agent.tmux.paneId,
1202
+ text: TEAM_INTERNAL_WAKE_SIGNAL,
1203
+ });
1204
+ await markPendingMessagesWakeupSent({
1205
+ store,
1206
+ runId: run.runId,
1207
+ roleId,
1208
+ now,
1209
+ });
1210
+ result.wokenRoleIds.push(roleId);
1211
+ }
1212
+ }
1213
+
1214
+ return result;
1215
+ }
1216
+
1217
+ async function markPendingMessagesWakeupSent(input: {
1218
+ store: TeamRunStore;
1219
+ runId: string;
1220
+ roleId: string;
1221
+ now: string;
1222
+ }): Promise<void> {
1223
+ const pending = await input.store.readMessageList(input.runId);
1224
+ const updated = pending.map((message) =>
1225
+ message.toRoleId === input.roleId
1226
+ ? {
1227
+ ...message,
1228
+ deliveryState: "wakeup-sent" as const,
1229
+ attemptCount: message.attemptCount + 1,
1230
+ lastAttemptAt: input.now,
1231
+ }
1232
+ : message,
1233
+ );
1234
+ await input.store.writeMessageList(input.runId, updated, input.now);
1235
+ for (const message of updated.filter((item) => item.toRoleId === input.roleId)) {
1236
+ await input.store.appendEvent(
1237
+ input.runId,
1238
+ createTeamEvent(
1239
+ input.runId,
1240
+ "TeamMessageWakeupSent",
1241
+ `Sent wake signal for message ${message.messageId}.`,
1242
+ input.now,
1243
+ { roleId: input.roleId, messageId: message.messageId },
1244
+ ),
1245
+ );
1246
+ }
1247
+ }
1248
+
1249
+ async function sendTeamMessageWithBrokerContext(
1250
+ options: TeamMessageBrokerOptions,
1251
+ input: TeamMessageBrokerSendInput,
1252
+ ): Promise<TeamMessageSendResult> {
1253
+ const store = createTeamRunStore(options.homeDir);
1254
+ const runId = await resolveRequestedRunId(store, input.runId);
1255
+ const run = await store.readRun(runId);
1256
+ if (run.status !== "running") {
1257
+ return {
1258
+ ok: false,
1259
+ error: "team-run-not-running",
1260
+ message: `Team run ${run.runId} is not running.`,
1261
+ };
1262
+ }
1263
+ const targetAgentId = run.roles[input.toRoleId];
1264
+ if (targetAgentId === undefined) {
1265
+ return {
1266
+ ok: false,
1267
+ error: "target-role-not-found",
1268
+ message: `Role ${input.toRoleId} does not exist in run ${run.runId}.`,
1269
+ };
1270
+ }
1271
+ const target = await store.readAgent(run.runId, input.toRoleId);
1272
+ if (target.status === "failed" || target.status === "unknown") {
1273
+ return {
1274
+ ok: false,
1275
+ error: "target-role-unavailable",
1276
+ message: `Role ${input.toRoleId} is not available in run ${run.runId}.`,
1277
+ };
1278
+ }
1279
+
1280
+ const now = (input.now ?? new Date()).toISOString();
1281
+ const fromRoleId = input.fromRoleId ?? "user";
1282
+ const ccRoleIds =
1283
+ fromRoleId !== "user" && fromRoleId !== "main" && input.toRoleId !== "main" && run.roles.main
1284
+ ? ["main"]
1285
+ : [];
1286
+ const message: TeamMessageRecord = {
1287
+ version: 1,
1288
+ messageId: createMessageId(now),
1289
+ runId: run.runId,
1290
+ fromRoleId,
1291
+ toRoleId: input.toRoleId,
1292
+ ccRoleIds,
1293
+ type: input.type ?? "request",
1294
+ body: input.message,
1295
+ status: "accepted",
1296
+ createdAt: now,
1297
+ };
1298
+
1299
+ await store.appendMessage(run.runId, message);
1300
+ await store.appendEvent(
1301
+ run.runId,
1302
+ createTeamEvent(
1303
+ run.runId,
1304
+ "TeamMessageAccepted",
1305
+ `Accepted message ${message.messageId}.`,
1306
+ now,
1307
+ {
1308
+ messageId: message.messageId,
1309
+ },
1310
+ ),
1311
+ );
1312
+ await schedulePendingTeamMessageDelivery({
1313
+ homeDir: options.homeDir,
1314
+ runId: run.runId,
1315
+ roleId: input.toRoleId,
1316
+ now: new Date(now),
1317
+ runtimeAdapter: options.runtimeAdapter,
1318
+ });
1319
+
1320
+ return {
1321
+ ok: true,
1322
+ messageId: message.messageId,
1323
+ delivery: "queued",
1324
+ queuedFor: input.toRoleId,
1325
+ cc: ccRoleIds,
1326
+ };
1327
+ }
1328
+
1329
+ async function spawnTeamRoleWithBrokerContext(
1330
+ options: TeamMessageBrokerOptions,
1331
+ input: TeamMessageBrokerSpawnInput,
1332
+ ): Promise<TeamRoleLifecycleSpawnResult> {
1333
+ const store = createTeamRunStore(options.homeDir);
1334
+ const runId = await resolveRequestedRunId(store, input.runId);
1335
+ const run = await store.readRun(runId);
1336
+
1337
+ try {
1338
+ const result = await spawnTeamRole({
1339
+ homeDir: options.homeDir,
1340
+ repoRoot: run.repoRoot,
1341
+ runId: run.runId,
1342
+ roleId: input.roleId,
1343
+ now: input.now,
1344
+ runtimeAdapter: options.runtimeAdapter,
1345
+ });
1346
+ return { ok: true, run: result.run, agent: result.agent, created: result.created };
1347
+ } catch (error) {
1348
+ return {
1349
+ ok: false,
1350
+ error: "spawn-role-failed",
1351
+ message: describeError(error),
1352
+ };
1353
+ }
1354
+ }
1355
+
1356
+ async function stopTeamRoleWithBrokerContext(
1357
+ options: TeamMessageBrokerOptions,
1358
+ input: TeamMessageBrokerStopInput,
1359
+ ): Promise<TeamRoleStopResult> {
1360
+ const store = createTeamRunStore(options.homeDir);
1361
+ const runId = await resolveRequestedRunId(store, input.runId);
1362
+ const run = await store.readRun(runId);
1363
+
1364
+ return stopTeamRole({
1365
+ homeDir: options.homeDir,
1366
+ runId: run.runId,
1367
+ roleId: input.roleId,
1368
+ now: input.now,
1369
+ runtimeAdapter: options.runtimeAdapter,
1370
+ });
1371
+ }
1372
+
1373
+ async function notifyMainAboutStoppedAgents(input: {
1374
+ homeDir?: string;
1375
+ run: TeamRunRecord;
1376
+ agents: TeamAgentRecord[];
1377
+ stoppedAgents: TeamAgentRecord[];
1378
+ livePaneIds: Set<string>;
1379
+ runtimeAdapter: TeamRuntimeAdapter;
1380
+ }): Promise<TeamMessageSendResult[]> {
1381
+ const main = input.agents.find((agent) => agent.roleId === "main");
1382
+ if (
1383
+ main === undefined ||
1384
+ !isActiveTeamAgentStatus(main.status) ||
1385
+ !input.livePaneIds.has(main.tmux.paneId)
1386
+ ) {
1387
+ return [];
1388
+ }
1389
+
1390
+ const broker = new TeamMessageBroker({
1391
+ homeDir: input.homeDir,
1392
+ runtimeAdapter: input.runtimeAdapter,
1393
+ });
1394
+ const notifications: TeamMessageSendResult[] = [];
1395
+ for (const agent of input.stoppedAgents) {
1396
+ if (agent.roleId === "main") continue;
1397
+ notifications.push(
1398
+ await broker.send({
1399
+ runId: input.run.runId,
1400
+ fromRoleId: "evodev",
1401
+ toRoleId: "main",
1402
+ type: "notice",
1403
+ message: `Role ${agent.roleId} pane ${agent.tmux.paneId} closed; marked stopped.`,
1404
+ }),
1405
+ );
1406
+ }
1407
+ return notifications;
1408
+ }
1409
+
1410
+ async function notifyMainAboutRecovery(input: {
1411
+ homeDir?: string;
1412
+ run: TeamRunRecord;
1413
+ outcomes: TeamAgentRecoveryResult[];
1414
+ agents: TeamAgentRecord[];
1415
+ runtimeAdapter: TeamRuntimeAdapter;
1416
+ }): Promise<TeamMessageSendResult[]> {
1417
+ const changedOutcomes = input.outcomes.filter((outcome) => outcome.outcome !== "reused");
1418
+ if (changedOutcomes.length === 0) return [];
1419
+ const main = input.agents.find((agent) => agent.roleId === "main");
1420
+ if (main === undefined || !isActiveTeamAgentStatus(main.status)) return [];
1421
+
1422
+ const lines = changedOutcomes.map((outcome) => {
1423
+ const target =
1424
+ outcome.paneId === null ? "no pane" : `pane ${outcome.previousPaneId} -> ${outcome.paneId}`;
1425
+ return `- ${outcome.roleId}: ${outcome.outcome} (${target})`;
1426
+ });
1427
+ const broker = new TeamMessageBroker({
1428
+ homeDir: input.homeDir,
1429
+ runtimeAdapter: input.runtimeAdapter,
1430
+ });
1431
+ const result = await broker.send({
1432
+ runId: input.run.runId,
1433
+ fromRoleId: "evodev",
1434
+ toRoleId: "main",
1435
+ type: "notice",
1436
+ message: ["EvoDev recovery summary:", ...lines].join("\n"),
1437
+ });
1438
+ return [result];
1439
+ }
1440
+
1441
+ export async function stopTeamRun(input: StopTeamRunInput): Promise<TeamStatusResult> {
1442
+ const store = createTeamRunStore(input.homeDir);
1443
+ const runId = await resolveRequestedRunId(store, input.runId);
1444
+ const run = await store.readRun(runId);
1445
+ const now = (input.now ?? new Date()).toISOString();
1446
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1447
+ await runtimeAdapter.stopRun({ session: run.tmux.session });
1448
+ const agents = await store.readAgents(run.runId);
1449
+ const stoppedAgents = agents.map((agent) => ({
1450
+ ...agent,
1451
+ status: "stopped" as const,
1452
+ updatedAt: now,
1453
+ }));
1454
+ const stoppedRun: TeamRunRecord = { ...run, status: "stopped", updatedAt: now };
1455
+ await store.writeRun(stoppedRun);
1456
+ for (const agent of stoppedAgents) {
1457
+ await store.writeAgent(agent);
1458
+ }
1459
+ await store.appendEvent(
1460
+ run.runId,
1461
+ createTeamEvent(run.runId, "TeamRunStopped", `Stopped team run ${run.runId}.`, now),
1462
+ );
1463
+ await writeTeamStatusSnapshot({
1464
+ store,
1465
+ runId: run.runId,
1466
+ now,
1467
+ lastEvent: "TeamRunStopped",
1468
+ });
1469
+ return { run: stoppedRun, agents: stoppedAgents };
1470
+ }
1471
+
1472
+ export async function getTeamStatus(input: TeamStatusInput = {}): Promise<TeamStatusResult> {
1473
+ const store = createTeamRunStore(input.homeDir);
1474
+ const runId = input.runId ?? (await store.readLatestRunId());
1475
+ if (runId === null) return { run: null, agents: [] };
1476
+ const run = await store.readRun(runId);
1477
+ return { run, agents: await store.readAgents(runId) };
1478
+ }
1479
+
1480
+ export async function listTeamRuns(input: ListTeamRunsInput = {}): Promise<TeamRunRecord[]> {
1481
+ const store = createTeamRunStore(input.homeDir);
1482
+ const entries = new Set<string>();
1483
+ for (const runsDir of [store.paths.runsDir, store.paths.legacyRunsDir]) {
1484
+ try {
1485
+ for (const entry of await readdir(runsDir)) entries.add(entry);
1486
+ } catch (error) {
1487
+ if (!isNotFoundError(error)) throw error;
1488
+ }
1489
+ }
1490
+ const runs: TeamRunRecord[] = [];
1491
+ for (const entry of entries) {
1492
+ if (entry === "latest.json") continue;
1493
+ try {
1494
+ runs.push(await store.readRun(entry));
1495
+ } catch {}
1496
+ }
1497
+ return runs.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1498
+ }
1499
+
1500
+ export async function reconcileTeamRun(
1501
+ input: ReconcileTeamRunInput = {},
1502
+ ): Promise<TeamRunReconcileResult> {
1503
+ const store = createTeamRunStore(input.homeDir);
1504
+ const runId = input.runId ?? (await store.readLatestRunId());
1505
+ if (runId === null) {
1506
+ return { run: null, agents: [], stoppedAgents: [], notifications: [], runtimeAvailable: true };
1507
+ }
1508
+
1509
+ const run = await store.readRun(runId);
1510
+ const agents = await store.readAgents(run.runId);
1511
+ if (run.status !== "running") {
1512
+ return { run, agents, stoppedAgents: [], notifications: [], runtimeAvailable: true };
1513
+ }
1514
+
1515
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1516
+ let livePaneIds: Set<string>;
1517
+ try {
1518
+ livePaneIds = new Set(
1519
+ (await runtimeAdapter.listPanes({ session: run.tmux.session })).map((pane) => pane.paneId),
1520
+ );
1521
+ } catch {
1522
+ livePaneIds = new Set();
1523
+ }
1524
+
1525
+ const now = (input.now ?? new Date()).toISOString();
1526
+ const stoppedAgents = agents
1527
+ .filter((agent) => isActiveTeamAgentStatus(agent.status) && !livePaneIds.has(agent.tmux.paneId))
1528
+ .map((agent) => ({ ...agent, status: "stopped" as const, updatedAt: now }));
1529
+
1530
+ if (stoppedAgents.length === 0) {
1531
+ await schedulePendingTeamMessageDelivery({
1532
+ homeDir: input.homeDir,
1533
+ runId: run.runId,
1534
+ now: new Date(now),
1535
+ runtimeAdapter,
1536
+ });
1537
+ await writeTeamStatusSnapshot({
1538
+ store,
1539
+ runId: run.runId,
1540
+ now,
1541
+ lastEvent: "TeamReconciled",
1542
+ });
1543
+ return { run, agents, stoppedAgents: [], notifications: [], runtimeAvailable: true };
1544
+ }
1545
+
1546
+ const stoppedRoleIds = new Set(stoppedAgents.map((agent) => agent.roleId));
1547
+ const updatedAgents = agents.map(
1548
+ (agent) => stoppedAgents.find((stopped) => stopped.roleId === agent.roleId) ?? agent,
1549
+ );
1550
+ const updatedRun: TeamRunRecord = {
1551
+ ...run,
1552
+ status: stoppedRoleIds.has("main") ? "stopped" : run.status,
1553
+ updatedAt: now,
1554
+ };
1555
+
1556
+ await store.writeRun(updatedRun);
1557
+ for (const agent of stoppedAgents) {
1558
+ await store.writeAgent(agent);
1559
+ await store.appendEvent(
1560
+ run.runId,
1561
+ createTeamEvent(run.runId, "AgentStopped", `Detected closed pane for ${agent.roleId}.`, now, {
1562
+ roleId: agent.roleId,
1563
+ agentId: agent.agentId,
1564
+ }),
1565
+ );
1566
+ }
1567
+
1568
+ const notifications =
1569
+ input.notifyMain === false
1570
+ ? []
1571
+ : await notifyMainAboutStoppedAgents({
1572
+ homeDir: input.homeDir,
1573
+ run: updatedRun,
1574
+ agents: updatedAgents,
1575
+ stoppedAgents,
1576
+ livePaneIds,
1577
+ runtimeAdapter,
1578
+ });
1579
+ await schedulePendingTeamMessageDelivery({
1580
+ homeDir: input.homeDir,
1581
+ runId: run.runId,
1582
+ now: new Date(now),
1583
+ runtimeAdapter,
1584
+ });
1585
+ await writeTeamStatusSnapshot({
1586
+ store,
1587
+ runId: run.runId,
1588
+ now,
1589
+ lastEvent: "TeamReconciled",
1590
+ });
1591
+ const finalRun = await store.readRun(run.runId);
1592
+ const finalAgents = await store.readAgents(run.runId);
1593
+
1594
+ return {
1595
+ run: finalRun,
1596
+ agents: finalAgents,
1597
+ stoppedAgents,
1598
+ notifications,
1599
+ runtimeAvailable: livePaneIds.size > 0,
1600
+ };
1601
+ }
1602
+
1603
+ export async function recordTeamAgentNativeSession(
1604
+ input: RecordTeamAgentNativeSessionInput,
1605
+ ): Promise<TeamAgentRecord> {
1606
+ const store = createTeamRunStore(input.homeDir);
1607
+ const runId = await resolveRequestedRunId(store, input.runId);
1608
+ const agent = await store.readAgent(runId, input.roleId);
1609
+ if (agent.nativeSession.sessionId === input.sessionId) return agent;
1610
+ const now = (input.now ?? new Date()).toISOString();
1611
+ const updatedAgent: TeamAgentRecord = {
1612
+ ...agent,
1613
+ nativeSession: {
1614
+ sessionId: input.sessionId,
1615
+ capturedAt: now,
1616
+ },
1617
+ updatedAt: now,
1618
+ };
1619
+
1620
+ await store.writeAgent(updatedAgent);
1621
+ await store.appendEvent(
1622
+ runId,
1623
+ createTeamEvent(
1624
+ runId,
1625
+ "AgentNativeSessionRecorded",
1626
+ `Recorded native session id for ${input.roleId}.`,
1627
+ now,
1628
+ {
1629
+ roleId: input.roleId,
1630
+ agentId: agent.agentId,
1631
+ },
1632
+ ),
1633
+ );
1634
+ await writeTeamStatusSnapshot({
1635
+ store,
1636
+ runId,
1637
+ now,
1638
+ lastEvent: "AgentNativeSessionRecorded",
1639
+ });
1640
+
1641
+ return updatedAgent;
1642
+ }
1643
+
1644
+ export async function updateTeamAgentHookState(
1645
+ input: UpdateTeamAgentHookStateInput,
1646
+ ): Promise<{ agent: TeamAgentRecord; statusPath: string; agentPath: string }> {
1647
+ const store = createTeamRunStore(input.homeDir);
1648
+ const runId = await resolveRequestedRunId(store, input.runId);
1649
+ const agent = await store.readAgent(runId, input.roleId);
1650
+ const now = (input.now ?? new Date()).toISOString();
1651
+ const nextStatus = teamAgentStatusForHookEvent(input.hookEvent, agent.status);
1652
+ const updatedAgent: TeamAgentRecord =
1653
+ nextStatus === agent.status
1654
+ ? { ...agent, updatedAt: now }
1655
+ : {
1656
+ ...agent,
1657
+ status: nextStatus,
1658
+ updatedAt: now,
1659
+ };
1660
+ await store.writeAgent(updatedAgent);
1661
+ await store.appendEvent(
1662
+ runId,
1663
+ createTeamEvent(
1664
+ runId,
1665
+ "AgentHookStateUpdated",
1666
+ `Updated ${input.roleId} hook state after ${input.hookEvent}.`,
1667
+ now,
1668
+ { roleId: input.roleId, agentId: updatedAgent.agentId },
1669
+ ),
1670
+ );
1671
+ await writeTeamStatusSnapshot({
1672
+ store,
1673
+ runId,
1674
+ now,
1675
+ lastEvent: input.hookEvent,
1676
+ });
1677
+ return {
1678
+ agent: updatedAgent,
1679
+ statusPath: store.paths.statusPath(runId),
1680
+ agentPath: store.paths.agentPath(runId, input.roleId),
1681
+ };
1682
+ }
1683
+
1684
+ export async function createTeamRoleRuntimeContext(
1685
+ input: TeamRoleRuntimeContextInput,
1686
+ ): Promise<string> {
1687
+ const store = createTeamRunStore(input.homeDir);
1688
+ const run = await store.readRun(input.runId);
1689
+ const agent = await store.readAgent(run.runId, input.roleId);
1690
+ const agents = await store.readAgents(run.runId);
1691
+ const role = await resolveRoleForAgentRecovery({
1692
+ homeDir: input.homeDir,
1693
+ repoRoot: run.repoRoot,
1694
+ agent,
1695
+ });
1696
+ return createAgentStartupPrompt({
1697
+ homeDir: input.homeDir,
1698
+ runId: run.runId,
1699
+ repoRoot: run.repoRoot,
1700
+ role,
1701
+ roster: agents,
1702
+ });
1703
+ }
1704
+
1705
+ export async function resumeTeamRun(input: ResumeTeamRunInput = {}): Promise<TeamRunResumeResult> {
1706
+ const store = createTeamRunStore(input.homeDir);
1707
+ const runId = await resolveRequestedRunId(store, input.runId);
1708
+ const run = await store.readRun(runId);
1709
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1710
+ const now = (input.now ?? new Date()).toISOString();
1711
+ const decision = input.missingSessionDecision ?? "ask";
1712
+ const originalAgents = sortAgentsForRecovery(await store.readAgents(run.runId));
1713
+ const livePaneIds = await readLivePaneIds(runtimeAdapter, run.tmux.session);
1714
+ const outcomes: TeamAgentRecoveryResult[] = [];
1715
+ const updatedAgents: TeamAgentRecord[] = [];
1716
+ let updatedRun: TeamRunRecord = { ...run, status: "running", updatedAt: now };
1717
+
1718
+ for (const agent of originalAgents) {
1719
+ if (isActiveTeamAgentStatus(agent.status) && livePaneIds.has(agent.tmux.paneId)) {
1720
+ updatedAgents.push(agent);
1721
+ outcomes.push({
1722
+ roleId: agent.roleId,
1723
+ agentId: agent.agentId,
1724
+ outcome: "reused",
1725
+ previousPaneId: agent.tmux.paneId,
1726
+ paneId: agent.tmux.paneId,
1727
+ nativeSessionId: agent.nativeSession.sessionId,
1728
+ });
1729
+ continue;
1730
+ }
1731
+
1732
+ const role = await resolveRoleForAgentRecovery({
1733
+ homeDir: input.homeDir,
1734
+ repoRoot: run.repoRoot,
1735
+ agent,
1736
+ });
1737
+ const roster = [
1738
+ ...updatedAgents,
1739
+ ...originalAgents.filter((item) => item.roleId !== agent.roleId),
1740
+ ];
1741
+ const startupPrompt = await createAgentStartupPrompt({
1742
+ homeDir: input.homeDir,
1743
+ runId: run.runId,
1744
+ repoRoot: run.repoRoot,
1745
+ role,
1746
+ roster,
1747
+ now,
1748
+ });
1749
+ const recoveryMode = resolveRecoveryMode(agent, decision);
1750
+
1751
+ if (recoveryMode === null) {
1752
+ const stoppedAgent = { ...agent, status: "stopped" as const, updatedAt: now };
1753
+ await store.writeAgent(stoppedAgent);
1754
+ await store.appendEvent(
1755
+ run.runId,
1756
+ createTeamEvent(
1757
+ run.runId,
1758
+ "AgentRecoveryDecisionRequired",
1759
+ `Recovery for ${agent.roleId} requires a session decision.`,
1760
+ now,
1761
+ { roleId: agent.roleId, agentId: agent.agentId },
1762
+ ),
1763
+ );
1764
+ updatedAgents.push(stoppedAgent);
1765
+ outcomes.push({
1766
+ roleId: agent.roleId,
1767
+ agentId: agent.agentId,
1768
+ outcome: decision === "fail" ? "failed" : "needs-decision",
1769
+ previousPaneId: agent.tmux.paneId,
1770
+ paneId: null,
1771
+ nativeSessionId: agent.nativeSession.sessionId,
1772
+ reason:
1773
+ decision === "fail"
1774
+ ? "Native session id is missing and fallback is disabled."
1775
+ : "Native session id is missing. Choose fail, recreate, or resume-latest.",
1776
+ decisionOptions: ["fail", "recreate", "resume-latest"],
1777
+ });
1778
+ continue;
1779
+ }
1780
+
1781
+ try {
1782
+ const handle = await runtimeAdapter.recoverAgent({
1783
+ runId: run.runId,
1784
+ sessionName: run.tmux.session,
1785
+ repoRoot: run.repoRoot,
1786
+ role,
1787
+ startupPrompt,
1788
+ mode: recoveryMode,
1789
+ targetPaneId: updatedAgents.find((item) => item.roleId === "main")?.tmux.paneId,
1790
+ });
1791
+ const recoveredAgent = createRecoveredAgentRecord({
1792
+ existing: agent,
1793
+ role,
1794
+ handle,
1795
+ mode: recoveryMode,
1796
+ now,
1797
+ });
1798
+ updatedRun = {
1799
+ ...updatedRun,
1800
+ roles: { ...updatedRun.roles, [agent.roleId]: recoveredAgent.agentId },
1801
+ updatedAt: now,
1802
+ };
1803
+ await store.writeAgent(recoveredAgent);
1804
+ await store.appendEvent(
1805
+ run.runId,
1806
+ createTeamEvent(
1807
+ run.runId,
1808
+ recoveredAgent.status === "recreated" ? "AgentRecreated" : "AgentRecovered",
1809
+ `${recoveredAgent.status === "recreated" ? "Recreated" : "Recovered"} role ${agent.roleId}.`,
1810
+ now,
1811
+ { roleId: agent.roleId, agentId: recoveredAgent.agentId },
1812
+ ),
1813
+ );
1814
+ updatedAgents.push(recoveredAgent);
1815
+ outcomes.push({
1816
+ roleId: recoveredAgent.roleId,
1817
+ agentId: recoveredAgent.agentId,
1818
+ outcome: recoveredAgent.status === "recreated" ? "recreated" : "resumed",
1819
+ previousPaneId: agent.tmux.paneId,
1820
+ paneId: recoveredAgent.tmux.paneId,
1821
+ nativeSessionId: recoveredAgent.nativeSession.sessionId,
1822
+ });
1823
+ } catch (error) {
1824
+ const stoppedAgent = { ...agent, status: "stopped" as const, updatedAt: now };
1825
+ await store.writeAgent(stoppedAgent);
1826
+ await store.appendEvent(
1827
+ run.runId,
1828
+ createTeamEvent(
1829
+ run.runId,
1830
+ "AgentRecoveryDecisionRequired",
1831
+ `Recovery for ${agent.roleId} failed: ${describeError(error)}`,
1832
+ now,
1833
+ { roleId: agent.roleId, agentId: agent.agentId },
1834
+ ),
1835
+ );
1836
+ updatedAgents.push(stoppedAgent);
1837
+ outcomes.push({
1838
+ roleId: agent.roleId,
1839
+ agentId: agent.agentId,
1840
+ outcome: "needs-decision",
1841
+ previousPaneId: agent.tmux.paneId,
1842
+ paneId: null,
1843
+ nativeSessionId: agent.nativeSession.sessionId,
1844
+ reason: describeError(error),
1845
+ decisionOptions: ["fail", "recreate", "resume-latest"],
1846
+ });
1847
+ }
1848
+ }
1849
+
1850
+ const main = updatedAgents.find((agent) => agent.roleId === "main");
1851
+ updatedRun = {
1852
+ ...updatedRun,
1853
+ status: main !== undefined && isActiveTeamAgentStatus(main.status) ? "running" : "stopped",
1854
+ updatedAt: now,
1855
+ };
1856
+ await store.writeRun(updatedRun);
1857
+ await writeTeamStatusSnapshot({
1858
+ store,
1859
+ runId: run.runId,
1860
+ now,
1861
+ lastEvent: "TeamRunResumed",
1862
+ });
1863
+
1864
+ const decisionRequired = outcomes.filter((outcome) => outcome.outcome === "needs-decision");
1865
+ const notifications =
1866
+ input.notifyMain === false
1867
+ ? []
1868
+ : await notifyMainAboutRecovery({
1869
+ homeDir: input.homeDir,
1870
+ run: updatedRun,
1871
+ outcomes,
1872
+ agents: updatedAgents,
1873
+ runtimeAdapter,
1874
+ });
1875
+
1876
+ return {
1877
+ run: updatedRun,
1878
+ agents: updatedAgents.sort((left, right) => left.roleId.localeCompare(right.roleId)),
1879
+ outcomes,
1880
+ decisionRequired,
1881
+ notifications,
1882
+ };
1883
+ }
1884
+
1885
+ export async function getTeamAttachCommand(input: TeamAttachInput = {}): Promise<string> {
1886
+ const store = createTeamRunStore(input.homeDir);
1887
+ const runId = await resolveRequestedRunId(store, input.runId);
1888
+ const run = await store.readRun(runId);
1889
+ const roleId = input.roleId ?? "main";
1890
+ const agent = await store.readAgent(run.runId, roleId);
1891
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1892
+ return runtimeAdapter.formatAttachCommand({
1893
+ session: run.tmux.session,
1894
+ paneId: agent.tmux.paneId,
1895
+ });
1896
+ }
1897
+
1898
+ export async function listTeamAgents(input: TeamStatusInput = {}): Promise<TeamAgentSummary[]> {
1899
+ const status = await getTeamStatus(input);
1900
+ return status.agents.map((agent) => ({
1901
+ roleId: agent.roleId,
1902
+ roleName: agent.roleName,
1903
+ status: agent.status,
1904
+ runtime: agent.runtime,
1905
+ canReceiveMessages: isActiveTeamAgentStatus(agent.status),
1906
+ isIdle: isIdleTeamAgentStatus(agent.status),
1907
+ isMidTurn: isMidTurnTeamAgentStatus(agent.status),
1908
+ }));
1909
+ }
1910
+
1911
+ export async function resolveTeamRole(input: {
1912
+ homeDir?: string;
1913
+ repoRoot: string;
1914
+ roleId: string;
1915
+ overrides?: Partial<{
1916
+ runtime: TeamAgentRuntime;
1917
+ model: string | null;
1918
+ thinkingLevel: string | null;
1919
+ }>;
1920
+ }): Promise<ResolvedTeamRole> {
1921
+ assertSafeId(input.roleId, "roleId");
1922
+ const settings = await readSettingsOrDefault(input.homeDir);
1923
+ const globalRolePath = join(
1924
+ resolveEvoDevPaths(input.homeDir).roleAgentsDir,
1925
+ `${input.roleId}.json`,
1926
+ );
1927
+ const candidate = await readRoleCandidate(globalRolePath, "global");
1928
+ const nativeAgent = await resolveTeamRoleNativeAgentBinding({
1929
+ homeDir: input.homeDir,
1930
+ repoRoot: input.repoRoot,
1931
+ roleId: input.roleId,
1932
+ });
1933
+ const raw =
1934
+ candidate?.value ?? createBuiltInRole(input.roleId, settings.teamRuntime.defaultRuntime);
1935
+ const source = candidate?.source ?? "builtin";
1936
+ const sourcePath = candidate?.path ?? null;
1937
+ const parsed = parseTeamRoleDefinition(raw, {
1938
+ roleId: input.roleId,
1939
+ source,
1940
+ defaultRuntime: settings.teamRuntime.defaultRuntime,
1941
+ defaultModel: settings.teamRuntime.defaultModel,
1942
+ defaultThinkingLevel: settings.teamRuntime.defaultThinkingLevel,
1943
+ recordTranscript: settings.teamRuntime.recordTranscript,
1944
+ });
1945
+
1946
+ return {
1947
+ ...parsed,
1948
+ runtime: input.overrides?.runtime ?? nativeAgent?.target ?? parsed.runtime,
1949
+ model: input.overrides?.model ?? parsed.model,
1950
+ thinkingLevel: input.overrides?.thinkingLevel ?? parsed.thinkingLevel,
1951
+ sourcePath,
1952
+ nativeAgent,
1953
+ };
1954
+ }
1955
+
1956
+ export function parseTeamRoleDefinition(
1957
+ value: unknown,
1958
+ defaults: {
1959
+ roleId: string;
1960
+ source: TeamRoleDefinition["source"];
1961
+ defaultRuntime: TeamAgentRuntime;
1962
+ defaultModel: string | null;
1963
+ defaultThinkingLevel: string | null;
1964
+ recordTranscript: boolean;
1965
+ },
1966
+ ): TeamRoleDefinition {
1967
+ const input = isRecord(value) ? value : {};
1968
+ const roleId = optionalString(input.roleId) ?? defaults.roleId;
1969
+ assertSafeId(roleId, "roleId");
1970
+ const runtime = parseRuntime(input.runtime, defaults.defaultRuntime);
1971
+ const isMain = roleId === "main";
1972
+ return {
1973
+ version: 1,
1974
+ roleId,
1975
+ roleName:
1976
+ optionalString(input.roleName) ?? optionalString(input.name) ?? defaultRoleName(roleId),
1977
+ description: optionalString(input.description) ?? `EvoDev ${roleId} role agent.`,
1978
+ runtime,
1979
+ model: optionalNullableString(input.model) ?? defaults.defaultModel,
1980
+ thinkingLevel: optionalNullableString(input.thinkingLevel) ?? defaults.defaultThinkingLevel,
1981
+ prompt: optionalString(input.prompt) ?? defaultRolePrompt(roleId),
1982
+ permissions: parseRolePermissions(input.permissions, isMain),
1983
+ teamPolicy: parseRolePolicy(input.teamPolicy, defaults.recordTranscript),
1984
+ source: defaults.source,
1985
+ };
1986
+ }
1987
+
1988
+ export function createTmuxRuntimeAdapter(
1989
+ runner: TeamRuntimeCommandRunner = new NodeTeamRuntimeCommandRunner(),
1990
+ options: TmuxRuntimeAdapterOptions = {},
1991
+ ): TeamRuntimeAdapter {
1992
+ return new TmuxRuntimeAdapter(runner, options);
1993
+ }
1994
+
1995
+ export class TmuxRuntimeAdapter implements TeamRuntimeAdapter {
1996
+ private messageBufferCounter = 0;
1997
+
1998
+ constructor(
1999
+ private readonly runner: TeamRuntimeCommandRunner,
2000
+ private readonly options: TmuxRuntimeAdapterOptions = {},
2001
+ ) {}
2002
+
2003
+ async createRun(input: TeamRuntimeCreateRunInput): Promise<TeamRuntimeAgentHandle> {
2004
+ const command = buildAgentShellCommand(input.role, input.startupPrompt, {
2005
+ runId: input.runId,
2006
+ repoRoot: input.repoRoot,
2007
+ runtimeCommands: this.options.runtimeCommands,
2008
+ environment: this.options.environment,
2009
+ });
2010
+ await this.runTmux([
2011
+ "new-session",
2012
+ "-d",
2013
+ "-s",
2014
+ input.sessionName,
2015
+ "-n",
2016
+ "main",
2017
+ "-c",
2018
+ input.repoRoot,
2019
+ command,
2020
+ ]);
2021
+ const pane = await this.runTmux([
2022
+ "display-message",
2023
+ "-p",
2024
+ "-t",
2025
+ `${input.sessionName}:main.0`,
2026
+ "#{pane_id}",
2027
+ ]);
2028
+ const paneId = pane.stdout.trim();
2029
+ await this.configureTeamWindow(`${input.sessionName}:main`);
2030
+ await this.setPaneTitle(paneId, input.role);
2031
+ return { session: input.sessionName, window: "main", paneId };
2032
+ }
2033
+
2034
+ async spawnAgent(input: TeamRuntimeSpawnAgentInput): Promise<TeamRuntimeAgentHandle> {
2035
+ const window = sanitizeWindowName(input.role.roleId);
2036
+ const command = buildAgentShellCommand(input.role, input.startupPrompt, {
2037
+ runId: input.runId,
2038
+ repoRoot: input.repoRoot,
2039
+ runtimeCommands: this.options.runtimeCommands,
2040
+ environment: this.options.environment,
2041
+ });
2042
+ if (input.targetPaneId !== undefined) {
2043
+ const pane = await this.splitPane(input.targetPaneId, input.repoRoot, command, input.role);
2044
+ return { session: input.sessionName, window: "main", paneId: pane };
2045
+ }
2046
+
2047
+ const pane = await this.runTmux([
2048
+ "new-window",
2049
+ "-d",
2050
+ "-P",
2051
+ "-F",
2052
+ "#{pane_id}",
2053
+ "-t",
2054
+ input.sessionName,
2055
+ "-n",
2056
+ window,
2057
+ "-c",
2058
+ input.repoRoot,
2059
+ command,
2060
+ ]);
2061
+ const paneId = pane.stdout.trim();
2062
+ await this.configureTeamWindow(`${input.sessionName}:${window}`);
2063
+ await this.setPaneTitle(paneId, input.role);
2064
+ return { session: input.sessionName, window, paneId };
2065
+ }
2066
+
2067
+ async recoverAgent(input: TeamRuntimeRecoverAgentInput): Promise<TeamRuntimeAgentHandle> {
2068
+ const window = sanitizeWindowName(input.role.roleId);
2069
+ const command =
2070
+ input.mode.type === "fresh"
2071
+ ? buildAgentShellCommand(input.role, input.startupPrompt, {
2072
+ runId: input.runId,
2073
+ repoRoot: input.repoRoot,
2074
+ runtimeCommands: this.options.runtimeCommands,
2075
+ environment: this.options.environment,
2076
+ })
2077
+ : buildAgentResumeShellCommand(input.role, input.startupPrompt, {
2078
+ runId: input.runId,
2079
+ repoRoot: input.repoRoot,
2080
+ mode: input.mode,
2081
+ runtimeCommands: this.options.runtimeCommands,
2082
+ environment: this.options.environment,
2083
+ });
2084
+ const session = await this.runner.run("tmux", ["-u", "has-session", "-t", input.sessionName]);
2085
+ if (session.exitCode !== 0) {
2086
+ await this.runTmux([
2087
+ "new-session",
2088
+ "-d",
2089
+ "-s",
2090
+ input.sessionName,
2091
+ "-n",
2092
+ window,
2093
+ "-c",
2094
+ input.repoRoot,
2095
+ command,
2096
+ ]);
2097
+ const pane = await this.runTmux([
2098
+ "display-message",
2099
+ "-p",
2100
+ "-t",
2101
+ `${input.sessionName}:${window}.0`,
2102
+ "#{pane_id}",
2103
+ ]);
2104
+ const paneId = pane.stdout.trim();
2105
+ await this.configureTeamWindow(`${input.sessionName}:${window}`);
2106
+ await this.setPaneTitle(paneId, input.role);
2107
+ return recoveredHandle(input, window, paneId);
2108
+ }
2109
+
2110
+ if (input.targetPaneId !== undefined && input.role.roleId !== "main") {
2111
+ const pane = await this.splitPane(input.targetPaneId, input.repoRoot, command, input.role);
2112
+ return recoveredHandle(input, "main", pane);
2113
+ }
2114
+
2115
+ const pane = await this.runTmux([
2116
+ "new-window",
2117
+ "-d",
2118
+ "-P",
2119
+ "-F",
2120
+ "#{pane_id}",
2121
+ "-t",
2122
+ input.sessionName,
2123
+ "-n",
2124
+ window,
2125
+ "-c",
2126
+ input.repoRoot,
2127
+ command,
2128
+ ]);
2129
+ const paneId = pane.stdout.trim();
2130
+ await this.configureTeamWindow(`${input.sessionName}:${window}`);
2131
+ await this.setPaneTitle(paneId, input.role);
2132
+ return recoveredHandle(input, window, paneId);
2133
+ }
2134
+
2135
+ private async splitPane(
2136
+ targetPaneId: string,
2137
+ repoRoot: string,
2138
+ command: string,
2139
+ role: ResolvedTeamRole,
2140
+ ): Promise<string> {
2141
+ const pane = await this.runTmux([
2142
+ "split-window",
2143
+ "-h",
2144
+ "-d",
2145
+ "-P",
2146
+ "-F",
2147
+ "#{pane_id}",
2148
+ "-t",
2149
+ targetPaneId,
2150
+ "-c",
2151
+ repoRoot,
2152
+ command,
2153
+ ]);
2154
+ const paneId = pane.stdout.trim();
2155
+ await this.configureTeamWindow(targetPaneId);
2156
+ await this.setPaneTitle(paneId, role);
2157
+ await this.runTmux(["select-layout", "-t", targetPaneId, "main-vertical"]);
2158
+ return paneId;
2159
+ }
2160
+
2161
+ private async configureTeamWindow(target: string): Promise<void> {
2162
+ await this.runTmux(["set-option", "-w", "-t", target, "pane-border-status", "top"]);
2163
+ await this.runTmux([
2164
+ "set-option",
2165
+ "-w",
2166
+ "-t",
2167
+ target,
2168
+ "pane-border-format",
2169
+ "[#{pane_index}] #{pane_title}",
2170
+ ]);
2171
+ }
2172
+
2173
+ private async setPaneTitle(paneId: string, role: ResolvedTeamRole): Promise<void> {
2174
+ await this.runTmux(["select-pane", "-t", paneId, "-T", formatTeamPaneTitle(role)]);
2175
+ }
2176
+
2177
+ async sendInput(input: TeamRuntimeSendInputInput): Promise<void> {
2178
+ const bufferName = `evodev-message-${Date.now()}-${this.messageBufferCounter++}`;
2179
+ await this.runTmux(["set-buffer", "-b", bufferName, input.text]);
2180
+ try {
2181
+ await this.runTmux(["paste-buffer", "-d", "-p", "-r", "-b", bufferName, "-t", input.paneId]);
2182
+ } catch (error) {
2183
+ await this.runner.run("tmux", ["-u", "delete-buffer", "-b", bufferName]);
2184
+ throw error;
2185
+ }
2186
+ await this.runTmux(["send-keys", "-t", input.paneId, "C-m"]);
2187
+ }
2188
+
2189
+ async listPanes(input: TeamRuntimeListPanesInput): Promise<TeamRuntimePaneInfo[]> {
2190
+ const panes = await this.runTmux(["list-panes", "-s", "-t", input.session, "-F", "#{pane_id}"]);
2191
+ return panes.stdout
2192
+ .split("\n")
2193
+ .map((paneId) => paneId.trim())
2194
+ .filter(Boolean)
2195
+ .map((paneId) => ({ paneId }));
2196
+ }
2197
+
2198
+ async stopAgent(input: TeamRuntimeStopAgentInput): Promise<void> {
2199
+ await this.runTmux(["kill-pane", "-t", input.paneId]);
2200
+ }
2201
+
2202
+ async stopRun(input: TeamRuntimeStopRunInput): Promise<void> {
2203
+ await this.runTmux(["kill-session", "-t", input.session]);
2204
+ }
2205
+
2206
+ formatAttachCommand(input: TeamRuntimeAttachInput): string {
2207
+ return input.paneId === undefined
2208
+ ? `tmux -u attach-session -t ${input.session}`
2209
+ : `tmux -u attach-session -t ${input.session} \\; select-pane -t ${input.paneId}`;
2210
+ }
2211
+
2212
+ private async runTmux(
2213
+ args: string[],
2214
+ options?: { input?: string },
2215
+ ): Promise<TeamRuntimeCommandResult> {
2216
+ const result = await this.runner.run("tmux", ["-u", ...args], options);
2217
+ if (result.exitCode !== 0) {
2218
+ throw new Error(`tmux ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
2219
+ }
2220
+ return result;
2221
+ }
2222
+ }
2223
+
2224
+ export class NodeTeamRuntimeCommandRunner implements TeamRuntimeCommandRunner {
2225
+ async run(
2226
+ command: string,
2227
+ args: string[],
2228
+ options: { input?: string } = {},
2229
+ ): Promise<TeamRuntimeCommandResult> {
2230
+ return new Promise((resolve, reject) => {
2231
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
2232
+ let stdout = "";
2233
+ let stderr = "";
2234
+ child.stdout.setEncoding("utf8");
2235
+ child.stderr.setEncoding("utf8");
2236
+ child.stdout.on("data", (chunk: string) => {
2237
+ stdout += chunk;
2238
+ });
2239
+ child.stderr.on("data", (chunk: string) => {
2240
+ stderr += chunk;
2241
+ });
2242
+ child.once("error", reject);
2243
+ child.once("close", (code) => {
2244
+ resolve({ exitCode: code ?? 1, stdout, stderr });
2245
+ });
2246
+ child.stdin.end(options.input ?? "");
2247
+ });
2248
+ }
2249
+ }
2250
+
2251
+ function createAgentRecord(input: {
2252
+ runId: string;
2253
+ role: ResolvedTeamRole;
2254
+ handle: TeamRuntimeAgentHandle;
2255
+ now: string;
2256
+ }): TeamAgentRecord {
2257
+ const tmux = teamTmuxFromHandle(input.handle);
2258
+ return {
2259
+ version: 1,
2260
+ agentId: createAgentId(input.role.roleId, input.now),
2261
+ roleId: input.role.roleId,
2262
+ roleName: input.role.roleName,
2263
+ runId: input.runId,
2264
+ runtime: input.role.runtime,
2265
+ model: input.role.model,
2266
+ thinkingLevel: input.role.thinkingLevel,
2267
+ status: "running",
2268
+ permissions: input.role.permissions,
2269
+ nativeSession: {
2270
+ sessionId: input.handle.nativeSessionId ?? null,
2271
+ capturedAt:
2272
+ input.handle.nativeSessionId === undefined || input.handle.nativeSessionId === null
2273
+ ? null
2274
+ : input.now,
2275
+ },
2276
+ tmux,
2277
+ createdAt: input.now,
2278
+ updatedAt: input.now,
2279
+ };
2280
+ }
2281
+
2282
+ function createRecoveredAgentRecord(input: {
2283
+ existing: TeamAgentRecord;
2284
+ role: ResolvedTeamRole;
2285
+ handle: TeamRuntimeAgentHandle;
2286
+ mode: TeamRuntimeAgentRecoveryMode;
2287
+ now: string;
2288
+ }): TeamAgentRecord {
2289
+ if (input.mode.type === "fresh") {
2290
+ return {
2291
+ ...createAgentRecord({
2292
+ runId: input.existing.runId,
2293
+ role: input.role,
2294
+ handle: input.handle,
2295
+ now: input.now,
2296
+ }),
2297
+ status: "recreated",
2298
+ };
2299
+ }
2300
+
2301
+ const nativeSessionId =
2302
+ input.handle.nativeSessionId ??
2303
+ (input.mode.type === "resume" ? input.mode.sessionId : input.existing.nativeSession.sessionId);
2304
+
2305
+ return {
2306
+ ...input.existing,
2307
+ roleName: input.role.roleName,
2308
+ runtime: input.role.runtime,
2309
+ model: input.role.model,
2310
+ thinkingLevel: input.role.thinkingLevel,
2311
+ permissions: input.role.permissions,
2312
+ status: "recovering",
2313
+ nativeSession: {
2314
+ sessionId: nativeSessionId,
2315
+ capturedAt:
2316
+ nativeSessionId === null
2317
+ ? null
2318
+ : input.handle.nativeSessionId === undefined
2319
+ ? input.existing.nativeSession.capturedAt
2320
+ : input.now,
2321
+ },
2322
+ tmux: teamTmuxFromHandle(input.handle),
2323
+ updatedAt: input.now,
2324
+ };
2325
+ }
2326
+
2327
+ function teamTmuxFromHandle(handle: TeamRuntimeAgentHandle): TeamAgentRecord["tmux"] {
2328
+ return {
2329
+ session: handle.session,
2330
+ window: handle.window,
2331
+ paneId: handle.paneId,
2332
+ };
2333
+ }
2334
+
2335
+ function resolveRecoveryMode(
2336
+ agent: TeamAgentRecord,
2337
+ decision: TeamSessionMissingDecision,
2338
+ ): TeamRuntimeAgentRecoveryMode | null {
2339
+ if (agent.nativeSession.sessionId !== null) {
2340
+ return { type: "resume", sessionId: agent.nativeSession.sessionId };
2341
+ }
2342
+ if (decision === "recreate") return { type: "fresh" };
2343
+ if (decision === "resume-latest") return { type: "resume-latest" };
2344
+ return null;
2345
+ }
2346
+
2347
+ async function readLivePaneIds(
2348
+ runtimeAdapter: TeamRuntimeAdapter,
2349
+ session: string,
2350
+ ): Promise<Set<string>> {
2351
+ try {
2352
+ return new Set((await runtimeAdapter.listPanes({ session })).map((pane) => pane.paneId));
2353
+ } catch {
2354
+ return new Set();
2355
+ }
2356
+ }
2357
+
2358
+ function sortAgentsForRecovery(agents: TeamAgentRecord[]): TeamAgentRecord[] {
2359
+ return [...agents].sort((left, right) => {
2360
+ if (left.roleId === "main") return -1;
2361
+ if (right.roleId === "main") return 1;
2362
+ return left.roleId.localeCompare(right.roleId);
2363
+ });
2364
+ }
2365
+
2366
+ async function resolveRoleForAgentRecovery(input: {
2367
+ homeDir?: string;
2368
+ repoRoot: string;
2369
+ agent: TeamAgentRecord;
2370
+ }): Promise<ResolvedTeamRole> {
2371
+ const resolved = await resolveTeamRole({
2372
+ homeDir: input.homeDir,
2373
+ repoRoot: input.repoRoot,
2374
+ roleId: input.agent.roleId,
2375
+ });
2376
+ return {
2377
+ ...resolved,
2378
+ roleName: input.agent.roleName,
2379
+ runtime: input.agent.runtime,
2380
+ model: input.agent.model,
2381
+ thinkingLevel: input.agent.thinkingLevel,
2382
+ permissions: input.agent.permissions,
2383
+ };
2384
+ }
2385
+
2386
+ function createAgentStartupPrompt(input: {
2387
+ homeDir?: string;
2388
+ runId: string;
2389
+ repoRoot: string;
2390
+ role: ResolvedTeamRole;
2391
+ roster: TeamAgentRecord[];
2392
+ now?: string;
2393
+ }): Promise<string> {
2394
+ return createScopedTeamStartupContext(input).then((scopedContext) =>
2395
+ renderTeamRoleStartupPrompt({ ...input, scopedContext }),
2396
+ );
2397
+ }
2398
+
2399
+ async function createScopedTeamStartupContext(input: {
2400
+ homeDir?: string;
2401
+ runId: string;
2402
+ repoRoot: string;
2403
+ role: ResolvedTeamRole;
2404
+ now?: string;
2405
+ }): Promise<string | null> {
2406
+ if (input.role.roleId === "main") return null;
2407
+ const homeDir = resolveEvoDevPaths(input.homeDir).homeDir;
2408
+ const settings = await readRuntimeInjectionSettings(homeDir);
2409
+ if (!settings.runtimeInjection) return null;
2410
+
2411
+ const pack = await createScopedKnowledgeContextPack({
2412
+ homeDir,
2413
+ projectKey: resolveProjectLogKey(homeDir, input.repoRoot),
2414
+ roleId: input.role.roleId,
2415
+ });
2416
+ if (pack === null) return null;
2417
+
2418
+ const sessionKey = `team-${input.runId}-${input.role.roleId}`;
2419
+ if (await hasContextInjectionReceipt({ homeDir, sessionKey, contextPackId: pack.id })) {
2420
+ return null;
2421
+ }
2422
+ await writeContextInjectionReceipt({
2423
+ homeDir,
2424
+ sessionKey,
2425
+ pack,
2426
+ trigger: "team-startup",
2427
+ hookEventId: null,
2428
+ injectedAt: input.now,
2429
+ });
2430
+ return formatScopedKnowledgePromptBlock(pack);
2431
+ }
2432
+
2433
+ function buildAgentShellCommand(
2434
+ role: ResolvedTeamRole,
2435
+ startupPrompt: string,
2436
+ context: {
2437
+ runId: string;
2438
+ repoRoot: string;
2439
+ runtimeCommands?: TmuxRuntimeAdapterOptions["runtimeCommands"];
2440
+ environment?: TmuxRuntimeAdapterOptions["environment"];
2441
+ },
2442
+ ): string {
2443
+ const args =
2444
+ role.runtime === "codex"
2445
+ ? buildCodexArgs(role, startupPrompt)
2446
+ : buildClaudeArgs(role, startupPrompt);
2447
+ const env = createAgentLaunchEnvironment(context.environment, {
2448
+ EVODEV_TEAM_RUN_ID: context.runId,
2449
+ EVODEV_TEAM_ROLE_ID: role.roleId,
2450
+ EVODEV_TEAM_REPO_ROOT: context.repoRoot,
2451
+ });
2452
+ return buildAgentCommand(resolveRuntimeCommand(role.runtime, context.runtimeCommands), args, env);
2453
+ }
2454
+
2455
+ function buildAgentResumeShellCommand(
2456
+ role: ResolvedTeamRole,
2457
+ startupPrompt: string,
2458
+ context: {
2459
+ runId: string;
2460
+ repoRoot: string;
2461
+ mode: Exclude<TeamRuntimeAgentRecoveryMode, { type: "fresh" }>;
2462
+ runtimeCommands?: TmuxRuntimeAdapterOptions["runtimeCommands"];
2463
+ environment?: TmuxRuntimeAdapterOptions["environment"];
2464
+ },
2465
+ ): string {
2466
+ const args =
2467
+ role.runtime === "codex"
2468
+ ? buildCodexResumeArgs(role, startupPrompt, context.mode)
2469
+ : buildClaudeResumeArgs(role, context.mode);
2470
+ const env = createAgentLaunchEnvironment(context.environment, {
2471
+ EVODEV_TEAM_RUN_ID: context.runId,
2472
+ EVODEV_TEAM_ROLE_ID: role.roleId,
2473
+ EVODEV_TEAM_REPO_ROOT: context.repoRoot,
2474
+ EVODEV_TEAM_RECOVERY: "1",
2475
+ });
2476
+ return buildAgentCommand(resolveRuntimeCommand(role.runtime, context.runtimeCommands), args, env);
2477
+ }
2478
+
2479
+ function createAgentLaunchEnvironment(
2480
+ source: Record<string, string | undefined> | undefined,
2481
+ evodev: Record<string, string>,
2482
+ ): Record<string, string> {
2483
+ return {
2484
+ ...selectLocaleEnvironment(source ?? process.env),
2485
+ ...evodev,
2486
+ };
2487
+ }
2488
+
2489
+ function selectLocaleEnvironment(
2490
+ source: Record<string, string | undefined>,
2491
+ ): Record<string, string> {
2492
+ const selected: Record<string, string> = {};
2493
+ for (const key of Object.keys(source).sort()) {
2494
+ const value = source[key];
2495
+ if (value === undefined || !isLocaleEnvironmentKey(key)) continue;
2496
+ selected[key] = value;
2497
+ }
2498
+ return selected;
2499
+ }
2500
+
2501
+ function isLocaleEnvironmentKey(key: string): boolean {
2502
+ return key === "LANG" || key === "LANGUAGE" || key === "LC_ALL" || /^LC_[A-Z0-9_]+$/.test(key);
2503
+ }
2504
+
2505
+ function buildAgentCommand(
2506
+ runtimeCommand: string,
2507
+ args: string[],
2508
+ env: Record<string, string>,
2509
+ ): string {
2510
+ const envEntries = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`);
2511
+ const commandParts = [runtimeCommand, ...args].map(shellQuote);
2512
+ return [...envEntries, ...commandParts].join(" ");
2513
+ }
2514
+
2515
+ function resolveRuntimeCommand(
2516
+ runtime: TeamAgentRuntime,
2517
+ runtimeCommands: TmuxRuntimeAdapterOptions["runtimeCommands"],
2518
+ ): string {
2519
+ return runtimeCommands?.[runtime] ?? runtime;
2520
+ }
2521
+
2522
+ function buildCodexArgs(role: ResolvedTeamRole, startupPrompt: string): string[] {
2523
+ const args = ["--no-alt-screen"];
2524
+ if (role.model !== null) args.push("--model", role.model);
2525
+ if (shouldPassVisibleStartupPrompt(role)) args.push(startupPrompt);
2526
+ return args;
2527
+ }
2528
+
2529
+ function buildCodexResumeArgs(
2530
+ role: ResolvedTeamRole,
2531
+ startupPrompt: string,
2532
+ mode: Exclude<TeamRuntimeAgentRecoveryMode, { type: "fresh" }>,
2533
+ ): string[] {
2534
+ const args = ["--no-alt-screen"];
2535
+ if (role.model !== null) args.push("--model", role.model);
2536
+ args.push("resume");
2537
+ if (mode.type === "resume") {
2538
+ args.push(mode.sessionId);
2539
+ } else {
2540
+ args.push("--last");
2541
+ }
2542
+ if (shouldPassVisibleStartupPrompt(role)) args.push(startupPrompt);
2543
+ return args;
2544
+ }
2545
+
2546
+ function buildClaudeArgs(role: ResolvedTeamRole, startupPrompt: string): string[] {
2547
+ const args = [];
2548
+ if (role.model !== null) args.push("--model", role.model);
2549
+ if (role.thinkingLevel !== null) args.push("--effort", role.thinkingLevel);
2550
+ if (shouldPassVisibleStartupPrompt(role)) args.push(startupPrompt);
2551
+ return args;
2552
+ }
2553
+
2554
+ function shouldPassVisibleStartupPrompt(role: ResolvedTeamRole): boolean {
2555
+ return role.roleId !== "main";
2556
+ }
2557
+
2558
+ function buildClaudeResumeArgs(
2559
+ role: ResolvedTeamRole,
2560
+ mode: Exclude<TeamRuntimeAgentRecoveryMode, { type: "fresh" }>,
2561
+ ): string[] {
2562
+ const args = [];
2563
+ if (role.model !== null) args.push("--model", role.model);
2564
+ if (role.thinkingLevel !== null) args.push("--effort", role.thinkingLevel);
2565
+ if (mode.type === "resume") {
2566
+ args.push("--resume", mode.sessionId);
2567
+ } else {
2568
+ args.push("--continue");
2569
+ }
2570
+ return args;
2571
+ }
2572
+
2573
+ function recoveredHandle(
2574
+ input: TeamRuntimeRecoverAgentInput,
2575
+ window: string,
2576
+ paneId: string,
2577
+ ): TeamRuntimeAgentHandle {
2578
+ return {
2579
+ session: input.sessionName,
2580
+ window,
2581
+ paneId,
2582
+ nativeSessionId: input.mode.type === "resume" ? input.mode.sessionId : null,
2583
+ };
2584
+ }
2585
+
2586
+ async function readSettingsOrDefault(homeDir?: string) {
2587
+ const settingsPath = resolveEvoDevPaths(homeDir).settingsPath;
2588
+ try {
2589
+ return parseSettings(JSON.parse(await readFile(settingsPath, "utf8")));
2590
+ } catch {
2591
+ return createDefaultSettings();
2592
+ }
2593
+ }
2594
+
2595
+ async function resolveRequestedRunId(
2596
+ store: TeamRunStore,
2597
+ requestedRunId?: string,
2598
+ ): Promise<string> {
2599
+ if (requestedRunId !== undefined) return requestedRunId;
2600
+ const latest = await store.readLatestRunId();
2601
+ if (latest === null) throw new Error("No EvoDev team run exists.");
2602
+ return latest;
2603
+ }
2604
+
2605
+ function createBuiltInRole(roleId: string, runtime: TeamAgentRuntime): unknown {
2606
+ const builtin = BUILT_IN_ROLE_PROMPTS[roleId];
2607
+ return {
2608
+ version: 1,
2609
+ roleId,
2610
+ roleName: builtin?.roleName ?? defaultRoleName(roleId),
2611
+ description: builtin?.description ?? `EvoDev ${roleId} role agent.`,
2612
+ runtime,
2613
+ prompt: builtin?.prompt ?? defaultRolePrompt(roleId),
2614
+ };
2615
+ }
2616
+
2617
+ function parseRolePermissions(value: unknown, main: boolean): TeamRolePermissions {
2618
+ const input = isRecord(value) ? value : {};
2619
+ return {
2620
+ writeMode: parseWriteMode(input.writeMode, "repo-write"),
2621
+ canUseTeamsMcp: optionalBoolean(input.canUseTeamsMcp) ?? true,
2622
+ canSpawnAgents: optionalBoolean(input.canSpawnAgents) ?? main,
2623
+ canStopAgents: optionalBoolean(input.canStopAgents) ?? main,
2624
+ };
2625
+ }
2626
+
2627
+ function parseRolePolicy(value: unknown, recordTranscript: boolean): TeamRolePolicy {
2628
+ const input = isRecord(value) ? value : {};
2629
+ return {
2630
+ roleInstancePolicy: "single-per-role",
2631
+ recordTranscript: optionalBoolean(input.recordTranscript) ?? recordTranscript,
2632
+ };
2633
+ }
2634
+
2635
+ function parseTeamRunRecord(value: unknown): TeamRunRecord {
2636
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team run record.");
2637
+ const run = value as unknown as TeamRunRecord;
2638
+ assertSafeId(run.runId, "runId");
2639
+ assertString(run.repoRoot, "run.repoRoot");
2640
+ if (!["running", "stopped", "failed"].includes(run.status))
2641
+ throw new Error("Invalid run status.");
2642
+ return run;
2643
+ }
2644
+
2645
+ function parseTeamAgentRecord(value: unknown): TeamAgentRecord {
2646
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team agent record.");
2647
+ const agent = value as unknown as TeamAgentRecord;
2648
+ assertSafeId(agent.agentId, "agentId");
2649
+ assertSafeId(agent.roleId, "roleId");
2650
+ if (
2651
+ ![
2652
+ "starting",
2653
+ "running",
2654
+ "busy",
2655
+ "idle",
2656
+ "waiting-input",
2657
+ "recovering",
2658
+ "recreated",
2659
+ "stopped",
2660
+ "exited",
2661
+ "needs-user-attention",
2662
+ "failed",
2663
+ "unknown",
2664
+ ].includes(agent.status)
2665
+ ) {
2666
+ throw new Error("Invalid agent status.");
2667
+ }
2668
+ const nativeSessionValue = isRecord(value.nativeSession) ? value.nativeSession : {};
2669
+ return {
2670
+ ...agent,
2671
+ nativeSession: {
2672
+ sessionId: optionalString(nativeSessionValue.sessionId) ?? null,
2673
+ capturedAt: optionalString(nativeSessionValue.capturedAt) ?? null,
2674
+ },
2675
+ };
2676
+ }
2677
+
2678
+ function parseTeamMessageRecord(value: unknown): TeamMessageRecord {
2679
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team message record.");
2680
+ const message = value as unknown as TeamMessageRecord;
2681
+ assertSafeId(message.messageId, "messageId");
2682
+ assertSafeId(message.runId, "runId");
2683
+ assertSafeId(message.toRoleId, "toRoleId");
2684
+ assertString(message.body, "message.body");
2685
+ return message;
2686
+ }
2687
+
2688
+ function createPendingMessageRecord(message: TeamMessageRecord): TeamPendingMessageRecord {
2689
+ return {
2690
+ ...message,
2691
+ deliveryState: "pending",
2692
+ attemptCount: 0,
2693
+ lastAttemptAt: null,
2694
+ };
2695
+ }
2696
+
2697
+ function parseTeamPendingMessageRecord(value: unknown): TeamPendingMessageRecord {
2698
+ const message = parseTeamMessageRecord(value);
2699
+ const input = value as Partial<TeamPendingMessageRecord>;
2700
+ const deliveryState =
2701
+ input.deliveryState === "pending" ||
2702
+ input.deliveryState === "claimed" ||
2703
+ input.deliveryState === "wakeup-sent" ||
2704
+ input.deliveryState === "failed"
2705
+ ? input.deliveryState
2706
+ : "pending";
2707
+ return {
2708
+ ...message,
2709
+ deliveryState,
2710
+ attemptCount:
2711
+ typeof input.attemptCount === "number" && Number.isInteger(input.attemptCount)
2712
+ ? input.attemptCount
2713
+ : 0,
2714
+ lastAttemptAt: optionalString(input.lastAttemptAt) ?? null,
2715
+ };
2716
+ }
2717
+
2718
+ function parseTeamMessageListFile(value: unknown): TeamMessageListFile {
2719
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team message list.");
2720
+ const messages = Array.isArray(value.messages)
2721
+ ? value.messages.map(parseTeamPendingMessageRecord)
2722
+ : [];
2723
+ return {
2724
+ version: 1,
2725
+ updatedAt: optionalString(value.updatedAt) ?? null,
2726
+ messages,
2727
+ };
2728
+ }
2729
+
2730
+ function parseTeamEventRecord(value: unknown): TeamEventRecord {
2731
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team event record.");
2732
+ const event = value as unknown as TeamEventRecord;
2733
+ assertSafeId(event.eventId, "eventId");
2734
+ assertSafeId(event.runId, "runId");
2735
+ assertString(event.summary, "event.summary");
2736
+ return event;
2737
+ }
2738
+
2739
+ function parseRuntime(value: unknown, fallback: TeamAgentRuntime): TeamAgentRuntime {
2740
+ if (value === undefined || value === null) return fallback;
2741
+ if (value === "codex" || value === "claude") return value;
2742
+ throw new Error("Role runtime must be codex or claude.");
2743
+ }
2744
+
2745
+ function parseWriteMode(value: unknown, fallback: TeamWriteMode): TeamWriteMode {
2746
+ if (value === undefined || value === null) return fallback;
2747
+ if (["read-only", "repo-write", "worktree-write", "disabled"].includes(String(value))) {
2748
+ return value as TeamWriteMode;
2749
+ }
2750
+ throw new Error("Role writeMode is invalid.");
2751
+ }
2752
+
2753
+ export function isActiveTeamAgentStatus(status: TeamAgentStatus): boolean {
2754
+ return (
2755
+ status === "starting" ||
2756
+ status === "running" ||
2757
+ status === "busy" ||
2758
+ status === "idle" ||
2759
+ status === "waiting-input" ||
2760
+ status === "recovering" ||
2761
+ status === "recreated"
2762
+ );
2763
+ }
2764
+
2765
+ export function isIdleTeamAgentStatus(status: TeamAgentStatus): boolean {
2766
+ return status === "idle" || status === "waiting-input";
2767
+ }
2768
+
2769
+ export function isMidTurnTeamAgentStatus(status: TeamAgentStatus): boolean {
2770
+ return status === "busy";
2771
+ }
2772
+
2773
+ function teamAgentStatusForHookEvent(
2774
+ hookEvent: string,
2775
+ fallback: TeamAgentStatus,
2776
+ ): TeamAgentStatus {
2777
+ if (hookEvent === "PreToolUse") return "busy";
2778
+ if (
2779
+ hookEvent === "Stop" ||
2780
+ hookEvent === "TeammateIdle" ||
2781
+ hookEvent === "SubagentStop" ||
2782
+ hookEvent === "TaskCompleted"
2783
+ ) {
2784
+ return "idle";
2785
+ }
2786
+ if (hookEvent === "SessionStart" || hookEvent === "UserPromptSubmit") return "running";
2787
+ if (hookEvent === "PostToolUse" || hookEvent === "PostToolUseFailure") return "running";
2788
+ if (hookEvent === "SessionEnd") return "waiting-input";
2789
+ return fallback;
2790
+ }
2791
+
2792
+ function createRunId(repoRoot: string, now: Date): string {
2793
+ return `run-${safeSlug(basename(repoRoot) || "repo")}-${timestampId(now)}`;
2794
+ }
2795
+
2796
+ function createAgentId(roleId: string, now: string): string {
2797
+ return `agent-${roleId}-${timestampId(new Date(now))}`;
2798
+ }
2799
+
2800
+ function createMessageId(now: string): string {
2801
+ return `msg-${timestampId(new Date(now))}`;
2802
+ }
2803
+
2804
+ function createEventId(now: string): string {
2805
+ return `evt-${timestampId(new Date(now))}`;
2806
+ }
2807
+
2808
+ function createTmuxSessionName(repoRoot: string, runId: string): string {
2809
+ return `evodev-${safeSlug(basename(repoRoot) || "repo")}-${runId}`.slice(0, 80);
2810
+ }
2811
+
2812
+ function createTeamEvent(
2813
+ runId: string,
2814
+ type: TeamEventRecord["type"],
2815
+ summary: string,
2816
+ now: string,
2817
+ refs: Partial<Pick<TeamEventRecord, "roleId" | "agentId" | "messageId">> = {},
2818
+ ): TeamEventRecord {
2819
+ return { version: 1, eventId: createEventId(now), runId, type, summary, createdAt: now, ...refs };
2820
+ }
2821
+
2822
+ function timestampId(date: Date): string {
2823
+ return date
2824
+ .toISOString()
2825
+ .replace(/[-:.TZ]/g, "")
2826
+ .slice(0, 17);
2827
+ }
2828
+
2829
+ function safeSlug(value: string): string {
2830
+ const slug = value.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
2831
+ return slug || "repo";
2832
+ }
2833
+
2834
+ function sanitizeWindowName(value: string): string {
2835
+ return safeSlug(value).slice(0, 30) || "agent";
2836
+ }
2837
+
2838
+ function formatTeamPaneTitle(role: ResolvedTeamRole): string {
2839
+ const label = role.roleName === role.roleId ? role.roleId : `${role.roleName} [${role.roleId}]`;
2840
+ const title = normalizeTmuxPaneTitle(label);
2841
+ return title.slice(0, 80) || role.roleId;
2842
+ }
2843
+
2844
+ function normalizeTmuxPaneTitle(value: string): string {
2845
+ let normalized = "";
2846
+ let pendingSpace = false;
2847
+
2848
+ for (const char of value) {
2849
+ const code = char.charCodeAt(0);
2850
+ const isControl = code < 32 || code === 127;
2851
+ if (isControl || char.trim() === "") {
2852
+ pendingSpace = normalized.length > 0;
2853
+ continue;
2854
+ }
2855
+
2856
+ if (pendingSpace) {
2857
+ normalized += " ";
2858
+ pendingSpace = false;
2859
+ }
2860
+ normalized += char;
2861
+ }
2862
+
2863
+ return normalized;
2864
+ }
2865
+
2866
+ function defaultRoleName(roleId: string): string {
2867
+ return roleId
2868
+ .split(/[-_.]/)
2869
+ .filter(Boolean)
2870
+ .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
2871
+ .join(" ");
2872
+ }
2873
+
2874
+ function defaultRolePrompt(roleId: string): string {
2875
+ return `You are the ${roleId} role agent for this EvoDev team run. Stay inside your role and communicate through Teams MCP.`;
2876
+ }
2877
+
2878
+ async function readRoleCandidate(
2879
+ path: string,
2880
+ source: TeamRoleDefinition["source"],
2881
+ ): Promise<{ value: unknown; path: string; source: TeamRoleDefinition["source"] } | null> {
2882
+ try {
2883
+ const raw = await readFile(path, "utf8");
2884
+ return { value: JSON.parse(raw), path, source };
2885
+ } catch (error) {
2886
+ if (isNotFoundError(error)) return null;
2887
+ throw new Error(`Cannot read team role definition ${path}: ${describeError(error)}`);
2888
+ }
2889
+ }
2890
+
2891
+ async function resolveTeamRoleNativeAgentBinding(input: {
2892
+ homeDir?: string;
2893
+ repoRoot: string;
2894
+ roleId: string;
2895
+ }): Promise<TeamNativeAgentBinding | null> {
2896
+ const bindings = await listTeamRoleBindings({
2897
+ homeDir: input.homeDir,
2898
+ repoRoot: input.repoRoot,
2899
+ });
2900
+ return bindings.find((binding) => binding.roleId === input.roleId) ?? null;
2901
+ }
2902
+
2903
+ function resolveGlobalTeamBindingPath(homeDir?: string): string {
2904
+ return join(resolveEvoDevPaths(homeDir).rootDir, "team", "roles.json");
2905
+ }
2906
+
2907
+ function resolveProjectTeamBindingPath(homeDir: string | undefined, projectKey: string): string {
2908
+ return join(resolveEvoDevPaths(homeDir).rootDir, "projects", safeSlug(projectKey), "team.json");
2909
+ }
2910
+
2911
+ function resolveTeamBindingWritePath(input: {
2912
+ homeDir?: string;
2913
+ repoRoot?: string;
2914
+ scope: "global" | "project";
2915
+ }): { path: string; projectKey: string | null } {
2916
+ if (input.scope === "global") {
2917
+ return { path: resolveGlobalTeamBindingPath(input.homeDir), projectKey: null };
2918
+ }
2919
+ if (input.repoRoot === undefined) {
2920
+ throw new Error("Project-scoped team config requires repoRoot.");
2921
+ }
2922
+ const projectKey = resolveProjectLogKey(
2923
+ resolveEvoDevPaths(input.homeDir).homeDir,
2924
+ input.repoRoot,
2925
+ );
2926
+ return {
2927
+ path: resolveProjectTeamBindingPath(input.homeDir, projectKey),
2928
+ projectKey,
2929
+ };
2930
+ }
2931
+
2932
+ async function readTeamBindingConfig(path: string): Promise<TeamRoleBindingConfig> {
2933
+ try {
2934
+ const raw = await readFile(path, "utf8");
2935
+ return parseTeamBindingConfig(JSON.parse(raw));
2936
+ } catch (error) {
2937
+ if (isNotFoundError(error)) {
2938
+ return { version: 1, updatedAt: "never", roles: [] };
2939
+ }
2940
+ throw error;
2941
+ }
2942
+ }
2943
+
2944
+ function parseTeamBindingConfig(value: unknown): TeamRoleBindingConfig {
2945
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team binding config.");
2946
+ const updatedAt = optionalString(value.updatedAt) ?? "unknown";
2947
+ const rolesValue = Array.isArray(value.roles) ? value.roles : [];
2948
+ return {
2949
+ version: 1,
2950
+ updatedAt,
2951
+ roles: rolesValue.map(parseTeamBindingRecord),
2952
+ };
2953
+ }
2954
+
2955
+ function parseTeamBindingRecord(value: unknown): TeamRoleBindingRecord {
2956
+ if (!isRecord(value) || value.version !== 1) {
2957
+ throw new Error("Invalid team binding record.");
2958
+ }
2959
+ const roleId = optionalString(value.roleId);
2960
+ const target = parseRuntime(value.target, "codex");
2961
+ const agentName = optionalString(value.agentName);
2962
+ const updatedAt = optionalString(value.updatedAt) ?? "unknown";
2963
+ if (roleId === undefined) throw new Error("Team binding roleId is required.");
2964
+ if (agentName === undefined) throw new Error("Team binding agentName is required.");
2965
+ assertSafeId(roleId, "roleId");
2966
+ assertSafeId(agentName, "agentName");
2967
+ return { version: 1, roleId, target, agentName, updatedAt };
2968
+ }
2969
+
2970
+ function bindingRecordToNative(
2971
+ record: TeamRoleBindingRecord,
2972
+ scope: "global" | "project",
2973
+ projectKey: string | null,
2974
+ ): TeamNativeAgentBinding {
2975
+ return {
2976
+ roleId: record.roleId,
2977
+ target: record.target,
2978
+ agentName: record.agentName,
2979
+ scope,
2980
+ projectKey,
2981
+ updatedAt: record.updatedAt,
2982
+ };
2983
+ }
2984
+
2985
+ function mergeTeamRoleBindings(
2986
+ global: TeamNativeAgentBinding[],
2987
+ project: TeamNativeAgentBinding[],
2988
+ ): TeamNativeAgentBinding[] {
2989
+ const byRole = new Map(global.map((binding) => [binding.roleId, binding]));
2990
+ for (const binding of project) byRole.set(binding.roleId, binding);
2991
+ return [...byRole.values()].sort((left, right) => left.roleId.localeCompare(right.roleId));
2992
+ }
2993
+
2994
+ function isNotFoundError(error: unknown): boolean {
2995
+ return (
2996
+ typeof error === "object" &&
2997
+ error !== null &&
2998
+ "code" in error &&
2999
+ (error as { code?: unknown }).code === "ENOENT"
3000
+ );
3001
+ }
3002
+
3003
+ async function pathExists(path: string): Promise<boolean> {
3004
+ try {
3005
+ await stat(path);
3006
+ return true;
3007
+ } catch (error) {
3008
+ if (isNotFoundError(error)) return false;
3009
+ throw error;
3010
+ }
3011
+ }
3012
+
3013
+ async function writeJson(path: string, value: unknown): Promise<void> {
3014
+ await mkdir(dirname(path), { recursive: true });
3015
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
3016
+ }
3017
+
3018
+ async function appendJsonLine(path: string, value: unknown): Promise<void> {
3019
+ await mkdir(dirname(path), { recursive: true });
3020
+ await appendFile(path, `${JSON.stringify(value)}\n`, "utf8");
3021
+ }
3022
+
3023
+ async function readJsonLines<T>(path: string, parse: (value: unknown) => T): Promise<T[]> {
3024
+ try {
3025
+ const text = await readFile(path, "utf8");
3026
+ return text
3027
+ .split("\n")
3028
+ .filter((line) => line.trim() !== "")
3029
+ .map((line) => parse(JSON.parse(line)));
3030
+ } catch (error) {
3031
+ if (isNotFoundError(error)) return [];
3032
+ throw error;
3033
+ }
3034
+ }
3035
+
3036
+ function shellQuote(value: string): string {
3037
+ return `'${value.replace(/'/g, `'\\''`)}'`;
3038
+ }
3039
+
3040
+ function assertSafeId(value: string, path: string): void {
3041
+ if (!SAFE_ROLE_ID_PATTERN.test(value)) throw new Error(`Invalid ${path}; unsafe id.`);
3042
+ }
3043
+
3044
+ function assertString(value: unknown, path: string): asserts value is string {
3045
+ if (typeof value !== "string" || value.length === 0) {
3046
+ throw new Error(`Invalid ${path}; expected non-empty string.`);
3047
+ }
3048
+ }
3049
+
3050
+ function optionalString(value: unknown): string | undefined {
3051
+ return typeof value === "string" && value.length > 0 ? value : undefined;
3052
+ }
3053
+
3054
+ function optionalNullableString(value: unknown): string | null | undefined {
3055
+ if (value === null) return null;
3056
+ return optionalString(value);
3057
+ }
3058
+
3059
+ function optionalBoolean(value: unknown): boolean | undefined {
3060
+ return typeof value === "boolean" ? value : undefined;
3061
+ }
3062
+
3063
+ function isRecord(value: unknown): value is Record<string, unknown> {
3064
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3065
+ }
3066
+
3067
+ function describeError(error: unknown): string {
3068
+ return error instanceof Error ? error.message : String(error);
3069
+ }