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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) 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 +248 -0
  5. package/assets/skills/coding/knowledge-distillation/manifest.json +10 -0
  6. package/assets/skills/coding/knowledge-distillation/references/knowledge-distillation-methods.md +122 -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 +242 -36
  16. package/dist/index.js +5045 -934
  17. package/dist/plugins/index.js +32 -32
  18. package/package.json +1 -1
  19. package/src/agents/index.ts +28 -49
  20. package/src/config/index.ts +2 -0
  21. package/src/config/paths.ts +30 -0
  22. package/src/config/settings.ts +52 -0
  23. package/src/config/store.ts +150 -0
  24. package/src/daemon/index.ts +376 -3
  25. package/src/evolution/index.ts +2356 -0
  26. package/src/hooks/index.ts +255 -238
  27. package/src/index.ts +4 -0
  28. package/src/pack/index.ts +13 -13
  29. package/src/plugins/capabilities.ts +40 -42
  30. package/src/plugins/index.ts +0 -1
  31. package/src/plugins/types.ts +4 -0
  32. package/src/protected-zones/index.ts +29 -11
  33. package/src/runtime-logs/index.ts +324 -0
  34. package/src/sync/orchestrator.ts +6 -0
  35. package/src/task/index.ts +3 -3
  36. package/src/team/index.ts +2398 -0
  37. package/src/team/mcp.ts +401 -0
  38. package/src/workflow/index.ts +6 -6
@@ -0,0 +1,2398 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { resolveEvoDevPaths } from "../config/paths.ts";
5
+ import { createDefaultSettings, parseSettings } from "../config/settings.ts";
6
+ import { resolveProjectLogKey } from "../runtime-logs/index.ts";
7
+
8
+ export type TeamAgentRuntime = "codex" | "claude";
9
+ export type TeamAgentStatus = "running" | "recovering" | "recreated" | "stopped" | "failed";
10
+ export type TeamRunStatus = "running" | "stopped" | "failed";
11
+ export type TeamWriteMode = "read-only" | "repo-write" | "worktree-write" | "disabled";
12
+ export type TeamMessageType = "request" | "result" | "issue" | "notice";
13
+ export type TeamRuntimeAgentRecoveryMode =
14
+ | { type: "resume"; sessionId: string }
15
+ | { type: "resume-latest" }
16
+ | { type: "fresh" };
17
+ export type TeamSessionMissingDecision = "ask" | "fail" | "recreate" | "resume-latest";
18
+
19
+ export interface TeamRuntimeCommandResult {
20
+ exitCode: number;
21
+ stdout: string;
22
+ stderr: string;
23
+ }
24
+
25
+ export interface TeamRuntimeCommandRunner {
26
+ run(
27
+ command: string,
28
+ args: string[],
29
+ options?: { input?: string },
30
+ ): Promise<TeamRuntimeCommandResult>;
31
+ }
32
+
33
+ export interface TmuxRuntimeAdapterOptions {
34
+ runtimeCommands?: Partial<Record<TeamAgentRuntime, string>>;
35
+ }
36
+
37
+ export interface TeamRuntimeAdapter {
38
+ createRun(input: TeamRuntimeCreateRunInput): Promise<TeamRuntimeAgentHandle>;
39
+ spawnAgent(input: TeamRuntimeSpawnAgentInput): Promise<TeamRuntimeAgentHandle>;
40
+ recoverAgent(input: TeamRuntimeRecoverAgentInput): Promise<TeamRuntimeAgentHandle>;
41
+ sendInput(input: TeamRuntimeSendInputInput): Promise<void>;
42
+ listPanes(input: TeamRuntimeListPanesInput): Promise<TeamRuntimePaneInfo[]>;
43
+ stopAgent(input: TeamRuntimeStopAgentInput): Promise<void>;
44
+ stopRun(input: TeamRuntimeStopRunInput): Promise<void>;
45
+ formatAttachCommand(input: TeamRuntimeAttachInput): string;
46
+ }
47
+
48
+ export interface TeamRuntimeCreateRunInput {
49
+ runId: string;
50
+ sessionName: string;
51
+ repoRoot: string;
52
+ role: ResolvedTeamRole;
53
+ startupPrompt: string;
54
+ }
55
+
56
+ export interface TeamRuntimeSpawnAgentInput {
57
+ runId: string;
58
+ sessionName: string;
59
+ repoRoot: string;
60
+ role: ResolvedTeamRole;
61
+ startupPrompt: string;
62
+ targetPaneId?: string;
63
+ }
64
+
65
+ export interface TeamRuntimeRecoverAgentInput {
66
+ runId: string;
67
+ sessionName: string;
68
+ repoRoot: string;
69
+ role: ResolvedTeamRole;
70
+ startupPrompt: string;
71
+ mode: TeamRuntimeAgentRecoveryMode;
72
+ targetPaneId?: string;
73
+ }
74
+
75
+ export interface TeamRuntimeAgentHandle {
76
+ session: string;
77
+ window: string;
78
+ paneId: string;
79
+ nativeSessionId?: string | null;
80
+ }
81
+
82
+ export interface TeamRuntimeSendInputInput {
83
+ session: string;
84
+ paneId: string;
85
+ text: string;
86
+ }
87
+
88
+ export interface TeamRuntimeListPanesInput {
89
+ session: string;
90
+ }
91
+
92
+ export interface TeamRuntimePaneInfo {
93
+ paneId: string;
94
+ }
95
+
96
+ export interface TeamRuntimeStopRunInput {
97
+ session: string;
98
+ }
99
+
100
+ export interface TeamRuntimeStopAgentInput {
101
+ session: string;
102
+ paneId: string;
103
+ }
104
+
105
+ export interface TeamRuntimeAttachInput {
106
+ session: string;
107
+ paneId?: string;
108
+ }
109
+
110
+ export interface TeamRolePermissions {
111
+ writeMode: TeamWriteMode;
112
+ canUseTeamsMcp: boolean;
113
+ canSpawnAgents: boolean;
114
+ canStopAgents: boolean;
115
+ }
116
+
117
+ export interface TeamRolePolicy {
118
+ roleInstancePolicy: "single-per-role";
119
+ recordTranscript: boolean;
120
+ }
121
+
122
+ export interface TeamRoleDefinition {
123
+ version: 1;
124
+ roleId: string;
125
+ roleName: string;
126
+ description: string;
127
+ runtime: TeamAgentRuntime;
128
+ model: string | null;
129
+ thinkingLevel: string | null;
130
+ prompt: string;
131
+ permissions: TeamRolePermissions;
132
+ teamPolicy: TeamRolePolicy;
133
+ source: "builtin" | "global";
134
+ }
135
+
136
+ export interface ResolvedTeamRole extends TeamRoleDefinition {
137
+ sourcePath: string | null;
138
+ nativeAgent: TeamNativeAgentBinding | null;
139
+ }
140
+
141
+ export interface TeamNativeAgentBinding {
142
+ roleId: string;
143
+ target: TeamAgentRuntime;
144
+ agentName: string;
145
+ scope: "global" | "project";
146
+ projectKey: string | null;
147
+ updatedAt: string;
148
+ }
149
+
150
+ export interface TeamRoleBindingRecord {
151
+ version: 1;
152
+ roleId: string;
153
+ target: TeamAgentRuntime;
154
+ agentName: string;
155
+ updatedAt: string;
156
+ }
157
+
158
+ export interface TeamRoleBindingConfig {
159
+ version: 1;
160
+ updatedAt: string;
161
+ roles: TeamRoleBindingRecord[];
162
+ }
163
+
164
+ export interface TeamRoleBindingScopeInput {
165
+ homeDir?: string;
166
+ repoRoot?: string;
167
+ scope: "global" | "project";
168
+ }
169
+
170
+ export interface TeamRunRecord {
171
+ version: 1;
172
+ runId: string;
173
+ repoRoot: string;
174
+ status: TeamRunStatus;
175
+ mainAgentId: string;
176
+ roleInstancePolicy: "single-per-role";
177
+ tmux: {
178
+ session: string;
179
+ };
180
+ roles: Record<string, string>;
181
+ createdAt: string;
182
+ updatedAt: string;
183
+ }
184
+
185
+ export interface TeamAgentRecord {
186
+ version: 1;
187
+ agentId: string;
188
+ roleId: string;
189
+ roleName: string;
190
+ runId: string;
191
+ runtime: TeamAgentRuntime;
192
+ model: string | null;
193
+ thinkingLevel: string | null;
194
+ status: TeamAgentStatus;
195
+ permissions: TeamRolePermissions;
196
+ nativeSession: {
197
+ sessionId: string | null;
198
+ capturedAt: string | null;
199
+ };
200
+ tmux: {
201
+ session: string;
202
+ window: string;
203
+ paneId: string;
204
+ };
205
+ createdAt: string;
206
+ updatedAt: string;
207
+ }
208
+
209
+ export interface TeamMessageRecord {
210
+ version: 1;
211
+ messageId: string;
212
+ runId: string;
213
+ fromRoleId: string;
214
+ toRoleId: string;
215
+ ccRoleIds: string[];
216
+ type: TeamMessageType;
217
+ body: string;
218
+ status: "accepted";
219
+ createdAt: string;
220
+ }
221
+
222
+ export interface TeamEventRecord {
223
+ version: 1;
224
+ eventId: string;
225
+ runId: string;
226
+ type:
227
+ | "TeamRunStarted"
228
+ | "AgentSpawned"
229
+ | "AgentStopped"
230
+ | "TeamMessageAccepted"
231
+ | "TeamMessageDelivered"
232
+ | "TeamMessageDeliveryFailed"
233
+ | "AgentNativeSessionRecorded"
234
+ | "AgentRecoveryDecisionRequired"
235
+ | "AgentRecovered"
236
+ | "AgentRecreated"
237
+ | "TeamRunStopped";
238
+ roleId?: string;
239
+ agentId?: string;
240
+ messageId?: string;
241
+ summary: string;
242
+ createdAt: string;
243
+ }
244
+
245
+ export interface StartTeamRunInput {
246
+ homeDir?: string;
247
+ repoRoot: string;
248
+ now?: Date;
249
+ mainOverrides?: Partial<{
250
+ runtime: TeamAgentRuntime;
251
+ model: string | null;
252
+ thinkingLevel: string | null;
253
+ }>;
254
+ runtimeAdapter?: TeamRuntimeAdapter;
255
+ }
256
+
257
+ export interface SpawnTeamRoleInput {
258
+ homeDir?: string;
259
+ repoRoot: string;
260
+ runId?: string;
261
+ roleId: string;
262
+ now?: Date;
263
+ runtimeAdapter?: TeamRuntimeAdapter;
264
+ }
265
+
266
+ export interface StopTeamRoleInput {
267
+ homeDir?: string;
268
+ runId?: string;
269
+ roleId: string;
270
+ now?: Date;
271
+ runtimeAdapter?: TeamRuntimeAdapter;
272
+ }
273
+
274
+ export interface SendTeamMessageInput {
275
+ homeDir?: string;
276
+ runId?: string;
277
+ fromRoleId?: string;
278
+ toRoleId: string;
279
+ message: string;
280
+ type?: TeamMessageType;
281
+ now?: Date;
282
+ runtimeAdapter?: TeamRuntimeAdapter;
283
+ }
284
+
285
+ export interface StopTeamRunInput {
286
+ homeDir?: string;
287
+ runId?: string;
288
+ now?: Date;
289
+ runtimeAdapter?: TeamRuntimeAdapter;
290
+ }
291
+
292
+ export interface TeamStatusInput {
293
+ homeDir?: string;
294
+ runId?: string;
295
+ }
296
+
297
+ export interface ListTeamRunsInput {
298
+ homeDir?: string;
299
+ }
300
+
301
+ export interface ReconcileTeamRunInput {
302
+ homeDir?: string;
303
+ runId?: string;
304
+ now?: Date;
305
+ runtimeAdapter?: TeamRuntimeAdapter;
306
+ notifyMain?: boolean;
307
+ }
308
+
309
+ export interface ResumeTeamRunInput {
310
+ homeDir?: string;
311
+ runId?: string;
312
+ now?: Date;
313
+ runtimeAdapter?: TeamRuntimeAdapter;
314
+ missingSessionDecision?: TeamSessionMissingDecision;
315
+ notifyMain?: boolean;
316
+ }
317
+
318
+ export interface RecordTeamAgentNativeSessionInput {
319
+ homeDir?: string;
320
+ runId?: string;
321
+ roleId: string;
322
+ sessionId: string;
323
+ now?: Date;
324
+ }
325
+
326
+ export interface TeamAttachInput {
327
+ homeDir?: string;
328
+ runId?: string;
329
+ roleId?: string;
330
+ runtimeAdapter?: TeamRuntimeAdapter;
331
+ }
332
+
333
+ export interface TeamRunStartResult {
334
+ run: TeamRunRecord;
335
+ mainAgent: TeamAgentRecord;
336
+ }
337
+
338
+ export interface TeamRoleSpawnResult {
339
+ run: TeamRunRecord;
340
+ agent: TeamAgentRecord;
341
+ created: boolean;
342
+ }
343
+
344
+ export interface TeamRoleStopResult {
345
+ ok: boolean;
346
+ run?: TeamRunRecord;
347
+ agent?: TeamAgentRecord;
348
+ stopped?: boolean;
349
+ error?: string;
350
+ message?: string;
351
+ }
352
+
353
+ export interface TeamRoleLifecycleSpawnResult {
354
+ ok: boolean;
355
+ run?: TeamRunRecord;
356
+ agent?: TeamAgentRecord;
357
+ created?: boolean;
358
+ error?: string;
359
+ message?: string;
360
+ }
361
+
362
+ export async function listTeamRoleBindings(input: {
363
+ homeDir?: string;
364
+ repoRoot?: string;
365
+ }): Promise<TeamNativeAgentBinding[]> {
366
+ const global = (
367
+ await readTeamBindingConfig(resolveGlobalTeamBindingPath(input.homeDir))
368
+ ).roles.map((record) => bindingRecordToNative(record, "global", null));
369
+ if (input.repoRoot === undefined) return global;
370
+
371
+ const projectKey = resolveProjectLogKey(
372
+ resolveEvoDevPaths(input.homeDir).homeDir,
373
+ input.repoRoot,
374
+ );
375
+ const project = (
376
+ await readTeamBindingConfig(resolveProjectTeamBindingPath(input.homeDir, projectKey))
377
+ ).roles.map((record) => bindingRecordToNative(record, "project", projectKey));
378
+ return mergeTeamRoleBindings(global, project);
379
+ }
380
+
381
+ export async function setTeamRoleBinding(input: {
382
+ homeDir?: string;
383
+ repoRoot?: string;
384
+ scope: "global" | "project";
385
+ roleId: string;
386
+ target: TeamAgentRuntime;
387
+ agentName: string;
388
+ now?: Date;
389
+ }): Promise<{ path: string; binding: TeamNativeAgentBinding }> {
390
+ assertSafeId(input.roleId, "roleId");
391
+ assertSafeId(input.agentName, "agentName");
392
+ const { path, projectKey } = resolveTeamBindingWritePath(input);
393
+ const timestamp = (input.now ?? new Date()).toISOString();
394
+ const current = await readTeamBindingConfig(path);
395
+ const nextRecord: TeamRoleBindingRecord = {
396
+ version: 1,
397
+ roleId: input.roleId,
398
+ target: input.target,
399
+ agentName: input.agentName,
400
+ updatedAt: timestamp,
401
+ };
402
+ const roles = [
403
+ ...current.roles.filter((record) => record.roleId !== input.roleId),
404
+ nextRecord,
405
+ ].sort((left, right) => left.roleId.localeCompare(right.roleId));
406
+ await writeJson(path, { version: 1, updatedAt: timestamp, roles });
407
+ return { path, binding: bindingRecordToNative(nextRecord, input.scope, projectKey) };
408
+ }
409
+
410
+ export async function unsetTeamRoleBinding(input: {
411
+ homeDir?: string;
412
+ repoRoot?: string;
413
+ scope: "global" | "project";
414
+ roleId: string;
415
+ now?: Date;
416
+ }): Promise<{ path: string; removed: boolean }> {
417
+ assertSafeId(input.roleId, "roleId");
418
+ const { path } = resolveTeamBindingWritePath(input);
419
+ const current = await readTeamBindingConfig(path);
420
+ const roles = current.roles.filter((record) => record.roleId !== input.roleId);
421
+ const removed = roles.length !== current.roles.length;
422
+ if (removed) {
423
+ await writeJson(path, {
424
+ version: 1,
425
+ updatedAt: (input.now ?? new Date()).toISOString(),
426
+ roles,
427
+ });
428
+ }
429
+ return { path, removed };
430
+ }
431
+
432
+ export interface TeamMessageSendResult {
433
+ ok: boolean;
434
+ messageId?: string;
435
+ deliveredTo?: string;
436
+ cc?: string[];
437
+ error?: string;
438
+ message?: string;
439
+ }
440
+
441
+ export interface TeamMessageBrokerOptions {
442
+ homeDir?: string;
443
+ runtimeAdapter?: TeamRuntimeAdapter;
444
+ }
445
+
446
+ export type TeamMessageBrokerSendInput = Omit<SendTeamMessageInput, "homeDir" | "runtimeAdapter">;
447
+ export interface TeamMessageBrokerSpawnInput {
448
+ runId?: string;
449
+ fromRoleId?: string;
450
+ roleId: string;
451
+ reason?: string;
452
+ now?: Date;
453
+ }
454
+
455
+ export interface TeamMessageBrokerStopInput {
456
+ runId?: string;
457
+ fromRoleId?: string;
458
+ roleId: string;
459
+ reason?: string;
460
+ now?: Date;
461
+ }
462
+
463
+ export interface TeamStatusResult {
464
+ run: TeamRunRecord | null;
465
+ agents: TeamAgentRecord[];
466
+ }
467
+
468
+ export interface TeamRunReconcileResult extends TeamStatusResult {
469
+ stoppedAgents: TeamAgentRecord[];
470
+ notifications: TeamMessageSendResult[];
471
+ runtimeAvailable: boolean;
472
+ }
473
+
474
+ export interface TeamAgentRecoveryResult {
475
+ roleId: string;
476
+ agentId: string;
477
+ outcome: "reused" | "resumed" | "recreated" | "needs-decision" | "failed";
478
+ previousPaneId: string;
479
+ paneId: string | null;
480
+ nativeSessionId: string | null;
481
+ reason?: string;
482
+ decisionOptions?: TeamSessionMissingDecision[];
483
+ }
484
+
485
+ export interface TeamRunResumeResult extends TeamStatusResult {
486
+ outcomes: TeamAgentRecoveryResult[];
487
+ decisionRequired: TeamAgentRecoveryResult[];
488
+ notifications: TeamMessageSendResult[];
489
+ }
490
+
491
+ export interface TeamAgentSummary {
492
+ roleId: string;
493
+ roleName: string;
494
+ status: TeamAgentStatus;
495
+ runtime: TeamAgentRuntime;
496
+ canReceiveMessages: boolean;
497
+ }
498
+
499
+ export interface TeamRunStore {
500
+ paths: ReturnType<typeof resolveTeamRunPaths>;
501
+ createRunDirs(runId: string): Promise<void>;
502
+ writeRun(run: TeamRunRecord): Promise<void>;
503
+ readRun(runId: string): Promise<TeamRunRecord>;
504
+ writeLatestRunId(runId: string): Promise<void>;
505
+ readLatestRunId(): Promise<string | null>;
506
+ writeAgent(agent: TeamAgentRecord): Promise<void>;
507
+ readAgent(runId: string, roleId: string): Promise<TeamAgentRecord>;
508
+ readAgents(runId: string): Promise<TeamAgentRecord[]>;
509
+ appendMessage(runId: string, message: TeamMessageRecord): Promise<void>;
510
+ appendEvent(runId: string, event: TeamEventRecord): Promise<void>;
511
+ }
512
+
513
+ const SAFE_ROLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
514
+ const BUILT_IN_ROLE_PROMPTS: Record<
515
+ string,
516
+ { roleName: string; description: string; prompt: string }
517
+ > = {
518
+ main: {
519
+ roleName: "Main Conductor",
520
+ description: "Coordinates the EvoDev team run and owns final synthesis.",
521
+ prompt:
522
+ "You are the main conductor for this EvoDev team run. Coordinate role agents, keep decisions explicit, and synthesize final outcomes.",
523
+ },
524
+ reviewer: {
525
+ roleName: "Code Reviewer",
526
+ description: "Reviews implementation quality, risks, and regressions.",
527
+ prompt:
528
+ "You are the code reviewer for this EvoDev team run. Review changes and report concrete findings.",
529
+ },
530
+ tester: {
531
+ roleName: "Test Engineer",
532
+ description: "Verifies behavior and identifies test gaps.",
533
+ prompt:
534
+ "You are the test engineer for this EvoDev team run. Run or recommend focused verification and report gaps.",
535
+ },
536
+ executor: {
537
+ roleName: "Implementation Executor",
538
+ description: "Implements scoped changes assigned by main.",
539
+ prompt:
540
+ "You are the implementation executor for this EvoDev team run. Keep changes scoped and report verification evidence.",
541
+ },
542
+ };
543
+
544
+ export function createTeamRunStore(homeDir?: string): TeamRunStore {
545
+ const paths = resolveTeamRunPaths(homeDir);
546
+
547
+ return {
548
+ paths,
549
+ async createRunDirs(runId) {
550
+ await mkdir(paths.runDir(runId), { recursive: true });
551
+ await mkdir(paths.agentsDir(runId), { recursive: true });
552
+ },
553
+ async writeRun(run) {
554
+ await this.createRunDirs(run.runId);
555
+ await writeJson(paths.runPath(run.runId), parseTeamRunRecord(run));
556
+ },
557
+ async readRun(runId) {
558
+ return parseTeamRunRecord(JSON.parse(await readFile(paths.runPath(runId), "utf8")));
559
+ },
560
+ async writeLatestRunId(runId) {
561
+ await mkdir(paths.runsDir, { recursive: true });
562
+ await writeJson(paths.latestRunPath, { version: 1, runId });
563
+ },
564
+ async readLatestRunId() {
565
+ try {
566
+ const latest = JSON.parse(await readFile(paths.latestRunPath, "utf8"));
567
+ if (latest?.version === 1 && typeof latest.runId === "string") return latest.runId;
568
+ return null;
569
+ } catch {
570
+ return null;
571
+ }
572
+ },
573
+ async writeAgent(agent) {
574
+ await this.createRunDirs(agent.runId);
575
+ await writeJson(paths.agentPath(agent.runId, agent.roleId), parseTeamAgentRecord(agent));
576
+ },
577
+ async readAgent(runId, roleId) {
578
+ return parseTeamAgentRecord(
579
+ JSON.parse(await readFile(paths.agentPath(runId, roleId), "utf8")),
580
+ );
581
+ },
582
+ async readAgents(runId) {
583
+ try {
584
+ const entries = await readdir(paths.agentsDir(runId));
585
+ const agents = await Promise.all(
586
+ entries
587
+ .filter((entry) => entry.endsWith(".json"))
588
+ .map((entry) =>
589
+ readFile(join(paths.agentsDir(runId), entry), "utf8").then((raw) =>
590
+ parseTeamAgentRecord(JSON.parse(raw)),
591
+ ),
592
+ ),
593
+ );
594
+ return agents.sort((left, right) => left.roleId.localeCompare(right.roleId));
595
+ } catch {
596
+ return [];
597
+ }
598
+ },
599
+ async appendMessage(runId, message) {
600
+ await this.createRunDirs(runId);
601
+ await appendJsonLine(paths.messagesPath(runId), parseTeamMessageRecord(message));
602
+ },
603
+ async appendEvent(runId, event) {
604
+ await this.createRunDirs(runId);
605
+ await appendJsonLine(paths.eventsPath(runId), parseTeamEventRecord(event));
606
+ },
607
+ };
608
+ }
609
+
610
+ export function resolveTeamRunPaths(homeDir?: string) {
611
+ const paths = resolveEvoDevPaths(homeDir);
612
+ return {
613
+ rootDir: paths.rootDir,
614
+ roleAgentsDir: paths.roleAgentsDir,
615
+ teamsDir: paths.teamsDir,
616
+ runsDir: paths.runsDir,
617
+ latestRunPath: paths.latestRunPath,
618
+ runDir: (runId: string) => join(paths.runsDir, runId),
619
+ runPath: (runId: string) => join(paths.runsDir, runId, "run.json"),
620
+ tmuxPath: (runId: string) => join(paths.runsDir, runId, "tmux.json"),
621
+ agentsDir: (runId: string) => join(paths.runsDir, runId, "agents"),
622
+ agentPath: (runId: string, roleId: string) =>
623
+ join(paths.runsDir, runId, "agents", `${roleId}.json`),
624
+ messagesPath: (runId: string) => join(paths.runsDir, runId, "messages.jsonl"),
625
+ eventsPath: (runId: string) => join(paths.runsDir, runId, "events.jsonl"),
626
+ };
627
+ }
628
+
629
+ export async function startTeamRun(input: StartTeamRunInput): Promise<TeamRunStartResult> {
630
+ const store = createTeamRunStore(input.homeDir);
631
+ const now = input.now ?? new Date();
632
+ const createdAt = now.toISOString();
633
+ const runId = createRunId(input.repoRoot, now);
634
+ const sessionName = createTmuxSessionName(input.repoRoot, runId);
635
+ const role = await resolveTeamRole({
636
+ homeDir: input.homeDir,
637
+ repoRoot: input.repoRoot,
638
+ roleId: "main",
639
+ overrides: input.mainOverrides,
640
+ });
641
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
642
+ const startupPrompt = createAgentStartupPrompt({
643
+ runId,
644
+ repoRoot: input.repoRoot,
645
+ role,
646
+ roster: [],
647
+ });
648
+ const handle = await runtimeAdapter.createRun({
649
+ runId,
650
+ sessionName,
651
+ repoRoot: input.repoRoot,
652
+ role,
653
+ startupPrompt,
654
+ });
655
+ const agent = createAgentRecord({
656
+ runId,
657
+ role,
658
+ handle,
659
+ now: createdAt,
660
+ });
661
+ const run: TeamRunRecord = {
662
+ version: 1,
663
+ runId,
664
+ repoRoot: input.repoRoot,
665
+ status: "running",
666
+ mainAgentId: agent.agentId,
667
+ roleInstancePolicy: "single-per-role",
668
+ tmux: { session: handle.session },
669
+ roles: { main: agent.agentId },
670
+ createdAt,
671
+ updatedAt: createdAt,
672
+ };
673
+
674
+ await store.writeRun(run);
675
+ await store.writeAgent(agent);
676
+ await store.writeLatestRunId(runId);
677
+ await writeJson(store.paths.tmuxPath(runId), {
678
+ version: 1,
679
+ runtime: "tmux",
680
+ session: handle.session,
681
+ createdAt,
682
+ windows: [{ name: handle.window, roles: ["main"] }],
683
+ });
684
+ await store.appendEvent(
685
+ runId,
686
+ createTeamEvent(runId, "TeamRunStarted", `Started team run ${runId}.`, createdAt, {
687
+ roleId: "main",
688
+ agentId: agent.agentId,
689
+ }),
690
+ );
691
+
692
+ return { run, mainAgent: agent };
693
+ }
694
+
695
+ export async function spawnTeamRole(input: SpawnTeamRoleInput): Promise<TeamRoleSpawnResult> {
696
+ const store = createTeamRunStore(input.homeDir);
697
+ const runId = await resolveRequestedRunId(store, input.runId);
698
+ const run = await store.readRun(runId);
699
+ if (run.status !== "running") throw new Error(`Team run ${run.runId} is not running.`);
700
+ const existingAgentId = run.roles[input.roleId];
701
+ if (existingAgentId !== undefined) {
702
+ const existing = await store.readAgent(run.runId, input.roleId);
703
+ if (isActiveTeamAgentStatus(existing.status)) {
704
+ return { run, agent: existing, created: false };
705
+ }
706
+ }
707
+
708
+ const now = (input.now ?? new Date()).toISOString();
709
+ const role = await resolveTeamRole({
710
+ homeDir: input.homeDir,
711
+ repoRoot: input.repoRoot,
712
+ roleId: input.roleId,
713
+ });
714
+ const agents = await store.readAgents(run.runId);
715
+ const mainAgent = agents.find((agent) => agent.roleId === "main");
716
+ const startupPrompt = createAgentStartupPrompt({
717
+ runId: run.runId,
718
+ repoRoot: run.repoRoot,
719
+ role,
720
+ roster: agents,
721
+ });
722
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
723
+ const handle = await runtimeAdapter.spawnAgent({
724
+ runId: run.runId,
725
+ sessionName: run.tmux.session,
726
+ repoRoot: run.repoRoot,
727
+ role,
728
+ startupPrompt,
729
+ targetPaneId: mainAgent?.tmux.paneId,
730
+ });
731
+ const agent = createAgentRecord({ runId: run.runId, role, handle, now });
732
+ const updatedRun: TeamRunRecord = {
733
+ ...run,
734
+ roles: { ...run.roles, [role.roleId]: agent.agentId },
735
+ updatedAt: now,
736
+ };
737
+
738
+ await store.writeRun(updatedRun);
739
+ await store.writeAgent(agent);
740
+ await store.appendEvent(
741
+ run.runId,
742
+ createTeamEvent(run.runId, "AgentSpawned", `Spawned role ${role.roleId}.`, now, {
743
+ roleId: role.roleId,
744
+ agentId: agent.agentId,
745
+ }),
746
+ );
747
+
748
+ return { run: updatedRun, agent, created: true };
749
+ }
750
+
751
+ export async function stopTeamRole(input: StopTeamRoleInput): Promise<TeamRoleStopResult> {
752
+ const store = createTeamRunStore(input.homeDir);
753
+ const runId = await resolveRequestedRunId(store, input.runId);
754
+ const run = await store.readRun(runId);
755
+ if (run.status !== "running") {
756
+ return {
757
+ ok: false,
758
+ error: "team-run-not-running",
759
+ message: `Team run ${run.runId} is not running.`,
760
+ };
761
+ }
762
+ if (input.roleId === "main") {
763
+ return {
764
+ ok: false,
765
+ error: "cannot-stop-main-role",
766
+ message: "Use evodev team stop to stop the entire team run instead of stopping main.",
767
+ };
768
+ }
769
+
770
+ const agentId = run.roles[input.roleId];
771
+ if (agentId === undefined) {
772
+ return {
773
+ ok: false,
774
+ error: "target-role-not-found",
775
+ message: `Role ${input.roleId} does not exist in run ${run.runId}.`,
776
+ };
777
+ }
778
+
779
+ const agent = await store.readAgent(run.runId, input.roleId);
780
+ if (!isActiveTeamAgentStatus(agent.status)) {
781
+ return {
782
+ ok: false,
783
+ error: "target-role-not-running",
784
+ message: `Role ${input.roleId} is not running in run ${run.runId}.`,
785
+ };
786
+ }
787
+
788
+ const now = (input.now ?? new Date()).toISOString();
789
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
790
+ await runtimeAdapter.stopAgent({
791
+ session: agent.tmux.session,
792
+ paneId: agent.tmux.paneId,
793
+ });
794
+ const stoppedAgent: TeamAgentRecord = { ...agent, status: "stopped", updatedAt: now };
795
+ const updatedRun: TeamRunRecord = { ...run, updatedAt: now };
796
+ await store.writeRun(updatedRun);
797
+ await store.writeAgent(stoppedAgent);
798
+ await store.appendEvent(
799
+ run.runId,
800
+ createTeamEvent(run.runId, "AgentStopped", `Stopped role ${input.roleId}.`, now, {
801
+ roleId: input.roleId,
802
+ agentId,
803
+ }),
804
+ );
805
+
806
+ return { ok: true, run: updatedRun, agent: stoppedAgent, stopped: true };
807
+ }
808
+
809
+ export class TeamMessageBroker {
810
+ constructor(private readonly options: TeamMessageBrokerOptions = {}) {}
811
+
812
+ async listAgents(input: TeamStatusInput = {}): Promise<TeamAgentSummary[]> {
813
+ return listTeamAgents({ homeDir: this.options.homeDir, runId: input.runId });
814
+ }
815
+
816
+ async send(input: TeamMessageBrokerSendInput): Promise<TeamMessageSendResult> {
817
+ return sendTeamMessageWithBrokerContext(this.options, input);
818
+ }
819
+
820
+ async spawnRole(input: TeamMessageBrokerSpawnInput): Promise<TeamRoleLifecycleSpawnResult> {
821
+ return spawnTeamRoleWithBrokerContext(this.options, input);
822
+ }
823
+
824
+ async stopRole(input: TeamMessageBrokerStopInput): Promise<TeamRoleStopResult> {
825
+ return stopTeamRoleWithBrokerContext(this.options, input);
826
+ }
827
+ }
828
+
829
+ export async function sendTeamMessage(input: SendTeamMessageInput): Promise<TeamMessageSendResult> {
830
+ return new TeamMessageBroker({
831
+ homeDir: input.homeDir,
832
+ runtimeAdapter: input.runtimeAdapter,
833
+ }).send(input);
834
+ }
835
+
836
+ async function sendTeamMessageWithBrokerContext(
837
+ options: TeamMessageBrokerOptions,
838
+ input: TeamMessageBrokerSendInput,
839
+ ): Promise<TeamMessageSendResult> {
840
+ const store = createTeamRunStore(options.homeDir);
841
+ const runId = await resolveRequestedRunId(store, input.runId);
842
+ const run = await store.readRun(runId);
843
+ if (run.status !== "running") {
844
+ return {
845
+ ok: false,
846
+ error: "team-run-not-running",
847
+ message: `Team run ${run.runId} is not running.`,
848
+ };
849
+ }
850
+ const targetAgentId = run.roles[input.toRoleId];
851
+ if (targetAgentId === undefined) {
852
+ return {
853
+ ok: false,
854
+ error: "target-role-not-found",
855
+ message: `Role ${input.toRoleId} does not exist in run ${run.runId}.`,
856
+ };
857
+ }
858
+ const target = await store.readAgent(run.runId, input.toRoleId);
859
+ if (!isActiveTeamAgentStatus(target.status)) {
860
+ return {
861
+ ok: false,
862
+ error: "target-role-not-running",
863
+ message: `Role ${input.toRoleId} is not running in run ${run.runId}.`,
864
+ };
865
+ }
866
+
867
+ const now = (input.now ?? new Date()).toISOString();
868
+ const fromRoleId = input.fromRoleId ?? "user";
869
+ const ccRoleIds =
870
+ fromRoleId !== "user" && fromRoleId !== "main" && input.toRoleId !== "main" && run.roles.main
871
+ ? ["main"]
872
+ : [];
873
+ const message: TeamMessageRecord = {
874
+ version: 1,
875
+ messageId: createMessageId(now),
876
+ runId: run.runId,
877
+ fromRoleId,
878
+ toRoleId: input.toRoleId,
879
+ ccRoleIds,
880
+ type: input.type ?? "request",
881
+ body: input.message,
882
+ status: "accepted",
883
+ createdAt: now,
884
+ };
885
+
886
+ await store.appendMessage(run.runId, message);
887
+ await store.appendEvent(
888
+ run.runId,
889
+ createTeamEvent(
890
+ run.runId,
891
+ "TeamMessageAccepted",
892
+ `Accepted message ${message.messageId}.`,
893
+ now,
894
+ {
895
+ messageId: message.messageId,
896
+ },
897
+ ),
898
+ );
899
+
900
+ const runtimeAdapter = options.runtimeAdapter ?? createTmuxRuntimeAdapter();
901
+ try {
902
+ await runtimeAdapter.sendInput({
903
+ session: target.tmux.session,
904
+ paneId: target.tmux.paneId,
905
+ text: formatDeliveredTeamMessage(message, false),
906
+ });
907
+ for (const ccRoleId of ccRoleIds) {
908
+ const cc = await store.readAgent(run.runId, ccRoleId);
909
+ if (isActiveTeamAgentStatus(cc.status)) {
910
+ await runtimeAdapter.sendInput({
911
+ session: cc.tmux.session,
912
+ paneId: cc.tmux.paneId,
913
+ text: formatDeliveredTeamMessage(message, true),
914
+ });
915
+ }
916
+ }
917
+ await store.appendEvent(
918
+ run.runId,
919
+ createTeamEvent(
920
+ run.runId,
921
+ "TeamMessageDelivered",
922
+ `Delivered message ${message.messageId} to ${input.toRoleId}.`,
923
+ now,
924
+ { roleId: input.toRoleId, agentId: targetAgentId, messageId: message.messageId },
925
+ ),
926
+ );
927
+ return {
928
+ ok: true,
929
+ messageId: message.messageId,
930
+ deliveredTo: input.toRoleId,
931
+ cc: ccRoleIds,
932
+ };
933
+ } catch (error) {
934
+ await store.appendEvent(
935
+ run.runId,
936
+ createTeamEvent(
937
+ run.runId,
938
+ "TeamMessageDeliveryFailed",
939
+ `Failed to deliver message ${message.messageId}: ${describeError(error)}`,
940
+ now,
941
+ { roleId: input.toRoleId, agentId: targetAgentId, messageId: message.messageId },
942
+ ),
943
+ );
944
+ return {
945
+ ok: false,
946
+ messageId: message.messageId,
947
+ error: "delivery-failed",
948
+ message: describeError(error),
949
+ };
950
+ }
951
+ }
952
+
953
+ async function spawnTeamRoleWithBrokerContext(
954
+ options: TeamMessageBrokerOptions,
955
+ input: TeamMessageBrokerSpawnInput,
956
+ ): Promise<TeamRoleLifecycleSpawnResult> {
957
+ const store = createTeamRunStore(options.homeDir);
958
+ const runId = await resolveRequestedRunId(store, input.runId);
959
+ const run = await store.readRun(runId);
960
+
961
+ try {
962
+ const result = await spawnTeamRole({
963
+ homeDir: options.homeDir,
964
+ repoRoot: run.repoRoot,
965
+ runId: run.runId,
966
+ roleId: input.roleId,
967
+ now: input.now,
968
+ runtimeAdapter: options.runtimeAdapter,
969
+ });
970
+ return { ok: true, run: result.run, agent: result.agent, created: result.created };
971
+ } catch (error) {
972
+ return {
973
+ ok: false,
974
+ error: "spawn-role-failed",
975
+ message: describeError(error),
976
+ };
977
+ }
978
+ }
979
+
980
+ async function stopTeamRoleWithBrokerContext(
981
+ options: TeamMessageBrokerOptions,
982
+ input: TeamMessageBrokerStopInput,
983
+ ): Promise<TeamRoleStopResult> {
984
+ const store = createTeamRunStore(options.homeDir);
985
+ const runId = await resolveRequestedRunId(store, input.runId);
986
+ const run = await store.readRun(runId);
987
+
988
+ return stopTeamRole({
989
+ homeDir: options.homeDir,
990
+ runId: run.runId,
991
+ roleId: input.roleId,
992
+ now: input.now,
993
+ runtimeAdapter: options.runtimeAdapter,
994
+ });
995
+ }
996
+
997
+ async function notifyMainAboutStoppedAgents(input: {
998
+ homeDir?: string;
999
+ run: TeamRunRecord;
1000
+ agents: TeamAgentRecord[];
1001
+ stoppedAgents: TeamAgentRecord[];
1002
+ livePaneIds: Set<string>;
1003
+ runtimeAdapter: TeamRuntimeAdapter;
1004
+ }): Promise<TeamMessageSendResult[]> {
1005
+ const main = input.agents.find((agent) => agent.roleId === "main");
1006
+ if (
1007
+ main === undefined ||
1008
+ !isActiveTeamAgentStatus(main.status) ||
1009
+ !input.livePaneIds.has(main.tmux.paneId)
1010
+ ) {
1011
+ return [];
1012
+ }
1013
+
1014
+ const broker = new TeamMessageBroker({
1015
+ homeDir: input.homeDir,
1016
+ runtimeAdapter: input.runtimeAdapter,
1017
+ });
1018
+ const notifications: TeamMessageSendResult[] = [];
1019
+ for (const agent of input.stoppedAgents) {
1020
+ if (agent.roleId === "main") continue;
1021
+ notifications.push(
1022
+ await broker.send({
1023
+ runId: input.run.runId,
1024
+ fromRoleId: "evodev",
1025
+ toRoleId: "main",
1026
+ type: "notice",
1027
+ message: `Role ${agent.roleId} pane ${agent.tmux.paneId} closed; marked stopped.`,
1028
+ }),
1029
+ );
1030
+ }
1031
+ return notifications;
1032
+ }
1033
+
1034
+ async function notifyMainAboutRecovery(input: {
1035
+ homeDir?: string;
1036
+ run: TeamRunRecord;
1037
+ outcomes: TeamAgentRecoveryResult[];
1038
+ agents: TeamAgentRecord[];
1039
+ runtimeAdapter: TeamRuntimeAdapter;
1040
+ }): Promise<TeamMessageSendResult[]> {
1041
+ const changedOutcomes = input.outcomes.filter((outcome) => outcome.outcome !== "reused");
1042
+ if (changedOutcomes.length === 0) return [];
1043
+ const main = input.agents.find((agent) => agent.roleId === "main");
1044
+ if (main === undefined || !isActiveTeamAgentStatus(main.status)) return [];
1045
+
1046
+ const lines = changedOutcomes.map((outcome) => {
1047
+ const target =
1048
+ outcome.paneId === null ? "no pane" : `pane ${outcome.previousPaneId} -> ${outcome.paneId}`;
1049
+ return `- ${outcome.roleId}: ${outcome.outcome} (${target})`;
1050
+ });
1051
+ const broker = new TeamMessageBroker({
1052
+ homeDir: input.homeDir,
1053
+ runtimeAdapter: input.runtimeAdapter,
1054
+ });
1055
+ const result = await broker.send({
1056
+ runId: input.run.runId,
1057
+ fromRoleId: "evodev",
1058
+ toRoleId: "main",
1059
+ type: "notice",
1060
+ message: ["EvoDev recovery summary:", ...lines].join("\n"),
1061
+ });
1062
+ return [result];
1063
+ }
1064
+
1065
+ export async function stopTeamRun(input: StopTeamRunInput): Promise<TeamStatusResult> {
1066
+ const store = createTeamRunStore(input.homeDir);
1067
+ const runId = await resolveRequestedRunId(store, input.runId);
1068
+ const run = await store.readRun(runId);
1069
+ const now = (input.now ?? new Date()).toISOString();
1070
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1071
+ await runtimeAdapter.stopRun({ session: run.tmux.session });
1072
+ const agents = await store.readAgents(run.runId);
1073
+ const stoppedAgents = agents.map((agent) => ({
1074
+ ...agent,
1075
+ status: "stopped" as const,
1076
+ updatedAt: now,
1077
+ }));
1078
+ const stoppedRun: TeamRunRecord = { ...run, status: "stopped", updatedAt: now };
1079
+ await store.writeRun(stoppedRun);
1080
+ for (const agent of stoppedAgents) {
1081
+ await store.writeAgent(agent);
1082
+ }
1083
+ await store.appendEvent(
1084
+ run.runId,
1085
+ createTeamEvent(run.runId, "TeamRunStopped", `Stopped team run ${run.runId}.`, now),
1086
+ );
1087
+ return { run: stoppedRun, agents: stoppedAgents };
1088
+ }
1089
+
1090
+ export async function getTeamStatus(input: TeamStatusInput = {}): Promise<TeamStatusResult> {
1091
+ const store = createTeamRunStore(input.homeDir);
1092
+ const runId = input.runId ?? (await store.readLatestRunId());
1093
+ if (runId === null) return { run: null, agents: [] };
1094
+ const run = await store.readRun(runId);
1095
+ return { run, agents: await store.readAgents(runId) };
1096
+ }
1097
+
1098
+ export async function listTeamRuns(input: ListTeamRunsInput = {}): Promise<TeamRunRecord[]> {
1099
+ const store = createTeamRunStore(input.homeDir);
1100
+ let entries: string[];
1101
+ try {
1102
+ entries = await readdir(store.paths.runsDir);
1103
+ } catch (error) {
1104
+ if (isNotFoundError(error)) return [];
1105
+ throw error;
1106
+ }
1107
+ const runs: TeamRunRecord[] = [];
1108
+ for (const entry of entries) {
1109
+ if (entry === "latest.json") continue;
1110
+ try {
1111
+ runs.push(await store.readRun(entry));
1112
+ } catch {}
1113
+ }
1114
+ return runs.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1115
+ }
1116
+
1117
+ export async function reconcileTeamRun(
1118
+ input: ReconcileTeamRunInput = {},
1119
+ ): Promise<TeamRunReconcileResult> {
1120
+ const store = createTeamRunStore(input.homeDir);
1121
+ const runId = input.runId ?? (await store.readLatestRunId());
1122
+ if (runId === null) {
1123
+ return { run: null, agents: [], stoppedAgents: [], notifications: [], runtimeAvailable: true };
1124
+ }
1125
+
1126
+ const run = await store.readRun(runId);
1127
+ const agents = await store.readAgents(run.runId);
1128
+ if (run.status !== "running") {
1129
+ return { run, agents, stoppedAgents: [], notifications: [], runtimeAvailable: true };
1130
+ }
1131
+
1132
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1133
+ let livePaneIds: Set<string>;
1134
+ try {
1135
+ livePaneIds = new Set(
1136
+ (await runtimeAdapter.listPanes({ session: run.tmux.session })).map((pane) => pane.paneId),
1137
+ );
1138
+ } catch {
1139
+ livePaneIds = new Set();
1140
+ }
1141
+
1142
+ const now = (input.now ?? new Date()).toISOString();
1143
+ const stoppedAgents = agents
1144
+ .filter((agent) => isActiveTeamAgentStatus(agent.status) && !livePaneIds.has(agent.tmux.paneId))
1145
+ .map((agent) => ({ ...agent, status: "stopped" as const, updatedAt: now }));
1146
+
1147
+ if (stoppedAgents.length === 0) {
1148
+ return { run, agents, stoppedAgents: [], notifications: [], runtimeAvailable: true };
1149
+ }
1150
+
1151
+ const stoppedRoleIds = new Set(stoppedAgents.map((agent) => agent.roleId));
1152
+ const updatedAgents = agents.map(
1153
+ (agent) => stoppedAgents.find((stopped) => stopped.roleId === agent.roleId) ?? agent,
1154
+ );
1155
+ const updatedRun: TeamRunRecord = {
1156
+ ...run,
1157
+ status: stoppedRoleIds.has("main") ? "stopped" : run.status,
1158
+ updatedAt: now,
1159
+ };
1160
+
1161
+ await store.writeRun(updatedRun);
1162
+ for (const agent of stoppedAgents) {
1163
+ await store.writeAgent(agent);
1164
+ await store.appendEvent(
1165
+ run.runId,
1166
+ createTeamEvent(run.runId, "AgentStopped", `Detected closed pane for ${agent.roleId}.`, now, {
1167
+ roleId: agent.roleId,
1168
+ agentId: agent.agentId,
1169
+ }),
1170
+ );
1171
+ }
1172
+
1173
+ const notifications =
1174
+ input.notifyMain === false
1175
+ ? []
1176
+ : await notifyMainAboutStoppedAgents({
1177
+ homeDir: input.homeDir,
1178
+ run: updatedRun,
1179
+ agents: updatedAgents,
1180
+ stoppedAgents,
1181
+ livePaneIds,
1182
+ runtimeAdapter,
1183
+ });
1184
+
1185
+ return {
1186
+ run: updatedRun,
1187
+ agents: updatedAgents,
1188
+ stoppedAgents,
1189
+ notifications,
1190
+ runtimeAvailable: livePaneIds.size > 0,
1191
+ };
1192
+ }
1193
+
1194
+ export async function recordTeamAgentNativeSession(
1195
+ input: RecordTeamAgentNativeSessionInput,
1196
+ ): Promise<TeamAgentRecord> {
1197
+ const store = createTeamRunStore(input.homeDir);
1198
+ const runId = await resolveRequestedRunId(store, input.runId);
1199
+ const agent = await store.readAgent(runId, input.roleId);
1200
+ if (agent.nativeSession.sessionId === input.sessionId) return agent;
1201
+ const now = (input.now ?? new Date()).toISOString();
1202
+ const updatedAgent: TeamAgentRecord = {
1203
+ ...agent,
1204
+ nativeSession: {
1205
+ sessionId: input.sessionId,
1206
+ capturedAt: now,
1207
+ },
1208
+ updatedAt: now,
1209
+ };
1210
+
1211
+ await store.writeAgent(updatedAgent);
1212
+ await store.appendEvent(
1213
+ runId,
1214
+ createTeamEvent(
1215
+ runId,
1216
+ "AgentNativeSessionRecorded",
1217
+ `Recorded native session id for ${input.roleId}.`,
1218
+ now,
1219
+ {
1220
+ roleId: input.roleId,
1221
+ agentId: agent.agentId,
1222
+ },
1223
+ ),
1224
+ );
1225
+
1226
+ return updatedAgent;
1227
+ }
1228
+
1229
+ export async function resumeTeamRun(input: ResumeTeamRunInput = {}): Promise<TeamRunResumeResult> {
1230
+ const store = createTeamRunStore(input.homeDir);
1231
+ const runId = await resolveRequestedRunId(store, input.runId);
1232
+ const run = await store.readRun(runId);
1233
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1234
+ const now = (input.now ?? new Date()).toISOString();
1235
+ const decision = input.missingSessionDecision ?? "ask";
1236
+ const originalAgents = sortAgentsForRecovery(await store.readAgents(run.runId));
1237
+ const livePaneIds = await readLivePaneIds(runtimeAdapter, run.tmux.session);
1238
+ const outcomes: TeamAgentRecoveryResult[] = [];
1239
+ const updatedAgents: TeamAgentRecord[] = [];
1240
+ let updatedRun: TeamRunRecord = { ...run, status: "running", updatedAt: now };
1241
+
1242
+ for (const agent of originalAgents) {
1243
+ if (isActiveTeamAgentStatus(agent.status) && livePaneIds.has(agent.tmux.paneId)) {
1244
+ updatedAgents.push(agent);
1245
+ outcomes.push({
1246
+ roleId: agent.roleId,
1247
+ agentId: agent.agentId,
1248
+ outcome: "reused",
1249
+ previousPaneId: agent.tmux.paneId,
1250
+ paneId: agent.tmux.paneId,
1251
+ nativeSessionId: agent.nativeSession.sessionId,
1252
+ });
1253
+ continue;
1254
+ }
1255
+
1256
+ const role = await resolveRoleForAgentRecovery({
1257
+ homeDir: input.homeDir,
1258
+ repoRoot: run.repoRoot,
1259
+ agent,
1260
+ });
1261
+ const roster = [
1262
+ ...updatedAgents,
1263
+ ...originalAgents.filter((item) => item.roleId !== agent.roleId),
1264
+ ];
1265
+ const startupPrompt = createAgentStartupPrompt({
1266
+ runId: run.runId,
1267
+ repoRoot: run.repoRoot,
1268
+ role,
1269
+ roster,
1270
+ });
1271
+ const recoveryMode = resolveRecoveryMode(agent, decision);
1272
+
1273
+ if (recoveryMode === null) {
1274
+ const stoppedAgent = { ...agent, status: "stopped" as const, updatedAt: now };
1275
+ await store.writeAgent(stoppedAgent);
1276
+ await store.appendEvent(
1277
+ run.runId,
1278
+ createTeamEvent(
1279
+ run.runId,
1280
+ "AgentRecoveryDecisionRequired",
1281
+ `Recovery for ${agent.roleId} requires a session decision.`,
1282
+ now,
1283
+ { roleId: agent.roleId, agentId: agent.agentId },
1284
+ ),
1285
+ );
1286
+ updatedAgents.push(stoppedAgent);
1287
+ outcomes.push({
1288
+ roleId: agent.roleId,
1289
+ agentId: agent.agentId,
1290
+ outcome: decision === "fail" ? "failed" : "needs-decision",
1291
+ previousPaneId: agent.tmux.paneId,
1292
+ paneId: null,
1293
+ nativeSessionId: agent.nativeSession.sessionId,
1294
+ reason:
1295
+ decision === "fail"
1296
+ ? "Native session id is missing and fallback is disabled."
1297
+ : "Native session id is missing. Choose fail, recreate, or resume-latest.",
1298
+ decisionOptions: ["fail", "recreate", "resume-latest"],
1299
+ });
1300
+ continue;
1301
+ }
1302
+
1303
+ try {
1304
+ const handle = await runtimeAdapter.recoverAgent({
1305
+ runId: run.runId,
1306
+ sessionName: run.tmux.session,
1307
+ repoRoot: run.repoRoot,
1308
+ role,
1309
+ startupPrompt,
1310
+ mode: recoveryMode,
1311
+ targetPaneId: updatedAgents.find((item) => item.roleId === "main")?.tmux.paneId,
1312
+ });
1313
+ const recoveredAgent = createRecoveredAgentRecord({
1314
+ existing: agent,
1315
+ role,
1316
+ handle,
1317
+ mode: recoveryMode,
1318
+ now,
1319
+ });
1320
+ updatedRun = {
1321
+ ...updatedRun,
1322
+ roles: { ...updatedRun.roles, [agent.roleId]: recoveredAgent.agentId },
1323
+ updatedAt: now,
1324
+ };
1325
+ await store.writeAgent(recoveredAgent);
1326
+ await store.appendEvent(
1327
+ run.runId,
1328
+ createTeamEvent(
1329
+ run.runId,
1330
+ recoveredAgent.status === "recreated" ? "AgentRecreated" : "AgentRecovered",
1331
+ `${recoveredAgent.status === "recreated" ? "Recreated" : "Recovered"} role ${agent.roleId}.`,
1332
+ now,
1333
+ { roleId: agent.roleId, agentId: recoveredAgent.agentId },
1334
+ ),
1335
+ );
1336
+ updatedAgents.push(recoveredAgent);
1337
+ outcomes.push({
1338
+ roleId: recoveredAgent.roleId,
1339
+ agentId: recoveredAgent.agentId,
1340
+ outcome: recoveredAgent.status === "recreated" ? "recreated" : "resumed",
1341
+ previousPaneId: agent.tmux.paneId,
1342
+ paneId: recoveredAgent.tmux.paneId,
1343
+ nativeSessionId: recoveredAgent.nativeSession.sessionId,
1344
+ });
1345
+ } catch (error) {
1346
+ const stoppedAgent = { ...agent, status: "stopped" as const, updatedAt: now };
1347
+ await store.writeAgent(stoppedAgent);
1348
+ await store.appendEvent(
1349
+ run.runId,
1350
+ createTeamEvent(
1351
+ run.runId,
1352
+ "AgentRecoveryDecisionRequired",
1353
+ `Recovery for ${agent.roleId} failed: ${describeError(error)}`,
1354
+ now,
1355
+ { roleId: agent.roleId, agentId: agent.agentId },
1356
+ ),
1357
+ );
1358
+ updatedAgents.push(stoppedAgent);
1359
+ outcomes.push({
1360
+ roleId: agent.roleId,
1361
+ agentId: agent.agentId,
1362
+ outcome: "needs-decision",
1363
+ previousPaneId: agent.tmux.paneId,
1364
+ paneId: null,
1365
+ nativeSessionId: agent.nativeSession.sessionId,
1366
+ reason: describeError(error),
1367
+ decisionOptions: ["fail", "recreate", "resume-latest"],
1368
+ });
1369
+ }
1370
+ }
1371
+
1372
+ const main = updatedAgents.find((agent) => agent.roleId === "main");
1373
+ updatedRun = {
1374
+ ...updatedRun,
1375
+ status: main !== undefined && isActiveTeamAgentStatus(main.status) ? "running" : "stopped",
1376
+ updatedAt: now,
1377
+ };
1378
+ await store.writeRun(updatedRun);
1379
+
1380
+ const decisionRequired = outcomes.filter((outcome) => outcome.outcome === "needs-decision");
1381
+ const notifications =
1382
+ input.notifyMain === false
1383
+ ? []
1384
+ : await notifyMainAboutRecovery({
1385
+ homeDir: input.homeDir,
1386
+ run: updatedRun,
1387
+ outcomes,
1388
+ agents: updatedAgents,
1389
+ runtimeAdapter,
1390
+ });
1391
+
1392
+ return {
1393
+ run: updatedRun,
1394
+ agents: updatedAgents.sort((left, right) => left.roleId.localeCompare(right.roleId)),
1395
+ outcomes,
1396
+ decisionRequired,
1397
+ notifications,
1398
+ };
1399
+ }
1400
+
1401
+ export async function getTeamAttachCommand(input: TeamAttachInput = {}): Promise<string> {
1402
+ const store = createTeamRunStore(input.homeDir);
1403
+ const runId = await resolveRequestedRunId(store, input.runId);
1404
+ const run = await store.readRun(runId);
1405
+ const roleId = input.roleId ?? "main";
1406
+ const agent = await store.readAgent(run.runId, roleId);
1407
+ const runtimeAdapter = input.runtimeAdapter ?? createTmuxRuntimeAdapter();
1408
+ return runtimeAdapter.formatAttachCommand({
1409
+ session: run.tmux.session,
1410
+ paneId: agent.tmux.paneId,
1411
+ });
1412
+ }
1413
+
1414
+ export async function listTeamAgents(input: TeamStatusInput = {}): Promise<TeamAgentSummary[]> {
1415
+ const status = await getTeamStatus(input);
1416
+ return status.agents.map((agent) => ({
1417
+ roleId: agent.roleId,
1418
+ roleName: agent.roleName,
1419
+ status: agent.status,
1420
+ runtime: agent.runtime,
1421
+ canReceiveMessages: isActiveTeamAgentStatus(agent.status),
1422
+ }));
1423
+ }
1424
+
1425
+ export async function resolveTeamRole(input: {
1426
+ homeDir?: string;
1427
+ repoRoot: string;
1428
+ roleId: string;
1429
+ overrides?: Partial<{
1430
+ runtime: TeamAgentRuntime;
1431
+ model: string | null;
1432
+ thinkingLevel: string | null;
1433
+ }>;
1434
+ }): Promise<ResolvedTeamRole> {
1435
+ assertSafeId(input.roleId, "roleId");
1436
+ const settings = await readSettingsOrDefault(input.homeDir);
1437
+ const globalRolePath = join(
1438
+ resolveEvoDevPaths(input.homeDir).roleAgentsDir,
1439
+ `${input.roleId}.json`,
1440
+ );
1441
+ const candidate = await readRoleCandidate(globalRolePath, "global");
1442
+ const nativeAgent = await resolveTeamRoleNativeAgentBinding({
1443
+ homeDir: input.homeDir,
1444
+ repoRoot: input.repoRoot,
1445
+ roleId: input.roleId,
1446
+ });
1447
+ const raw =
1448
+ candidate?.value ?? createBuiltInRole(input.roleId, settings.teamRuntime.defaultRuntime);
1449
+ const source = candidate?.source ?? "builtin";
1450
+ const sourcePath = candidate?.path ?? null;
1451
+ const parsed = parseTeamRoleDefinition(raw, {
1452
+ roleId: input.roleId,
1453
+ source,
1454
+ defaultRuntime: settings.teamRuntime.defaultRuntime,
1455
+ defaultModel: settings.teamRuntime.defaultModel,
1456
+ defaultThinkingLevel: settings.teamRuntime.defaultThinkingLevel,
1457
+ recordTranscript: settings.teamRuntime.recordTranscript,
1458
+ });
1459
+
1460
+ return {
1461
+ ...parsed,
1462
+ runtime: input.overrides?.runtime ?? nativeAgent?.target ?? parsed.runtime,
1463
+ model: input.overrides?.model ?? parsed.model,
1464
+ thinkingLevel: input.overrides?.thinkingLevel ?? parsed.thinkingLevel,
1465
+ sourcePath,
1466
+ nativeAgent,
1467
+ };
1468
+ }
1469
+
1470
+ export function parseTeamRoleDefinition(
1471
+ value: unknown,
1472
+ defaults: {
1473
+ roleId: string;
1474
+ source: TeamRoleDefinition["source"];
1475
+ defaultRuntime: TeamAgentRuntime;
1476
+ defaultModel: string | null;
1477
+ defaultThinkingLevel: string | null;
1478
+ recordTranscript: boolean;
1479
+ },
1480
+ ): TeamRoleDefinition {
1481
+ const input = isRecord(value) ? value : {};
1482
+ const roleId = optionalString(input.roleId) ?? defaults.roleId;
1483
+ assertSafeId(roleId, "roleId");
1484
+ const runtime = parseRuntime(input.runtime, defaults.defaultRuntime);
1485
+ const isMain = roleId === "main";
1486
+ return {
1487
+ version: 1,
1488
+ roleId,
1489
+ roleName:
1490
+ optionalString(input.roleName) ?? optionalString(input.name) ?? defaultRoleName(roleId),
1491
+ description: optionalString(input.description) ?? `EvoDev ${roleId} role agent.`,
1492
+ runtime,
1493
+ model: optionalNullableString(input.model) ?? defaults.defaultModel,
1494
+ thinkingLevel: optionalNullableString(input.thinkingLevel) ?? defaults.defaultThinkingLevel,
1495
+ prompt: optionalString(input.prompt) ?? defaultRolePrompt(roleId),
1496
+ permissions: parseRolePermissions(input.permissions, isMain),
1497
+ teamPolicy: parseRolePolicy(input.teamPolicy, defaults.recordTranscript),
1498
+ source: defaults.source,
1499
+ };
1500
+ }
1501
+
1502
+ export function createTmuxRuntimeAdapter(
1503
+ runner: TeamRuntimeCommandRunner = new NodeTeamRuntimeCommandRunner(),
1504
+ options: TmuxRuntimeAdapterOptions = {},
1505
+ ): TeamRuntimeAdapter {
1506
+ return new TmuxRuntimeAdapter(runner, options);
1507
+ }
1508
+
1509
+ export class TmuxRuntimeAdapter implements TeamRuntimeAdapter {
1510
+ constructor(
1511
+ private readonly runner: TeamRuntimeCommandRunner,
1512
+ private readonly options: TmuxRuntimeAdapterOptions = {},
1513
+ ) {}
1514
+
1515
+ async createRun(input: TeamRuntimeCreateRunInput): Promise<TeamRuntimeAgentHandle> {
1516
+ const command = buildAgentShellCommand(input.role, input.startupPrompt, {
1517
+ runId: input.runId,
1518
+ repoRoot: input.repoRoot,
1519
+ runtimeCommands: this.options.runtimeCommands,
1520
+ });
1521
+ await this.runTmux([
1522
+ "new-session",
1523
+ "-d",
1524
+ "-s",
1525
+ input.sessionName,
1526
+ "-n",
1527
+ "main",
1528
+ "-c",
1529
+ input.repoRoot,
1530
+ command,
1531
+ ]);
1532
+ const pane = await this.runTmux([
1533
+ "display-message",
1534
+ "-p",
1535
+ "-t",
1536
+ `${input.sessionName}:main.0`,
1537
+ "#{pane_id}",
1538
+ ]);
1539
+ return { session: input.sessionName, window: "main", paneId: pane.stdout.trim() };
1540
+ }
1541
+
1542
+ async spawnAgent(input: TeamRuntimeSpawnAgentInput): Promise<TeamRuntimeAgentHandle> {
1543
+ const window = sanitizeWindowName(input.role.roleId);
1544
+ const command = buildAgentShellCommand(input.role, input.startupPrompt, {
1545
+ runId: input.runId,
1546
+ repoRoot: input.repoRoot,
1547
+ runtimeCommands: this.options.runtimeCommands,
1548
+ });
1549
+ if (input.targetPaneId !== undefined) {
1550
+ const pane = await this.splitPane(input.targetPaneId, input.repoRoot, command);
1551
+ return { session: input.sessionName, window: "main", paneId: pane };
1552
+ }
1553
+
1554
+ const pane = await this.runTmux([
1555
+ "new-window",
1556
+ "-d",
1557
+ "-P",
1558
+ "-F",
1559
+ "#{pane_id}",
1560
+ "-t",
1561
+ input.sessionName,
1562
+ "-n",
1563
+ window,
1564
+ "-c",
1565
+ input.repoRoot,
1566
+ command,
1567
+ ]);
1568
+ return { session: input.sessionName, window, paneId: pane.stdout.trim() };
1569
+ }
1570
+
1571
+ async recoverAgent(input: TeamRuntimeRecoverAgentInput): Promise<TeamRuntimeAgentHandle> {
1572
+ const window = sanitizeWindowName(input.role.roleId);
1573
+ const command =
1574
+ input.mode.type === "fresh"
1575
+ ? buildAgentShellCommand(input.role, input.startupPrompt, {
1576
+ runId: input.runId,
1577
+ repoRoot: input.repoRoot,
1578
+ runtimeCommands: this.options.runtimeCommands,
1579
+ })
1580
+ : buildAgentResumeShellCommand(input.role, input.startupPrompt, {
1581
+ runId: input.runId,
1582
+ repoRoot: input.repoRoot,
1583
+ mode: input.mode,
1584
+ runtimeCommands: this.options.runtimeCommands,
1585
+ });
1586
+ const session = await this.runner.run("tmux", ["has-session", "-t", input.sessionName]);
1587
+ if (session.exitCode !== 0) {
1588
+ await this.runTmux([
1589
+ "new-session",
1590
+ "-d",
1591
+ "-s",
1592
+ input.sessionName,
1593
+ "-n",
1594
+ window,
1595
+ "-c",
1596
+ input.repoRoot,
1597
+ command,
1598
+ ]);
1599
+ const pane = await this.runTmux([
1600
+ "display-message",
1601
+ "-p",
1602
+ "-t",
1603
+ `${input.sessionName}:${window}.0`,
1604
+ "#{pane_id}",
1605
+ ]);
1606
+ return recoveredHandle(input, window, pane.stdout.trim());
1607
+ }
1608
+
1609
+ if (input.targetPaneId !== undefined && input.role.roleId !== "main") {
1610
+ const pane = await this.splitPane(input.targetPaneId, input.repoRoot, command);
1611
+ return recoveredHandle(input, "main", pane);
1612
+ }
1613
+
1614
+ const pane = await this.runTmux([
1615
+ "new-window",
1616
+ "-d",
1617
+ "-P",
1618
+ "-F",
1619
+ "#{pane_id}",
1620
+ "-t",
1621
+ input.sessionName,
1622
+ "-n",
1623
+ window,
1624
+ "-c",
1625
+ input.repoRoot,
1626
+ command,
1627
+ ]);
1628
+ return recoveredHandle(input, window, pane.stdout.trim());
1629
+ }
1630
+
1631
+ private async splitPane(
1632
+ targetPaneId: string,
1633
+ repoRoot: string,
1634
+ command: string,
1635
+ ): Promise<string> {
1636
+ const pane = await this.runTmux([
1637
+ "split-window",
1638
+ "-h",
1639
+ "-d",
1640
+ "-P",
1641
+ "-F",
1642
+ "#{pane_id}",
1643
+ "-t",
1644
+ targetPaneId,
1645
+ "-c",
1646
+ repoRoot,
1647
+ command,
1648
+ ]);
1649
+ await this.runTmux(["select-layout", "-t", targetPaneId, "tiled"]);
1650
+ return pane.stdout.trim();
1651
+ }
1652
+
1653
+ async sendInput(input: TeamRuntimeSendInputInput): Promise<void> {
1654
+ await this.runTmux(["set-buffer", "-b", "evodev-message", input.text]);
1655
+ await this.runTmux(["paste-buffer", "-d", "-b", "evodev-message", "-t", input.paneId]);
1656
+ await this.runTmux(["send-keys", "-t", input.paneId, "Enter"]);
1657
+ }
1658
+
1659
+ async listPanes(input: TeamRuntimeListPanesInput): Promise<TeamRuntimePaneInfo[]> {
1660
+ const panes = await this.runTmux(["list-panes", "-t", input.session, "-F", "#{pane_id}"]);
1661
+ return panes.stdout
1662
+ .split("\n")
1663
+ .map((paneId) => paneId.trim())
1664
+ .filter(Boolean)
1665
+ .map((paneId) => ({ paneId }));
1666
+ }
1667
+
1668
+ async stopAgent(input: TeamRuntimeStopAgentInput): Promise<void> {
1669
+ await this.runTmux(["kill-pane", "-t", input.paneId]);
1670
+ }
1671
+
1672
+ async stopRun(input: TeamRuntimeStopRunInput): Promise<void> {
1673
+ await this.runTmux(["kill-session", "-t", input.session]);
1674
+ }
1675
+
1676
+ formatAttachCommand(input: TeamRuntimeAttachInput): string {
1677
+ return input.paneId === undefined
1678
+ ? `tmux attach-session -t ${input.session}`
1679
+ : `tmux attach-session -t ${input.session} \\; select-pane -t ${input.paneId}`;
1680
+ }
1681
+
1682
+ private async runTmux(
1683
+ args: string[],
1684
+ options?: { input?: string },
1685
+ ): Promise<TeamRuntimeCommandResult> {
1686
+ const result = await this.runner.run("tmux", args, options);
1687
+ if (result.exitCode !== 0) {
1688
+ throw new Error(`tmux ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
1689
+ }
1690
+ return result;
1691
+ }
1692
+ }
1693
+
1694
+ export class NodeTeamRuntimeCommandRunner implements TeamRuntimeCommandRunner {
1695
+ async run(
1696
+ command: string,
1697
+ args: string[],
1698
+ options: { input?: string } = {},
1699
+ ): Promise<TeamRuntimeCommandResult> {
1700
+ return new Promise((resolve, reject) => {
1701
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
1702
+ let stdout = "";
1703
+ let stderr = "";
1704
+ child.stdout.setEncoding("utf8");
1705
+ child.stderr.setEncoding("utf8");
1706
+ child.stdout.on("data", (chunk: string) => {
1707
+ stdout += chunk;
1708
+ });
1709
+ child.stderr.on("data", (chunk: string) => {
1710
+ stderr += chunk;
1711
+ });
1712
+ child.once("error", reject);
1713
+ child.once("close", (code) => {
1714
+ resolve({ exitCode: code ?? 1, stdout, stderr });
1715
+ });
1716
+ child.stdin.end(options.input ?? "");
1717
+ });
1718
+ }
1719
+ }
1720
+
1721
+ function createAgentRecord(input: {
1722
+ runId: string;
1723
+ role: ResolvedTeamRole;
1724
+ handle: TeamRuntimeAgentHandle;
1725
+ now: string;
1726
+ }): TeamAgentRecord {
1727
+ const tmux = teamTmuxFromHandle(input.handle);
1728
+ return {
1729
+ version: 1,
1730
+ agentId: createAgentId(input.role.roleId, input.now),
1731
+ roleId: input.role.roleId,
1732
+ roleName: input.role.roleName,
1733
+ runId: input.runId,
1734
+ runtime: input.role.runtime,
1735
+ model: input.role.model,
1736
+ thinkingLevel: input.role.thinkingLevel,
1737
+ status: "running",
1738
+ permissions: input.role.permissions,
1739
+ nativeSession: {
1740
+ sessionId: input.handle.nativeSessionId ?? null,
1741
+ capturedAt:
1742
+ input.handle.nativeSessionId === undefined || input.handle.nativeSessionId === null
1743
+ ? null
1744
+ : input.now,
1745
+ },
1746
+ tmux,
1747
+ createdAt: input.now,
1748
+ updatedAt: input.now,
1749
+ };
1750
+ }
1751
+
1752
+ function createRecoveredAgentRecord(input: {
1753
+ existing: TeamAgentRecord;
1754
+ role: ResolvedTeamRole;
1755
+ handle: TeamRuntimeAgentHandle;
1756
+ mode: TeamRuntimeAgentRecoveryMode;
1757
+ now: string;
1758
+ }): TeamAgentRecord {
1759
+ if (input.mode.type === "fresh") {
1760
+ return {
1761
+ ...createAgentRecord({
1762
+ runId: input.existing.runId,
1763
+ role: input.role,
1764
+ handle: input.handle,
1765
+ now: input.now,
1766
+ }),
1767
+ status: "recreated",
1768
+ };
1769
+ }
1770
+
1771
+ const nativeSessionId =
1772
+ input.handle.nativeSessionId ??
1773
+ (input.mode.type === "resume" ? input.mode.sessionId : input.existing.nativeSession.sessionId);
1774
+
1775
+ return {
1776
+ ...input.existing,
1777
+ roleName: input.role.roleName,
1778
+ runtime: input.role.runtime,
1779
+ model: input.role.model,
1780
+ thinkingLevel: input.role.thinkingLevel,
1781
+ permissions: input.role.permissions,
1782
+ status: "recovering",
1783
+ nativeSession: {
1784
+ sessionId: nativeSessionId,
1785
+ capturedAt:
1786
+ nativeSessionId === null
1787
+ ? null
1788
+ : input.handle.nativeSessionId === undefined
1789
+ ? input.existing.nativeSession.capturedAt
1790
+ : input.now,
1791
+ },
1792
+ tmux: teamTmuxFromHandle(input.handle),
1793
+ updatedAt: input.now,
1794
+ };
1795
+ }
1796
+
1797
+ function teamTmuxFromHandle(handle: TeamRuntimeAgentHandle): TeamAgentRecord["tmux"] {
1798
+ return {
1799
+ session: handle.session,
1800
+ window: handle.window,
1801
+ paneId: handle.paneId,
1802
+ };
1803
+ }
1804
+
1805
+ function resolveRecoveryMode(
1806
+ agent: TeamAgentRecord,
1807
+ decision: TeamSessionMissingDecision,
1808
+ ): TeamRuntimeAgentRecoveryMode | null {
1809
+ if (agent.nativeSession.sessionId !== null) {
1810
+ return { type: "resume", sessionId: agent.nativeSession.sessionId };
1811
+ }
1812
+ if (decision === "recreate") return { type: "fresh" };
1813
+ if (decision === "resume-latest") return { type: "resume-latest" };
1814
+ return null;
1815
+ }
1816
+
1817
+ async function readLivePaneIds(
1818
+ runtimeAdapter: TeamRuntimeAdapter,
1819
+ session: string,
1820
+ ): Promise<Set<string>> {
1821
+ try {
1822
+ return new Set((await runtimeAdapter.listPanes({ session })).map((pane) => pane.paneId));
1823
+ } catch {
1824
+ return new Set();
1825
+ }
1826
+ }
1827
+
1828
+ function sortAgentsForRecovery(agents: TeamAgentRecord[]): TeamAgentRecord[] {
1829
+ return [...agents].sort((left, right) => {
1830
+ if (left.roleId === "main") return -1;
1831
+ if (right.roleId === "main") return 1;
1832
+ return left.roleId.localeCompare(right.roleId);
1833
+ });
1834
+ }
1835
+
1836
+ async function resolveRoleForAgentRecovery(input: {
1837
+ homeDir?: string;
1838
+ repoRoot: string;
1839
+ agent: TeamAgentRecord;
1840
+ }): Promise<ResolvedTeamRole> {
1841
+ const resolved = await resolveTeamRole({
1842
+ homeDir: input.homeDir,
1843
+ repoRoot: input.repoRoot,
1844
+ roleId: input.agent.roleId,
1845
+ });
1846
+ return {
1847
+ ...resolved,
1848
+ roleName: input.agent.roleName,
1849
+ runtime: input.agent.runtime,
1850
+ model: input.agent.model,
1851
+ thinkingLevel: input.agent.thinkingLevel,
1852
+ permissions: input.agent.permissions,
1853
+ };
1854
+ }
1855
+
1856
+ function createAgentStartupPrompt(input: {
1857
+ runId: string;
1858
+ repoRoot: string;
1859
+ role: ResolvedTeamRole;
1860
+ roster: TeamAgentRecord[];
1861
+ }): string {
1862
+ const roster =
1863
+ input.roster.length === 0
1864
+ ? "- main: starting"
1865
+ : input.roster
1866
+ .map((agent) => `- ${agent.roleId}: ${agent.status} (${agent.roleName})`)
1867
+ .join("\n");
1868
+ return [
1869
+ "You are an EvoDev managed role agent.",
1870
+ `Team run: ${input.runId}`,
1871
+ `Repository: ${input.repoRoot}`,
1872
+ `Role id: ${input.role.roleId}`,
1873
+ `Role name: ${input.role.roleName}`,
1874
+ `Runtime: ${input.role.runtime}`,
1875
+ input.role.nativeAgent === null
1876
+ ? "Native Code Agent binding: none"
1877
+ : `Native Code Agent binding: ${input.role.nativeAgent.target}/${input.role.nativeAgent.agentName} (${input.role.nativeAgent.scope})`,
1878
+ `Model: ${input.role.model ?? "default"}`,
1879
+ `Thinking level: ${input.role.thinkingLevel ?? "default"}`,
1880
+ `Write mode: ${input.role.permissions.writeMode}`,
1881
+ `Transcript recording: ${input.role.teamPolicy.recordTranscript ? "enabled" : "disabled"}`,
1882
+ "",
1883
+ "Current known agents:",
1884
+ roster,
1885
+ "",
1886
+ "EvoDev team runtime control contract:",
1887
+ "This run is already inside the EvoDev-managed team runtime.",
1888
+ "For EvoDev role-agent lifecycle, use the current EvoDev run control plane.",
1889
+ "Unprefixed user requests such as 'start the team', 'execute team', 'create agents', or 'spawn roles' mean: use the current EvoDev run control plane.",
1890
+ "Do not answer those requests with only a role plan when a role agent should be created.",
1891
+ "",
1892
+ "Use Teams MCP as the primary control plane:",
1893
+ "- list_agents: discover current EvoDev role agents.",
1894
+ "- spawn_role: create or reuse exactly one EvoDev role agent.",
1895
+ "- send_message: communicate through the EvoDev broker.",
1896
+ "- stop_role: stop a role agent when allowed.",
1897
+ "If Teams MCP is unavailable, fall back to the EvoDev CLI from this repository:",
1898
+ "- evodev team spawn --role <roleId>",
1899
+ "- evodev team send --to <roleId> --message <text>",
1900
+ "- evodev team status",
1901
+ "Teams MCP defaults are inherited from the environment:",
1902
+ `EVODEV_TEAM_RUN_ID=${input.runId}`,
1903
+ `EVODEV_TEAM_ROLE_ID=${input.role.roleId}`,
1904
+ "When calling Teams MCP tools, let the MCP server-bound environment identify this run and role.",
1905
+ "Do not operate tmux directly for role lifecycle; let EvoDev create and track panes.",
1906
+ "Any server-bound role may request role lifecycle changes; EvoDev records and tracks panes but does not use role policy to stop execution.",
1907
+ input.role.roleId === "main"
1908
+ ? "As main, when the user approves team execution, pick the needed role ids, call spawn_role or evodev team spawn for each role, verify with list_agents/status, then send role-specific assignments."
1909
+ : "As a non-main role, prefer coordinating with main through send_message, but lifecycle tools remain advisory and non-gating.",
1910
+ input.role.nativeAgent === null
1911
+ ? "No native Code Agent agent is bound to this role."
1912
+ : `Use the bound native Code Agent agent name '${input.role.nativeAgent.agentName}' as role context when the runtime supports named agents; EvoDev does not load or transform the native agent file.`,
1913
+ "Cross-agent messages are copied to main.",
1914
+ "",
1915
+ input.role.prompt,
1916
+ ].join("\n");
1917
+ }
1918
+
1919
+ function formatDeliveredTeamMessage(message: TeamMessageRecord, cc: boolean): string {
1920
+ const header = cc ? "[EvoDev team cc]" : "[EvoDev team message]";
1921
+ const footer = cc ? "[/EvoDev team cc]" : "[/EvoDev team message]";
1922
+ return [
1923
+ header,
1924
+ `from: ${message.fromRoleId}`,
1925
+ `to: ${message.toRoleId}`,
1926
+ `type: ${message.type}`,
1927
+ `messageId: ${message.messageId}`,
1928
+ cc ? "" : `cc: ${message.ccRoleIds.join(",") || "none"}`,
1929
+ "",
1930
+ message.body,
1931
+ footer,
1932
+ ]
1933
+ .filter((line) => line !== "")
1934
+ .join("\n");
1935
+ }
1936
+
1937
+ function buildAgentShellCommand(
1938
+ role: ResolvedTeamRole,
1939
+ startupPrompt: string,
1940
+ context: {
1941
+ runId: string;
1942
+ repoRoot: string;
1943
+ runtimeCommands?: TmuxRuntimeAdapterOptions["runtimeCommands"];
1944
+ },
1945
+ ): string {
1946
+ const args =
1947
+ role.runtime === "codex"
1948
+ ? buildCodexArgs(role, startupPrompt)
1949
+ : buildClaudeArgs(role, startupPrompt);
1950
+ const env = {
1951
+ EVODEV_TEAM_RUN_ID: context.runId,
1952
+ EVODEV_TEAM_ROLE_ID: role.roleId,
1953
+ EVODEV_TEAM_REPO_ROOT: context.repoRoot,
1954
+ };
1955
+ return buildAgentCommand(resolveRuntimeCommand(role.runtime, context.runtimeCommands), args, env);
1956
+ }
1957
+
1958
+ function buildAgentResumeShellCommand(
1959
+ role: ResolvedTeamRole,
1960
+ startupPrompt: string,
1961
+ context: {
1962
+ runId: string;
1963
+ repoRoot: string;
1964
+ mode: Exclude<TeamRuntimeAgentRecoveryMode, { type: "fresh" }>;
1965
+ runtimeCommands?: TmuxRuntimeAdapterOptions["runtimeCommands"];
1966
+ },
1967
+ ): string {
1968
+ const args =
1969
+ role.runtime === "codex"
1970
+ ? buildCodexResumeArgs(role, startupPrompt, context.mode)
1971
+ : buildClaudeResumeArgs(role, context.mode);
1972
+ const env = {
1973
+ EVODEV_TEAM_RUN_ID: context.runId,
1974
+ EVODEV_TEAM_ROLE_ID: role.roleId,
1975
+ EVODEV_TEAM_REPO_ROOT: context.repoRoot,
1976
+ EVODEV_TEAM_RECOVERY: "1",
1977
+ };
1978
+ return buildAgentCommand(resolveRuntimeCommand(role.runtime, context.runtimeCommands), args, env);
1979
+ }
1980
+
1981
+ function buildAgentCommand(
1982
+ runtimeCommand: string,
1983
+ args: string[],
1984
+ env: Record<string, string>,
1985
+ ): string {
1986
+ const envEntries = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`);
1987
+ const commandParts = [runtimeCommand, ...args].map(shellQuote);
1988
+ return [...envEntries, ...commandParts].join(" ");
1989
+ }
1990
+
1991
+ function resolveRuntimeCommand(
1992
+ runtime: TeamAgentRuntime,
1993
+ runtimeCommands: TmuxRuntimeAdapterOptions["runtimeCommands"],
1994
+ ): string {
1995
+ return runtimeCommands?.[runtime] ?? runtime;
1996
+ }
1997
+
1998
+ function buildCodexArgs(role: ResolvedTeamRole, startupPrompt: string): string[] {
1999
+ const args = ["--no-alt-screen"];
2000
+ if (role.model !== null) args.push("--model", role.model);
2001
+ args.push(startupPrompt);
2002
+ return args;
2003
+ }
2004
+
2005
+ function buildCodexResumeArgs(
2006
+ role: ResolvedTeamRole,
2007
+ startupPrompt: string,
2008
+ mode: Exclude<TeamRuntimeAgentRecoveryMode, { type: "fresh" }>,
2009
+ ): string[] {
2010
+ const args = ["--no-alt-screen"];
2011
+ if (role.model !== null) args.push("--model", role.model);
2012
+ args.push("resume");
2013
+ if (mode.type === "resume") {
2014
+ args.push(mode.sessionId);
2015
+ } else {
2016
+ args.push("--last");
2017
+ }
2018
+ args.push(startupPrompt);
2019
+ return args;
2020
+ }
2021
+
2022
+ function buildClaudeArgs(role: ResolvedTeamRole, startupPrompt: string): string[] {
2023
+ const args = [];
2024
+ if (role.model !== null) args.push("--model", role.model);
2025
+ if (role.thinkingLevel !== null) args.push("--effort", role.thinkingLevel);
2026
+ args.push(startupPrompt);
2027
+ return args;
2028
+ }
2029
+
2030
+ function buildClaudeResumeArgs(
2031
+ role: ResolvedTeamRole,
2032
+ mode: Exclude<TeamRuntimeAgentRecoveryMode, { type: "fresh" }>,
2033
+ ): string[] {
2034
+ const args = [];
2035
+ if (role.model !== null) args.push("--model", role.model);
2036
+ if (role.thinkingLevel !== null) args.push("--effort", role.thinkingLevel);
2037
+ if (mode.type === "resume") {
2038
+ args.push("--resume", mode.sessionId);
2039
+ } else {
2040
+ args.push("--continue");
2041
+ }
2042
+ return args;
2043
+ }
2044
+
2045
+ function recoveredHandle(
2046
+ input: TeamRuntimeRecoverAgentInput,
2047
+ window: string,
2048
+ paneId: string,
2049
+ ): TeamRuntimeAgentHandle {
2050
+ return {
2051
+ session: input.sessionName,
2052
+ window,
2053
+ paneId,
2054
+ nativeSessionId: input.mode.type === "resume" ? input.mode.sessionId : null,
2055
+ };
2056
+ }
2057
+
2058
+ async function readSettingsOrDefault(homeDir?: string) {
2059
+ const settingsPath = resolveEvoDevPaths(homeDir).settingsPath;
2060
+ try {
2061
+ return parseSettings(JSON.parse(await readFile(settingsPath, "utf8")));
2062
+ } catch {
2063
+ return createDefaultSettings();
2064
+ }
2065
+ }
2066
+
2067
+ async function resolveRequestedRunId(
2068
+ store: TeamRunStore,
2069
+ requestedRunId?: string,
2070
+ ): Promise<string> {
2071
+ if (requestedRunId !== undefined) return requestedRunId;
2072
+ const latest = await store.readLatestRunId();
2073
+ if (latest === null) throw new Error("No EvoDev team run exists.");
2074
+ return latest;
2075
+ }
2076
+
2077
+ function createBuiltInRole(roleId: string, runtime: TeamAgentRuntime): unknown {
2078
+ const builtin = BUILT_IN_ROLE_PROMPTS[roleId];
2079
+ return {
2080
+ version: 1,
2081
+ roleId,
2082
+ roleName: builtin?.roleName ?? defaultRoleName(roleId),
2083
+ description: builtin?.description ?? `EvoDev ${roleId} role agent.`,
2084
+ runtime,
2085
+ prompt: builtin?.prompt ?? defaultRolePrompt(roleId),
2086
+ };
2087
+ }
2088
+
2089
+ function parseRolePermissions(value: unknown, main: boolean): TeamRolePermissions {
2090
+ const input = isRecord(value) ? value : {};
2091
+ return {
2092
+ writeMode: parseWriteMode(input.writeMode, main ? "repo-write" : "read-only"),
2093
+ canUseTeamsMcp: optionalBoolean(input.canUseTeamsMcp) ?? true,
2094
+ canSpawnAgents: optionalBoolean(input.canSpawnAgents) ?? main,
2095
+ canStopAgents: optionalBoolean(input.canStopAgents) ?? main,
2096
+ };
2097
+ }
2098
+
2099
+ function parseRolePolicy(value: unknown, recordTranscript: boolean): TeamRolePolicy {
2100
+ const input = isRecord(value) ? value : {};
2101
+ return {
2102
+ roleInstancePolicy: "single-per-role",
2103
+ recordTranscript: optionalBoolean(input.recordTranscript) ?? recordTranscript,
2104
+ };
2105
+ }
2106
+
2107
+ function parseTeamRunRecord(value: unknown): TeamRunRecord {
2108
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team run record.");
2109
+ const run = value as unknown as TeamRunRecord;
2110
+ assertSafeId(run.runId, "runId");
2111
+ assertString(run.repoRoot, "run.repoRoot");
2112
+ if (!["running", "stopped", "failed"].includes(run.status))
2113
+ throw new Error("Invalid run status.");
2114
+ return run;
2115
+ }
2116
+
2117
+ function parseTeamAgentRecord(value: unknown): TeamAgentRecord {
2118
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team agent record.");
2119
+ const agent = value as unknown as TeamAgentRecord;
2120
+ assertSafeId(agent.agentId, "agentId");
2121
+ assertSafeId(agent.roleId, "roleId");
2122
+ if (!["running", "recovering", "recreated", "stopped", "failed"].includes(agent.status)) {
2123
+ throw new Error("Invalid agent status.");
2124
+ }
2125
+ const nativeSessionValue = isRecord(value.nativeSession) ? value.nativeSession : {};
2126
+ return {
2127
+ ...agent,
2128
+ nativeSession: {
2129
+ sessionId: optionalString(nativeSessionValue.sessionId) ?? null,
2130
+ capturedAt: optionalString(nativeSessionValue.capturedAt) ?? null,
2131
+ },
2132
+ };
2133
+ }
2134
+
2135
+ function parseTeamMessageRecord(value: unknown): TeamMessageRecord {
2136
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team message record.");
2137
+ const message = value as unknown as TeamMessageRecord;
2138
+ assertSafeId(message.messageId, "messageId");
2139
+ assertSafeId(message.runId, "runId");
2140
+ assertSafeId(message.toRoleId, "toRoleId");
2141
+ assertString(message.body, "message.body");
2142
+ return message;
2143
+ }
2144
+
2145
+ function parseTeamEventRecord(value: unknown): TeamEventRecord {
2146
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team event record.");
2147
+ const event = value as unknown as TeamEventRecord;
2148
+ assertSafeId(event.eventId, "eventId");
2149
+ assertSafeId(event.runId, "runId");
2150
+ assertString(event.summary, "event.summary");
2151
+ return event;
2152
+ }
2153
+
2154
+ function parseRuntime(value: unknown, fallback: TeamAgentRuntime): TeamAgentRuntime {
2155
+ if (value === undefined || value === null) return fallback;
2156
+ if (value === "codex" || value === "claude") return value;
2157
+ throw new Error("Role runtime must be codex or claude.");
2158
+ }
2159
+
2160
+ function parseWriteMode(value: unknown, fallback: TeamWriteMode): TeamWriteMode {
2161
+ if (value === undefined || value === null) return fallback;
2162
+ if (["read-only", "repo-write", "worktree-write", "disabled"].includes(String(value))) {
2163
+ return value as TeamWriteMode;
2164
+ }
2165
+ throw new Error("Role writeMode is invalid.");
2166
+ }
2167
+
2168
+ function isActiveTeamAgentStatus(status: TeamAgentStatus): boolean {
2169
+ return status === "running" || status === "recovering" || status === "recreated";
2170
+ }
2171
+
2172
+ function createRunId(repoRoot: string, now: Date): string {
2173
+ return `run-${safeSlug(basename(repoRoot) || "repo")}-${timestampId(now)}`;
2174
+ }
2175
+
2176
+ function createAgentId(roleId: string, now: string): string {
2177
+ return `agent-${roleId}-${timestampId(new Date(now))}`;
2178
+ }
2179
+
2180
+ function createMessageId(now: string): string {
2181
+ return `msg-${timestampId(new Date(now))}`;
2182
+ }
2183
+
2184
+ function createEventId(now: string): string {
2185
+ return `evt-${timestampId(new Date(now))}`;
2186
+ }
2187
+
2188
+ function createTmuxSessionName(repoRoot: string, runId: string): string {
2189
+ return `evodev-${safeSlug(basename(repoRoot) || "repo")}-${runId}`.slice(0, 80);
2190
+ }
2191
+
2192
+ function createTeamEvent(
2193
+ runId: string,
2194
+ type: TeamEventRecord["type"],
2195
+ summary: string,
2196
+ now: string,
2197
+ refs: Partial<Pick<TeamEventRecord, "roleId" | "agentId" | "messageId">> = {},
2198
+ ): TeamEventRecord {
2199
+ return { version: 1, eventId: createEventId(now), runId, type, summary, createdAt: now, ...refs };
2200
+ }
2201
+
2202
+ function timestampId(date: Date): string {
2203
+ return date
2204
+ .toISOString()
2205
+ .replace(/[-:.TZ]/g, "")
2206
+ .slice(0, 17);
2207
+ }
2208
+
2209
+ function safeSlug(value: string): string {
2210
+ const slug = value.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
2211
+ return slug || "repo";
2212
+ }
2213
+
2214
+ function sanitizeWindowName(value: string): string {
2215
+ return safeSlug(value).slice(0, 30) || "agent";
2216
+ }
2217
+
2218
+ function defaultRoleName(roleId: string): string {
2219
+ return roleId
2220
+ .split(/[-_.]/)
2221
+ .filter(Boolean)
2222
+ .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
2223
+ .join(" ");
2224
+ }
2225
+
2226
+ function defaultRolePrompt(roleId: string): string {
2227
+ return `You are the ${roleId} role agent for this EvoDev team run. Stay inside your role and communicate through Teams MCP.`;
2228
+ }
2229
+
2230
+ async function readRoleCandidate(
2231
+ path: string,
2232
+ source: TeamRoleDefinition["source"],
2233
+ ): Promise<{ value: unknown; path: string; source: TeamRoleDefinition["source"] } | null> {
2234
+ try {
2235
+ const raw = await readFile(path, "utf8");
2236
+ return { value: JSON.parse(raw), path, source };
2237
+ } catch (error) {
2238
+ if (isNotFoundError(error)) return null;
2239
+ throw new Error(`Cannot read team role definition ${path}: ${describeError(error)}`);
2240
+ }
2241
+ }
2242
+
2243
+ async function resolveTeamRoleNativeAgentBinding(input: {
2244
+ homeDir?: string;
2245
+ repoRoot: string;
2246
+ roleId: string;
2247
+ }): Promise<TeamNativeAgentBinding | null> {
2248
+ const bindings = await listTeamRoleBindings({
2249
+ homeDir: input.homeDir,
2250
+ repoRoot: input.repoRoot,
2251
+ });
2252
+ return bindings.find((binding) => binding.roleId === input.roleId) ?? null;
2253
+ }
2254
+
2255
+ function resolveGlobalTeamBindingPath(homeDir?: string): string {
2256
+ return join(resolveEvoDevPaths(homeDir).rootDir, "team", "roles.json");
2257
+ }
2258
+
2259
+ function resolveProjectTeamBindingPath(homeDir: string | undefined, projectKey: string): string {
2260
+ return join(resolveEvoDevPaths(homeDir).rootDir, "projects", safeSlug(projectKey), "team.json");
2261
+ }
2262
+
2263
+ function resolveTeamBindingWritePath(input: {
2264
+ homeDir?: string;
2265
+ repoRoot?: string;
2266
+ scope: "global" | "project";
2267
+ }): { path: string; projectKey: string | null } {
2268
+ if (input.scope === "global") {
2269
+ return { path: resolveGlobalTeamBindingPath(input.homeDir), projectKey: null };
2270
+ }
2271
+ if (input.repoRoot === undefined) {
2272
+ throw new Error("Project-scoped team config requires repoRoot.");
2273
+ }
2274
+ const projectKey = resolveProjectLogKey(
2275
+ resolveEvoDevPaths(input.homeDir).homeDir,
2276
+ input.repoRoot,
2277
+ );
2278
+ return {
2279
+ path: resolveProjectTeamBindingPath(input.homeDir, projectKey),
2280
+ projectKey,
2281
+ };
2282
+ }
2283
+
2284
+ async function readTeamBindingConfig(path: string): Promise<TeamRoleBindingConfig> {
2285
+ try {
2286
+ const raw = await readFile(path, "utf8");
2287
+ return parseTeamBindingConfig(JSON.parse(raw));
2288
+ } catch (error) {
2289
+ if (isNotFoundError(error)) {
2290
+ return { version: 1, updatedAt: "never", roles: [] };
2291
+ }
2292
+ throw error;
2293
+ }
2294
+ }
2295
+
2296
+ function parseTeamBindingConfig(value: unknown): TeamRoleBindingConfig {
2297
+ if (!isRecord(value) || value.version !== 1) throw new Error("Invalid team binding config.");
2298
+ const updatedAt = optionalString(value.updatedAt) ?? "unknown";
2299
+ const rolesValue = Array.isArray(value.roles) ? value.roles : [];
2300
+ return {
2301
+ version: 1,
2302
+ updatedAt,
2303
+ roles: rolesValue.map(parseTeamBindingRecord),
2304
+ };
2305
+ }
2306
+
2307
+ function parseTeamBindingRecord(value: unknown): TeamRoleBindingRecord {
2308
+ if (!isRecord(value) || value.version !== 1) {
2309
+ throw new Error("Invalid team binding record.");
2310
+ }
2311
+ const roleId = optionalString(value.roleId);
2312
+ const target = parseRuntime(value.target, "codex");
2313
+ const agentName = optionalString(value.agentName);
2314
+ const updatedAt = optionalString(value.updatedAt) ?? "unknown";
2315
+ if (roleId === undefined) throw new Error("Team binding roleId is required.");
2316
+ if (agentName === undefined) throw new Error("Team binding agentName is required.");
2317
+ assertSafeId(roleId, "roleId");
2318
+ assertSafeId(agentName, "agentName");
2319
+ return { version: 1, roleId, target, agentName, updatedAt };
2320
+ }
2321
+
2322
+ function bindingRecordToNative(
2323
+ record: TeamRoleBindingRecord,
2324
+ scope: "global" | "project",
2325
+ projectKey: string | null,
2326
+ ): TeamNativeAgentBinding {
2327
+ return {
2328
+ roleId: record.roleId,
2329
+ target: record.target,
2330
+ agentName: record.agentName,
2331
+ scope,
2332
+ projectKey,
2333
+ updatedAt: record.updatedAt,
2334
+ };
2335
+ }
2336
+
2337
+ function mergeTeamRoleBindings(
2338
+ global: TeamNativeAgentBinding[],
2339
+ project: TeamNativeAgentBinding[],
2340
+ ): TeamNativeAgentBinding[] {
2341
+ const byRole = new Map(global.map((binding) => [binding.roleId, binding]));
2342
+ for (const binding of project) byRole.set(binding.roleId, binding);
2343
+ return [...byRole.values()].sort((left, right) => left.roleId.localeCompare(right.roleId));
2344
+ }
2345
+
2346
+ function isNotFoundError(error: unknown): boolean {
2347
+ return (
2348
+ typeof error === "object" &&
2349
+ error !== null &&
2350
+ "code" in error &&
2351
+ (error as { code?: unknown }).code === "ENOENT"
2352
+ );
2353
+ }
2354
+
2355
+ async function writeJson(path: string, value: unknown): Promise<void> {
2356
+ await mkdir(dirname(path), { recursive: true });
2357
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
2358
+ }
2359
+
2360
+ async function appendJsonLine(path: string, value: unknown): Promise<void> {
2361
+ await mkdir(dirname(path), { recursive: true });
2362
+ await appendFile(path, `${JSON.stringify(value)}\n`, "utf8");
2363
+ }
2364
+
2365
+ function shellQuote(value: string): string {
2366
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2367
+ }
2368
+
2369
+ function assertSafeId(value: string, path: string): void {
2370
+ if (!SAFE_ROLE_ID_PATTERN.test(value)) throw new Error(`Invalid ${path}; unsafe id.`);
2371
+ }
2372
+
2373
+ function assertString(value: unknown, path: string): asserts value is string {
2374
+ if (typeof value !== "string" || value.length === 0) {
2375
+ throw new Error(`Invalid ${path}; expected non-empty string.`);
2376
+ }
2377
+ }
2378
+
2379
+ function optionalString(value: unknown): string | undefined {
2380
+ return typeof value === "string" && value.length > 0 ? value : undefined;
2381
+ }
2382
+
2383
+ function optionalNullableString(value: unknown): string | null | undefined {
2384
+ if (value === null) return null;
2385
+ return optionalString(value);
2386
+ }
2387
+
2388
+ function optionalBoolean(value: unknown): boolean | undefined {
2389
+ return typeof value === "boolean" ? value : undefined;
2390
+ }
2391
+
2392
+ function isRecord(value: unknown): value is Record<string, unknown> {
2393
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2394
+ }
2395
+
2396
+ function describeError(error: unknown): string {
2397
+ return error instanceof Error ? error.message : String(error);
2398
+ }