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

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