heyio 0.42.0 → 1.0.0

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 (100) hide show
  1. package/README.md +40 -52
  2. package/dist/api/auth.js +35 -38
  3. package/dist/api/server.js +157 -1139
  4. package/dist/config.js +49 -32
  5. package/dist/copilot/agents.js +72 -1055
  6. package/dist/copilot/client.js +6 -17
  7. package/dist/copilot/io-scheduler.js +55 -139
  8. package/dist/copilot/model-router.js +100 -72
  9. package/dist/copilot/orchestrator.js +91 -515
  10. package/dist/copilot/scheduler.js +67 -189
  11. package/dist/copilot/skills.js +41 -366
  12. package/dist/copilot/system-message.js +40 -200
  13. package/dist/copilot/tools.js +191 -2042
  14. package/dist/daemon.js +54 -201
  15. package/dist/index.js +15 -133
  16. package/dist/mcp/config.js +23 -31
  17. package/dist/mcp/index.js +2 -3
  18. package/dist/mcp/registry.js +33 -88
  19. package/dist/notify.js +18 -100
  20. package/dist/paths.js +13 -24
  21. package/dist/setup.js +35 -0
  22. package/dist/store/db.js +111 -297
  23. package/dist/store/feed.js +29 -97
  24. package/dist/store/instances.js +56 -121
  25. package/dist/store/schedules.js +21 -73
  26. package/dist/store/squads.js +35 -186
  27. package/dist/store/tasks.js +25 -168
  28. package/dist/telegram/bot.js +20 -312
  29. package/dist/telegram/handlers.js +39 -3
  30. package/dist/watchdog.js +31 -45
  31. package/dist/wiki/fs.js +38 -155
  32. package/dist/wiki/search.js +31 -44
  33. package/package.json +5 -8
  34. package/web-dist/assets/ChatView-EFFiln1H.js +11 -0
  35. package/web-dist/assets/FeedView-bN4NMOL7.js +6 -0
  36. package/web-dist/assets/LoginView-CNtasq3n.js +1 -0
  37. package/web-dist/assets/McpView-C2CHiwsi.js +1 -0
  38. package/web-dist/assets/SchedulesView-CyilLban.js +1 -0
  39. package/web-dist/assets/SettingsView-1wLXKEF4.js +1 -0
  40. package/web-dist/assets/SkillsView-BLsD-0u0.js +1 -0
  41. package/web-dist/assets/SquadDetailView-CsCw2ZLp.js +21 -0
  42. package/web-dist/assets/SquadsView-DQ3vFlyO.js +6 -0
  43. package/web-dist/assets/WikiView-19M3oqnq.js +21 -0
  44. package/web-dist/assets/api-WGvTsXaE.js +1 -0
  45. package/web-dist/assets/index-D7M5O-_l.css +1 -0
  46. package/web-dist/assets/index-DZOS9syn.js +95 -0
  47. package/web-dist/assets/plus-BOvyX1BC.js +6 -0
  48. package/web-dist/assets/trash-2-DHoetkC4.js +6 -0
  49. package/web-dist/favicon.svg +4 -1
  50. package/web-dist/index.html +7 -10
  51. package/dist/api/logout.test.js +0 -129
  52. package/dist/api/mcp.test.js +0 -285
  53. package/dist/api/wiki.test.js +0 -283
  54. package/dist/auth/session-logic.js +0 -79
  55. package/dist/auth/session-logic.test.js +0 -201
  56. package/dist/copilot/auto-complete-instance.test.js +0 -104
  57. package/dist/copilot/cron.js +0 -136
  58. package/dist/copilot/event-summary.js +0 -286
  59. package/dist/copilot/instance-deactivate.test.js +0 -119
  60. package/dist/copilot/model-router.test.js +0 -71
  61. package/dist/copilot/review-backfill.js +0 -57
  62. package/dist/copilot/session-timeout.js +0 -112
  63. package/dist/copilot/session-timeout.test.js +0 -372
  64. package/dist/copilot/skills.test.js +0 -55
  65. package/dist/copilot/universes.js +0 -469
  66. package/dist/instance-watchdog.js +0 -104
  67. package/dist/instance-watchdog.test.js +0 -183
  68. package/dist/mcp/client.js +0 -109
  69. package/dist/mcp/client.test.js +0 -99
  70. package/dist/mcp/config.test.js +0 -49
  71. package/dist/mcp/registry.test.js +0 -79
  72. package/dist/notify.test.js +0 -232
  73. package/dist/store/feed.test.js +0 -279
  74. package/dist/store/instances.test.js +0 -310
  75. package/dist/store/io-schedules.js +0 -63
  76. package/dist/store/notifications.js +0 -79
  77. package/dist/store/notifications.test.js +0 -197
  78. package/dist/store/schedule-runs.js +0 -46
  79. package/dist/store/squads.test.js +0 -405
  80. package/dist/store/tasks.test.js +0 -150
  81. package/dist/store/worktrees.js +0 -83
  82. package/dist/tui/index.js +0 -286
  83. package/dist/update.js +0 -81
  84. package/dist/watchdog.test.js +0 -83
  85. package/dist/wiki/wiki-squad.test.js +0 -54
  86. package/web-dist/assets/AgentActivityView-CedxxE6K.js +0 -1
  87. package/web-dist/assets/ChatView-DMkYQo_V.js +0 -4
  88. package/web-dist/assets/FeedView-BH4q-31V.js +0 -1
  89. package/web-dist/assets/InboxView-BVwVP4EW.js +0 -1
  90. package/web-dist/assets/LoginView-DRPDhnwu.js +0 -1
  91. package/web-dist/assets/McpView-D8yWz-lq.js +0 -1
  92. package/web-dist/assets/SchedulesView-BzzyncGF.js +0 -1
  93. package/web-dist/assets/SettingsTabs.vue_vue_type_script_setup_true_lang-oW3ySu7Y.js +0 -1
  94. package/web-dist/assets/SkillsView-oxpYuhx7.js +0 -1
  95. package/web-dist/assets/SquadsView-CaKUIKlq.js +0 -1
  96. package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-8U15Qp_Q.js +0 -1
  97. package/web-dist/assets/WikiView-C5jXUlfW.js +0 -1
  98. package/web-dist/assets/index-BrWzNw-N.css +0 -10
  99. package/web-dist/assets/index-f67odrrt.js +0 -81
  100. package/web-dist/icons.svg +0 -24
@@ -1,1066 +1,83 @@
1
- import { randomUUID } from "crypto";
2
- import { EventEmitter } from "events";
3
- import { execSync } from "child_process";
4
- import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, } from "fs";
5
- import { join, dirname, resolve } from "path";
6
- import { homedir } from "os";
7
- import { defineTool, approveAll } from "@github/copilot-sdk";
8
- import { z } from "zod";
1
+ import { approveAll } from "@github/copilot-sdk";
9
2
  import { getClient } from "./client.js";
10
- import { getSkillDirectories } from "./skills.js";
11
- import { sendWithIdleTimeout } from "./session-timeout.js";
12
- import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
13
- import { getSquad, updateSquadSession, updateSquadStatus, getDecisions, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
14
- import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
15
- import { getInstance, updateInstanceStatus, mergeInstanceDecisions, } from "../store/instances.js";
16
- import { removeWorktree } from "../store/worktrees.js";
17
- import { createFeedEntry } from "../store/feed.js";
18
- import { SESSIONS_DIR } from "../paths.js";
19
- import { getUniverse } from "./universes.js";
20
- import { readSquadWikiPages } from "../wiki/fs.js";
21
- // Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
22
- const agentSessions = new Map();
23
- const agentSessionModels = new Map();
24
- function agentSessionKey(squadSlug, characterName) {
25
- return characterName ? `${squadSlug}:${characterName}` : squadSlug;
26
- }
27
- /**
28
- * Drop the in-memory cached Copilot session (and model) for an agent so the
29
- * next task creates a fresh one. Pairs with `clearAgentSession` in the
30
- * store, which nulls the persisted copilot_session_id.
31
- */
32
- export function clearAgentInMemorySession(squadSlug, characterName) {
33
- const key = agentSessionKey(squadSlug, characterName);
34
- agentSessions.delete(key);
35
- agentSessionModels.delete(key);
36
- }
37
- export function getAgentInfo() {
38
- const activeTasks = getActiveTasks();
39
- const tasksByAgent = new Map();
40
- const taskIdsByAgent = new Map();
41
- for (const task of activeTasks) {
42
- tasksByAgent.set(task.agent_slug, task.description);
43
- taskIdsByAgent.set(task.agent_slug, task.task_id);
44
- }
45
- const agents = [];
46
- const seenSquads = new Set();
47
- // Collect info from squad agents (named agents)
48
- for (const [key, _session] of agentSessions) {
49
- const parts = key.split(":");
50
- const squadSlug = parts[0];
51
- const characterName = parts[1];
52
- seenSquads.add(squadSlug);
53
- const squad = getSquad(squadSlug);
54
- if (characterName) {
55
- const agent = getSquadAgent(squadSlug, characterName);
56
- const currentTask = tasksByAgent.get(key) ?? tasksByAgent.get(squadSlug);
57
- const currentTaskId = taskIdsByAgent.get(key) ?? taskIdsByAgent.get(squadSlug);
58
- agents.push({
59
- slug: squadSlug,
60
- name: agent ? `${agent.character_name} (${agent.role_title})` : characterName,
61
- characterName,
62
- roleTitle: agent?.role_title,
63
- universe: squad?.universe ?? undefined,
64
- status: agent?.status === "working" ? "working" : currentTask ? "working" : "idle",
65
- currentTask,
66
- currentTaskId,
67
- model: agentSessionModels.get(key),
68
- });
69
- }
70
- else {
71
- // Legacy generic agent
72
- const currentTask = tasksByAgent.get(squadSlug);
73
- const currentTaskId = taskIdsByAgent.get(squadSlug);
74
- agents.push({
75
- slug: squadSlug,
76
- name: squad?.name ?? squadSlug,
77
- status: currentTask ? "working" : squad?.status === "error" ? "error" : "idle",
78
- currentTask,
79
- currentTaskId,
80
- model: agentSessionModels.get(key),
81
- });
82
- }
83
- }
84
- return agents;
85
- }
86
- const STREAM_EVENT_TYPES = new Set([
87
- "assistant.turn_start",
88
- "assistant.intent",
89
- "assistant.reasoning",
90
- "assistant.reasoning_delta",
91
- "assistant.message_delta",
92
- "assistant.message",
93
- "assistant.turn_end",
94
- "tool.execution_start",
95
- "tool.execution_progress",
96
- "tool.execution_partial_result",
97
- "tool.execution_complete",
98
- "session.error",
99
- "session.warning",
100
- ]);
101
- const MAX_TASK_EVENTS = 1000;
102
- const taskEventBuffers = new Map();
103
- const taskEventEmitter = new EventEmitter();
104
- taskEventEmitter.setMaxListeners(0);
105
- function recordTaskEvent(taskId, ev) {
106
- let buf = taskEventBuffers.get(taskId);
107
- if (!buf) {
108
- buf = [];
109
- taskEventBuffers.set(taskId, buf);
110
- }
111
- buf.push(ev);
112
- if (buf.length > MAX_TASK_EVENTS)
113
- buf.splice(0, buf.length - MAX_TASK_EVENTS);
114
- taskEventEmitter.emit(taskId, ev);
115
- }
116
- export function getTaskEvents(taskId) {
117
- return taskEventBuffers.get(taskId) ?? [];
118
- }
119
- export function subscribeToTaskEvents(taskId, listener) {
120
- taskEventEmitter.on(taskId, listener);
121
- return () => taskEventEmitter.off(taskId, listener);
122
- }
123
- // ---------------------------------------------------------------------------
124
- // Task prompt envelope (issue #54)
125
- //
126
- // Before sending a task to an agent we prepend a short "Recent squad
127
- // decisions" preamble and append a tail that asks the agent to call
128
- // squad_log_decision if their work involved a non-trivial architectural
129
- // choice. This is the lowest-friction nudge we can give: agents see what
130
- // they're augmenting AND a reminder to capture institutional knowledge.
131
- // ---------------------------------------------------------------------------
132
- const RECENT_DECISIONS_LIMIT = 5;
133
- function buildTaskPromptEnvelope(squadSlug, task) {
134
- const recent = getDecisions(squadSlug, RECENT_DECISIONS_LIMIT);
135
- const preamble = recent.length === 0
136
- ? `## Recent squad decisions
137
- _(None recorded yet — be the first to log one with \`squad_log_decision\` if your work involves a real architectural choice.)_`
138
- : `## Recent squad decisions (last ${recent.length})
139
- You should treat these as load-bearing context. Reverse them only with a clear reason and a new \`squad_log_decision\` entry.
140
-
141
- ${recent
142
- .slice()
143
- .reverse()
144
- .map((d) => {
145
- const ctx = d.context ? ` — _${d.context}_` : "";
146
- return `- [${d.created_at}] **${d.decision}**${ctx}`;
147
- })
148
- .join("\n")}`;
149
- const tail = `## Capturing institutional knowledge
150
- When you finish this task, if your work involved a non-trivial architectural choice (a strategy, a tradeoff, an interface decision, a workaround with a clear reason), call \`squad_log_decision\` with **one sentence** summarizing the choice and **a short context** explaining why. Examples:
151
- - decision: "Use idle-reset timeout instead of wall-clock for agent tasks" / context: "Wall-clock killed 2/3 long-running tasks mid-progress (#42, #45)."
152
- - decision: "Veto power expanded to lead + QA + test engineers" / context: "Single-reviewer veto was too narrow when test engineer wasn't designated QA."
153
-
154
- If your work was a routine implementation that didn't make a real choice (e.g. small docs edit, mechanical refactor, one-line fix), skip the call — don't log noise.`;
155
- return `${preamble}
156
-
157
- ---
158
-
159
- ## Task
160
- ${task}
161
-
162
- ---
163
-
164
- ${tail}`;
165
- }
166
- /**
167
- * Auto-complete a squad instance after its task finishes successfully.
168
- * Merges decisions back to master, cleans up worktree, sends notification.
169
- */
170
- function autoCompleteInstance(instanceId) {
171
- try {
172
- const instance = getInstance(instanceId);
173
- if (!instance)
174
- return;
175
- if (instance.status === "done" || instance.status === "failed")
176
- return;
177
- updateInstanceStatus(instanceId, "merging");
178
- const merged = mergeInstanceDecisions(instanceId, instance.master_squad_slug);
179
- // Clean up worktree
180
- const projectPath = instance.worktree_path.replace(/\/\.io-worktrees\/.*$/, "");
181
- try {
182
- removeWorktree(projectPath, instance.worktree_path);
183
- }
184
- catch (err) {
185
- console.error(`[io] Failed to remove worktree for instance ${instanceId}:`, err);
186
- }
187
- updateInstanceStatus(instanceId, "done");
188
- createFeedEntry({
189
- type: "notification",
190
- title: `[${instance.master_squad_slug}] Instance auto-completed`,
191
- body: `Instance "${instanceId}" auto-completed after task finished. ${merged} decision(s) merged to master squad.`,
192
- source_type: "instance-auto-complete",
193
- });
194
- console.error(`[io] Instance "${instanceId}" auto-completed — ${merged} decisions merged`);
195
- }
196
- catch (err) {
197
- console.error(`[io] Error auto-completing instance ${instanceId}:`, err);
198
- }
199
- }
200
- export async function delegateToAgent(squadSlug, task, onComplete, targetAgent, instanceId) {
201
- const squad = getSquad(squadSlug);
202
- if (!squad) {
203
- throw new Error(`Squad not found: ${squadSlug}`);
204
- }
205
- // Determine which agent session to use
206
- let agent;
207
- if (targetAgent) {
208
- agent = getSquadAgent(squadSlug, targetAgent);
209
- if (!agent) {
210
- throw new Error(`Agent "${targetAgent}" not found in squad "${squadSlug}". Use squad_agents to list the roster.`);
211
- }
212
- }
213
- else {
214
- // Prefer the designated team lead if one exists; otherwise fall back to
215
- // the first idle agent (or just the first agent on the roster).
216
- const lead = getSquadLead(squadSlug);
217
- if (lead) {
218
- agent = lead;
219
- }
220
- else {
221
- const agents = listSquadAgents(squadSlug);
222
- if (agents.length > 0) {
223
- agent = agents.find((a) => a.status === "idle") ?? agents[0];
224
- }
225
- }
226
- }
227
- const agentKey = agent
228
- ? agentSessionKey(squadSlug, agent.character_name)
229
- : squadSlug;
230
- // Idempotency: if an identical task is already running on this agent_slug,
231
- // join the existing task instead of racing a second instance. (Issue #53)
232
- const normalizedTask = task.trim();
233
- const duplicate = getActiveTasks().find((t) => t.agent_slug === agentKey && t.description.trim() === normalizedTask);
234
- if (duplicate) {
235
- console.error(`[io] Dedup: task with identical description already running on ${agentKey} (taskId=${duplicate.task_id}); returning existing taskId.`);
236
- recordTaskEvent(duplicate.task_id, {
237
- ts: Date.now(),
238
- type: "task.dedup_joined",
239
- data: { agentKey, description: normalizedTask },
240
- });
241
- return duplicate.task_id;
242
- }
243
- const session = agent
244
- ? await getOrCreateAgentSession(squadSlug, agent, task)
245
- : await getOrCreateSession(squadSlug, task);
246
- const taskId = randomUUID();
247
- createTask(taskId, agentKey, task, undefined, instanceId);
248
- updateSquadStatus(squadSlug, "working");
249
- if (agent)
250
- updateAgentStatus(squadSlug, agent.character_name, "working");
251
- // Subscribe to the agent session's events for the duration of this task so
252
- // the web UI can preview the agent's "thread of consciousness" live.
253
- recordTaskEvent(taskId, {
254
- ts: Date.now(),
255
- type: "task.start",
256
- data: { taskId, agentKey, description: task },
257
- });
258
- const unsubscribe = session.on((event) => {
259
- if (!STREAM_EVENT_TYPES.has(event.type))
260
- return;
261
- recordTaskEvent(taskId, {
262
- ts: Date.now(),
263
- type: event.type,
264
- data: event.data ?? null,
265
- });
266
- });
267
- // Run the task in the background — return taskId immediately
268
- void (async () => {
269
- try {
270
- const envelopedTask = buildTaskPromptEnvelope(squadSlug, task);
271
- const sendResult = await sendWithIdleTimeout(session, envelopedTask, {
272
- // Reset on every progress event; only abort if the agent goes
273
- // genuinely silent for this long. 10 minutes covers the longest
274
- // realistic tool call (npm install, full build, large file edits)
275
- // while still catching truly stuck sessions. (Issue #53)
276
- idleMs: 10 * 60_000,
277
- // Absolute upper bound — 60 minutes. Anything longer is almost
278
- // certainly a runaway loop; cap it.
279
- hardCapMs: 60 * 60_000,
280
- onIdleTimeout: ({ lastEventType, idleMs }) => {
281
- console.error(`[io] Agent task ${taskId} idle for ${Math.round(idleMs / 1000)}s (last event: ${lastEventType ?? "none"}) — aborting session.`);
282
- },
283
- });
284
- if (sendResult.timedOut) {
285
- const partial = sendResult.content;
286
- recordTaskEvent(taskId, {
287
- ts: Date.now(),
288
- type: "task.timeout",
289
- data: {
290
- reason: sendResult.timeoutReason,
291
- lastEventType: sendResult.lastEventType,
292
- partial,
293
- },
294
- });
295
- const stamped = `[task timed out — ${sendResult.timeoutReason === "idle" ? "idle reset" : "hard cap"}; last event: ${sendResult.lastEventType ?? "none"}]\n\n${partial}`;
296
- failTask(taskId, stamped);
297
- updateSquadStatus(squadSlug, "idle");
298
- if (agent)
299
- updateAgentStatus(squadSlug, agent.character_name, "idle");
300
- onComplete(taskId, stamped);
301
- return;
302
- }
303
- const result = sendResult.content || "Task completed (no output)";
304
- completeTask(taskId, result);
305
- // Auto-complete the instance if this task was associated with one (#261)
306
- if (instanceId) {
307
- autoCompleteInstance(instanceId);
308
- }
309
- updateSquadStatus(squadSlug, "idle");
310
- if (agent)
311
- updateAgentStatus(squadSlug, agent.character_name, "idle");
312
- recordTaskEvent(taskId, { ts: Date.now(), type: "task.done", data: { result } });
313
- try {
314
- await runPeerReview(squadSlug, agent?.character_name ?? "", taskId, task, result);
315
- }
316
- catch (reviewErr) {
317
- console.error("[io] Peer review error:", reviewErr instanceof Error ? reviewErr.message : reviewErr);
318
- recordTaskEvent(taskId, {
319
- ts: Date.now(),
320
- type: "task.review_error",
321
- data: {
322
- error: reviewErr instanceof Error ? reviewErr.message : String(reviewErr),
323
- },
324
- });
325
- }
326
- onComplete(taskId, result);
327
- }
328
- catch (err) {
329
- const message = err instanceof Error ? err.message : String(err);
330
- failTask(taskId, message);
331
- updateSquadStatus(squadSlug, "error");
332
- if (agent)
333
- updateAgentStatus(squadSlug, agent.character_name, "error");
334
- recordTaskEvent(taskId, { ts: Date.now(), type: "task.failed", data: { error: message } });
335
- }
336
- finally {
337
- try {
338
- unsubscribe();
339
- }
340
- catch { /* ignore */ }
341
- }
342
- })();
343
- const agentLabel = agent
344
- ? `${agent.character_name} (${agent.role_title})`
345
- : `squad "${squadSlug}"`;
346
- return taskId;
347
- }
348
- export async function shutdownAgents() {
349
- for (const [key, session] of agentSessions) {
350
- try {
351
- await session.destroy();
352
- }
353
- catch {
354
- // best-effort cleanup
355
- }
356
- agentSessions.delete(key);
357
- agentSessionModels.delete(key);
358
- }
359
- }
360
- export function getActiveAgentTasks() {
361
- return getActiveTasks().map((t) => ({
362
- taskId: t.task_id,
363
- agentSlug: t.agent_slug,
364
- description: t.description,
365
- status: t.status,
366
- }));
367
- }
368
- // ---------------------------------------------------------------------------
369
- // Internal helpers
370
- // ---------------------------------------------------------------------------
371
- /**
372
- * Create or resume a Copilot session for a specific named agent.
373
- * Model is selected per-task: uses the higher of the agent's default tier
374
- * and the task's classified complexity. This means an agent never gets a
375
- * model worse than their baseline, but can be upgraded for complex tasks.
376
- */
377
- async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
378
- const key = agentSessionKey(squadSlug, agent.character_name);
379
- // Determine model: task complexity is sole determinant when task context exists;
380
- // stored model_tier is only a fallback for ad-hoc sessions without task context.
381
- const agentTier = agent.model_tier;
382
- const effectiveTier = taskDescription ? classifyComplexity(taskDescription) : agentTier;
383
- const model = getModelForTier(effectiveTier);
384
- // If we have a cached session, check if the model matches AND the agent
385
- // hasn't been left in an error state by a previous task. If either is off,
386
- // destroy and recreate. Reusing a session whose underlying SDK process has
387
- // been throwing is how Panthro got "stuck" in error after the issue #42
388
- // delegation timeout (issue #55).
389
- const existing = agentSessions.get(key);
390
- if (existing) {
391
- const fresh = getSquadAgent(squadSlug, agent.character_name);
392
- const persistedStatus = fresh?.status ?? agent.status;
393
- if (persistedStatus === "error") {
394
- console.error(`[io] Agent ${agent.character_name}: previous session ended in error — discarding cached session and recreating`);
395
- try {
396
- await existing.destroy();
397
- }
398
- catch { /* best-effort */ }
399
- agentSessions.delete(key);
400
- agentSessionModels.delete(key);
401
- }
402
- else {
403
- // Sessions don't expose their model, so track it separately
404
- const cachedModel = agentSessionModels.get(key);
405
- if (cachedModel === model)
406
- return existing;
407
- // Model changed — destroy old session for the upgraded model
408
- console.error(`[io] Agent ${agent.character_name}: upgrading model ${cachedModel} → ${model} for task complexity`);
409
- try {
410
- await existing.destroy();
411
- }
412
- catch { /* best-effort */ }
413
- agentSessions.delete(key);
414
- agentSessionModels.delete(key);
415
- }
416
- }
417
- const squad = getSquad(squadSlug);
418
- const client = await getClient();
419
- const decisions = getDecisionsSummary(squadSlug);
420
- const wikiPages = readSquadWikiPages(squadSlug);
421
- const wikiSection = wikiPages.length > 0
422
- ? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
423
- : "";
424
- console.error(`[io] Agent ${agent.character_name}: using model "${model}" (stored tier: ${agentTier}, effective: ${effectiveTier})`);
425
- const universeName = squad.universe
426
- ? getUniverse(squad.universe)?.name ?? squad.universe
427
- : "Unknown";
428
- const isLead = agent.is_lead === 1;
429
- const agentTools = buildAgentTools(squadSlug, isLead);
430
- let leadSection = "";
431
- if (isLead) {
432
- const teammates = listSquadAgents(squadSlug).filter((a) => a.character_name !== agent.character_name);
433
- const roster = teammates.length > 0
434
- ? teammates
435
- .map((t) => {
436
- const charter = t.charter
437
- ? t.charter.length > 200
438
- ? t.charter.slice(0, 200) + "…"
439
- : t.charter
440
- : "(no charter)";
441
- return `- **${t.character_name}** — ${t.role_title}: ${charter}`;
442
- })
443
- .join("\n")
444
- : "_(no other agents on this squad yet — ask IO to add some)_";
445
- leadSection = `
446
-
447
- ## Team Lead Role
448
- You are the team lead for this squad. **Your sole job is coordination — you do NOT write code, own any domain, or implement features yourself.** Every incoming task must be analyzed, decomposed, and assigned to the appropriate domain specialist via the \`delegate_to_teammate\` tool. The only work you perform directly is breaking tasks down, delegating, and synthesizing results.
449
-
450
- ### Fan-out planning (REQUIRED before any work begins)
451
- When a task arrives, BEFORE touching code or shell, you MUST:
452
-
453
- 1. **List every distinct work-area** the task touches (e.g. "API endpoint", "DB migration", "frontend component", "tests", "docs"). One bullet per area.
454
- 2. **Score each teammate's charter** against each area — for every area, name the teammate whose charter most closely matches and quote the keyword/phrase from their charter that justifies the assignment.
455
- 3. **Produce a fan-out plan** as a short markdown list: \`- <area> → <teammate> — <one-sentence subtask>\`.
456
- 4. **Delegate each subtask in the plan via \`delegate_to_teammate\`** — in parallel where the subtasks are independent. Do NOT shell, edit, or write code yourself between steps 1–3 and the first \`delegate_to_teammate\` call.
457
-
458
- ### When you may implement directly
459
- Only if **all** of the following are true:
460
- - The task is genuinely trivial (a one-line change, a typo fix, a single-file rename) AND fits no teammate's charter better than yours.
461
- - No teammate's charter covers the work-area at all.
462
- - A prior \`delegate_to_teammate\` attempt for this exact subtask failed twice with a clear, unrecoverable error.
463
-
464
- If you find yourself reaching for the shell or file_ops on a normal feature/bug task, **stop** — that's a signal you skipped the fan-out plan. Go back and delegate.
465
-
466
- ### Reviewing teammate output
467
- After every \`delegate_to_teammate\` call returns, read the result, decide whether it satisfies the subtask, and either accept it (move on to the next subtask) or send a follow-up \`delegate_to_teammate\` to the same teammate with the specific gap to address. Synthesize the final summary only after every subtask is accepted.
468
-
469
- ## Your Team
470
- ${roster}`;
471
- }
472
- const systemMessage = `You are ${agent.character_name}, a specialist agent on the "${squad.name}" project team (${universeName} universe).
473
-
474
- ## Your Identity
475
- - **Name**: ${agent.character_name}
476
- - **Role**: ${agent.role_title}
477
- - **Personality**: ${agent.personality ?? "Professional and focused."}
478
-
479
- ## Your Charter
480
- ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
481
-
482
- ## Project
483
- - **Path**: ${squad.project_path}
484
-
485
- ## Past Decisions
486
- ${decisions}${leadSection}${wikiSection}
487
-
488
- ## Repository Hygiene
489
- Before you make ANY code changes, you MUST sync your working copy with the remote default branch and work from a fresh feature branch. This prevents the merge conflicts the team hit on PRs like #45.
490
-
491
- 1. \`cd\` to the project path above.
492
- 2. \`git fetch origin\` — pick up everything that has merged since your last task.
493
- 3. \`git checkout main && git pull origin main\` — fast-forward your local main.
494
- 4. \`git checkout -b <your-handle>/<short-slug>\` — create a fresh branch from the updated main. Never commit directly to main, and never reuse a stale branch from a prior task.
495
- 5. Only THEN start editing files, running tools, or delegating subtasks.
496
-
497
- If the project's default branch is not \`main\` (e.g. \`master\`, \`develop\`), substitute it everywhere above. If you are not in a git repository, skip this section and proceed normally.
498
-
499
- ## Instructions
500
- You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
501
- Log important decisions with squad_log_decision so they persist.
502
- Stay in character — let your personality color your work style and communication, but always deliver quality results.`;
503
- const commonConfig = {
504
- model,
505
- configDir: SESSIONS_DIR,
506
- streaming: false,
507
- systemMessage: { content: systemMessage },
508
- tools: agentTools,
509
- skillDirectories: getSkillDirectories(),
510
- onPermissionRequest: approveAll,
511
- infiniteSessions: {
512
- enabled: true,
513
- backgroundCompactionThreshold: 0.8,
514
- bufferExhaustionThreshold: 0.95,
515
- },
516
- };
517
- let session;
518
- if (agent.copilot_session_id) {
519
- try {
520
- session = await client.resumeSession(agent.copilot_session_id, commonConfig);
521
- }
522
- catch {
523
- session = await client.createSession(commonConfig);
524
- }
525
- }
526
- else {
527
- session = await client.createSession(commonConfig);
528
- }
529
- updateAgentSession(squadSlug, agent.character_name, session.sessionId);
530
- agentSessions.set(key, session);
531
- agentSessionModels.set(key, model);
532
- return session;
533
- }
534
- /**
535
- * Legacy: create a generic squad session (for squads without named agents).
536
- */
537
- async function getOrCreateSession(squadSlug, taskDescription) {
538
- const existing = agentSessions.get(squadSlug);
539
- if (existing)
540
- return existing;
541
- const squad = getSquad(squadSlug);
3
+ import { getLeadForSquad, getAgentsForSquad, updateAgentStatus } from "../store/squads.js";
4
+ import { createTask, updateTaskStatus } from "../store/tasks.js";
5
+ import { touchInstanceActivity } from "../store/instances.js";
6
+ import { selectModel, classifyComplexity } from "./model-router.js";
7
+ import { postFeedItem } from "../store/feed.js";
8
+ export async function delegateTask(squadId, task, instanceId) {
9
+ const lead = getLeadForSquad(squadId);
10
+ if (!lead) {
11
+ throw new Error("Squad has no team lead. Add a lead agent first.");
12
+ }
13
+ const agents = getAgentsForSquad(squadId);
14
+ const taskRecord = createTask(squadId, task, instanceId, lead.id);
15
+ // Update lead status
16
+ updateAgentStatus(lead.id, "working");
17
+ // Touch instance activity if applicable
18
+ if (instanceId) {
19
+ touchInstanceActivity(instanceId);
20
+ }
21
+ // Select model based on task complexity
22
+ const tier = classifyComplexity(task);
23
+ const model = await selectModel(tier);
24
+ // Create ephemeral agent session for the lead
542
25
  const client = await getClient();
543
- const decisions = getDecisionsSummary(squadSlug);
544
- const wikiPages = readSquadWikiPages(squadSlug);
545
- const wikiSection = wikiPages.length > 0
546
- ? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
547
- : "";
548
- const agentTools = buildAgentTools(squadSlug);
549
- const model = getModelForTask(taskDescription ?? "", squad.model);
550
- const commonConfig = {
551
- model,
552
- configDir: SESSIONS_DIR,
553
- streaming: false,
554
- systemMessage: {
555
- content: `You are a specialist agent working on the "${squad.name}" project at ${squad.project_path}.
556
-
557
- ## Past Decisions
558
- ${decisions}
559
-
560
- ## Repository Hygiene
561
- Before you make ANY code changes, you MUST sync your working copy with the remote default branch and work from a fresh feature branch. This prevents the merge conflicts the team hit on PRs like #45.
562
-
563
- 1. \`cd\` to the project path above.
564
- 2. \`git fetch origin\` — pick up everything that has merged since your last task.
565
- 3. \`git checkout main && git pull origin main\` — fast-forward your local main.
566
- 4. \`git checkout -b <your-handle>/<short-slug>\` — create a fresh branch from the updated main. Never commit directly to main, and never reuse a stale branch from a prior task.
567
- 5. Only THEN start editing files, running tools, or delegating subtasks.
568
-
569
- If the project's default branch is not \`main\` (e.g. \`master\`, \`develop\`), substitute it everywhere above. If you are not in a git repository, skip this section and proceed normally.
570
-
571
- ## Your Role
572
- You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
573
- Log important decisions with squad_log_decision so they persist.`,
574
- },
575
- tools: agentTools,
576
- skillDirectories: getSkillDirectories(),
577
- onPermissionRequest: approveAll,
578
- infiniteSessions: {
579
- enabled: true,
580
- backgroundCompactionThreshold: 0.8,
581
- bufferExhaustionThreshold: 0.95,
582
- },
583
- };
584
- let session;
585
- // Try to resume an existing session if we have a saved session ID
586
- if (squad.copilot_session_id) {
587
- try {
588
- session = await client.resumeSession(squad.copilot_session_id, commonConfig);
589
- }
590
- catch {
591
- session = await client.createSession(commonConfig);
592
- }
593
- }
594
- else {
595
- session = await client.createSession(commonConfig);
596
- }
597
- updateSquadSession(squadSlug, session.sessionId);
598
- agentSessions.set(squadSlug, session);
599
- return session;
600
- }
601
- function buildAgentTools(squadSlug, isLead = false) {
602
- const shell = defineTool("shell", {
603
- description: "Run a shell command. Use for git, build tools, file operations, etc.",
604
- skipPermission: true,
605
- parameters: z.object({
606
- command: z.string().describe("The command to run"),
607
- timeout_secs: z
608
- .number()
609
- .optional()
610
- .describe("Timeout in seconds (default: 60)"),
611
- working_dir: z
612
- .string()
613
- .optional()
614
- .describe("Working directory for the command"),
615
- }),
616
- handler: async ({ command, timeout_secs, working_dir }) => {
617
- try {
618
- const result = execSync(command, {
619
- encoding: "utf-8",
620
- timeout: (timeout_secs ?? 60) * 1000,
621
- maxBuffer: 1024 * 1024,
622
- cwd: working_dir,
623
- env: { ...process.env, HOME: process.env.HOME || homedir() },
624
- });
625
- const output = result.trim();
626
- if (output.length > 8000) {
627
- return output.slice(0, 8000) + "\n\n[…truncated]";
628
- }
629
- return output || "(no output)";
630
- }
631
- catch (err) {
632
- const execErr = err;
633
- const stderr = execErr.stderr?.trim() ?? "";
634
- const stdout = execErr.stdout?.trim() ?? "";
635
- const msg = stderr || stdout || execErr.message || "Command failed";
636
- if (msg.length > 4000) {
637
- return `Error:\n${msg.slice(0, 4000)}\n[…truncated]`;
638
- }
639
- return `Error:\n${msg}`;
640
- }
641
- },
642
- });
643
- const fileOps = defineTool("file_ops", {
644
- description: "Read, write, or list files on the local filesystem.",
645
- skipPermission: true,
646
- parameters: z.object({
647
- operation: z
648
- .enum(["read", "write", "list"])
649
- .describe("Operation to perform"),
650
- path: z.string().describe("File or directory path"),
651
- content: z
652
- .string()
653
- .optional()
654
- .describe("Content to write (for write operation)"),
655
- recursive: z
656
- .boolean()
657
- .optional()
658
- .describe("Recurse into subdirectories (for list)"),
659
- }),
660
- handler: async ({ operation, path: filePath, content, recursive }) => {
661
- try {
662
- const resolved = resolve(filePath);
663
- if (operation === "read") {
664
- if (!existsSync(resolved))
665
- return `File not found: ${filePath}`;
666
- const text = readFileSync(resolved, "utf-8");
667
- if (text.length > 8000) {
668
- return text.slice(0, 8000) + "\n\n[…truncated]";
669
- }
670
- return text;
671
- }
672
- if (operation === "write") {
673
- if (!content)
674
- return "Error: content is required for write operation";
675
- mkdirSync(dirname(resolved), { recursive: true });
676
- writeFileSync(resolved, content, "utf-8");
677
- return `Written: ${filePath}`;
678
- }
679
- if (operation === "list") {
680
- if (!existsSync(resolved))
681
- return `Directory not found: ${filePath}`;
682
- if (recursive) {
683
- const files = walkDirectory(resolved);
684
- return files.join("\n") || "(empty directory)";
685
- }
686
- const entries = readdirSync(resolved);
687
- return (entries
688
- .map((e) => {
689
- const full = join(resolved, e);
690
- const isDir = statSync(full).isDirectory();
691
- return isDir ? `${e}/` : e;
692
- })
693
- .join("\n") || "(empty directory)");
694
- }
695
- return `Unknown operation: ${operation}`;
696
- }
697
- catch (err) {
698
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
699
- }
700
- },
701
- });
702
- const squadLogDecision = defineTool("squad_log_decision", {
703
- description: "Log an important decision for this squad so it persists across sessions.",
704
- skipPermission: true,
705
- parameters: z.object({
706
- decision: z.string().describe("The decision made"),
707
- context: z.string().optional().describe("Context or reasoning"),
708
- }),
709
- handler: async ({ decision, context }) => {
710
- try {
711
- logDecision(squadSlug, decision, context);
712
- return `Decision logged for squad ${squadSlug}`;
713
- }
714
- catch (err) {
715
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
716
- }
717
- },
718
- });
719
- const tools = [shell, fileOps, squadLogDecision];
720
- if (isLead) {
721
- const delegateToTeammate = defineTool("delegate_to_teammate", {
722
- description: "Delegate a subtask to a teammate on this squad. The teammate runs the task synchronously and returns its result. Use this to divvy work as the team lead.",
723
- skipPermission: true,
724
- parameters: z.object({
725
- teammate: z
726
- .string()
727
- .describe("The teammate's character_name (e.g., 'Optimus Prime')"),
728
- task: z
729
- .string()
730
- .describe("The concrete task or subtask the teammate should perform"),
731
- }),
732
- handler: async ({ teammate, task }) => {
733
- try {
734
- const teammateAgent = getSquadAgent(squadSlug, teammate);
735
- if (!teammateAgent) {
736
- return `Error: teammate "${teammate}" not found in squad "${squadSlug}". Use squad_agents to list the roster.`;
737
- }
738
- if (teammateAgent.is_lead === 1) {
739
- return `Error: "${teammate}" is the team lead. Delegate to a non-lead teammate.`;
740
- }
741
- // Record this sub-delegation as a first-class task so the squad's
742
- // work-distribution stats reflect real fan-out (issue #51).
743
- const childTaskId = randomUUID();
744
- const childAgentKey = agentSessionKey(squadSlug, teammateAgent.character_name);
745
- createTask(childTaskId, childAgentKey, task, "delegate_to_teammate");
746
- updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
747
- try {
748
- const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
749
- recordTaskEvent(childTaskId, {
750
- ts: Date.now(),
751
- type: "task.start",
752
- data: { taskId: childTaskId, agentKey: childAgentKey, description: task },
753
- });
754
- let unsubChild;
755
- try {
756
- unsubChild = session.on((event) => {
757
- if (!STREAM_EVENT_TYPES.has(event.type))
758
- return;
759
- recordTaskEvent(childTaskId, {
760
- ts: Date.now(),
761
- type: event.type,
762
- data: event.data ?? null,
763
- });
764
- });
765
- const envelopedTask = buildTaskPromptEnvelope(squadSlug, task);
766
- // Idle-reset timeout: 10min between progress events, 30min
767
- // hard cap. (Issue #53 — replaces #51's 30min wall-clock cap
768
- // that still killed agents mid-tool-call when they had
769
- // long-running shell work between assistant messages.)
770
- const sendResult = await sendWithIdleTimeout(session, envelopedTask, {
771
- idleMs: 10 * 60_000,
772
- hardCapMs: 30 * 60_000,
773
- onIdleTimeout: ({ lastEventType }) => {
774
- console.error(`[io] Teammate ${teammateAgent.character_name} idle (last event: ${lastEventType ?? "none"}) — aborting.`);
775
- },
776
- });
777
- const result = sendResult.content || "(teammate returned no output)";
778
- updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
779
- if (sendResult.timedOut) {
780
- const stamped = `[teammate timed out — ${sendResult.timeoutReason === "idle" ? "idle reset" : "hard cap"}; last event: ${sendResult.lastEventType ?? "none"}]\n\n${result}`;
781
- failTask(childTaskId, stamped);
782
- return stamped;
783
- }
784
- completeTask(childTaskId, result);
785
- return result;
786
- }
787
- finally {
788
- try {
789
- unsubChild?.();
790
- }
791
- catch { /* best-effort cleanup */ }
792
- }
793
- }
794
- catch (err) {
795
- updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
796
- const message = err instanceof Error ? err.message : String(err);
797
- failTask(childTaskId, message);
798
- return `Error from teammate "${teammate}": ${message}`;
799
- }
800
- }
801
- catch (err) {
802
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
803
- }
26
+ const agentRoster = agents
27
+ .map((a) => `- ${a.character_name} (${a.role_title})${a.is_lead ? " [LEAD]" : ""}${a.is_qa ? " [QA]" : ""}${a.is_test ? " [TEST]" : ""}`)
28
+ .join("\n");
29
+ const systemMessage = `# Squad Team Lead: ${lead.character_name}
30
+
31
+ You are ${lead.character_name}, the team lead for this squad. Your role is to:
32
+ 1. Break down tasks into smaller pieces
33
+ 2. Route work to the appropriate specialist
34
+ 3. Coordinate reviews and approvals
35
+ 4. Ensure quality gates are met
36
+
37
+ ## Your Team:
38
+ ${agentRoster}
39
+
40
+ ## Workflow Rules:
41
+ - Peer review: QA + Test + Lead have veto power
42
+ - Use \`--comment\` with "LGTM" for approvals (not \`--approve\`)
43
+ - Always pull latest before starting code work
44
+ - Merge criteria: all veto-capable members have posted approving comments + CI passes + no conflicts
45
+
46
+ ${lead.persona ? `## Personality:\n${lead.persona}` : ""}
47
+ `;
48
+ let result;
49
+ try {
50
+ const session = await client.createSession({
51
+ model,
52
+ streaming: true,
53
+ workingDirectory: process.cwd(),
54
+ systemMessage: { content: systemMessage },
55
+ onPermissionRequest: approveAll,
56
+ infiniteSessions: {
57
+ enabled: true,
58
+ backgroundCompactionThreshold: 0.8,
59
+ bufferExhaustionThreshold: 0.95,
804
60
  },
805
61
  });
806
- tools.push(delegateToTeammate);
807
- }
808
- return tools;
809
- }
810
- function walkDirectory(dir, maxDepth = 3, depth = 0) {
811
- if (depth >= maxDepth)
812
- return [];
813
- const results = [];
814
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
815
- if (entry.name.startsWith("."))
816
- continue;
817
- const full = join(dir, entry.name);
818
- if (entry.isDirectory()) {
819
- results.push(`${entry.name}/`);
820
- results.push(...walkDirectory(full, maxDepth, depth + 1).map((f) => ` ${entry.name}/${f}`));
821
- }
822
- else {
823
- results.push(entry.name);
824
- }
825
- }
826
- return results;
827
- }
828
- /**
829
- * Parse APPROVED/REJECTED verdict from a reviewer's free-form response.
830
- *
831
- * Robust to common formatting variants:
832
- * - Leading blank lines or markdown headers (e.g. "## Review\n\nAPPROVED")
833
- * - Markdown emphasis (e.g. "**APPROVED**")
834
- * - Verdict appearing only later in the response
835
- * - Both tokens appearing in the same line ("I almost said REJECTED but APPROVED")
836
- *
837
- * Strategy:
838
- * 1. Strip markdown noise.
839
- * 2. Look at the first 10 non-empty lines for a *line-leading* verdict.
840
- * 3. Fall back to the first occurrence of either token anywhere in the body.
841
- * 4. If neither token appears, treat as REJECTED (conservative).
842
- */
843
- export function parseReviewVerdict(content) {
844
- if (!content)
845
- return false;
846
- const stripped = content.replace(/[*_`#>]/g, "");
847
- const lines = stripped
848
- .split(/\r?\n/)
849
- .map((l) => l.trim())
850
- .filter(Boolean)
851
- .slice(0, 10);
852
- for (const line of lines) {
853
- const lead = line
854
- .toUpperCase()
855
- .match(/^[^A-Z]*\b(APPROVED|REJECTED)\b/);
856
- if (lead)
857
- return lead[1] === "APPROVED";
858
- }
859
- const upper = stripped.toUpperCase();
860
- const a = upper.search(/\bAPPROVED\b/);
861
- const r = upper.search(/\bREJECTED\b/);
862
- if (a === -1 && r === -1)
863
- return false;
864
- if (a === -1)
865
- return false;
866
- if (r === -1)
867
- return true;
868
- return a < r;
869
- }
870
- /**
871
- * Return the reviewer's prose comments with any leading verdict line stripped.
872
- * Preserves the original formatting (no upper-casing, no markdown stripping).
873
- */
874
- export function stripLeadingVerdictLine(content) {
875
- if (!content)
876
- return "";
877
- const lines = content.split(/\r?\n/);
878
- let i = 0;
879
- while (i < lines.length && lines[i].trim() === "")
880
- i++;
881
- if (i < lines.length) {
882
- const probe = lines[i]
883
- .replace(/[*_`#>]/g, "")
884
- .trim()
885
- .toUpperCase();
886
- if (/^(APPROVED|REJECTED)\b/.test(probe))
887
- i++;
888
- }
889
- return lines.slice(i).join("\n").trim();
890
- }
891
- /**
892
- * Run a peer review phase after a task completes. Every other agent on the
893
- * squad reviews the work and votes APPROVED / REJECTED. QA agents
894
- * (is_qa === 1) have veto power: if any QA agent rejects, the PR is left as
895
- * draft. Otherwise, any GitHub PR URL found in the task result is promoted
896
- * from draft to ready via `gh pr ready`.
897
- */
898
- async function runPeerReview(squadSlug, originalAgentCharacter, taskId, taskDescription, taskResult) {
899
- const reviewers = listSquadAgents(squadSlug).filter((a) => a.character_name !== originalAgentCharacter);
900
- if (reviewers.length === 0) {
901
- recordTaskEvent(taskId, {
902
- ts: Date.now(),
903
- type: "task.review_complete",
904
- data: { promoted: false, reason: "No other agents to review" },
905
- });
906
- return;
907
- }
908
- const reviewPrompt = `You are reviewing the following completed task:
909
-
910
- ## Task
911
- ${taskDescription}
912
-
913
- ## Work Done
914
- ${taskResult}
915
-
916
- Review the work. Respond with:
917
- - First line: APPROVED or REJECTED
918
- - Remaining lines: your review comments`;
919
- const reviews = [];
920
- for (const reviewer of reviewers) {
921
62
  try {
922
- const session = await getOrCreateAgentSession(squadSlug, reviewer, `Peer review of task ${taskId}`);
923
- const response = await session.sendAndWait({ prompt: reviewPrompt }, 300_000);
924
- const content = response?.data?.content ?? "";
925
- const approved = parseReviewVerdict(content);
926
- const comments = stripLeadingVerdictLine(content) || null;
927
- createReview(taskId, squadSlug, reviewer.character_name, approved, comments ?? undefined);
928
- recordTaskEvent(taskId, {
929
- ts: Date.now(),
930
- type: "task.review",
931
- data: {
932
- reviewer: reviewer.character_name,
933
- is_qa: reviewer.is_qa === 1,
934
- is_lead: reviewer.is_lead === 1,
935
- approved,
936
- comments,
937
- },
938
- });
939
- reviews.push({
940
- reviewer: reviewer.character_name,
941
- is_qa: reviewer.is_qa === 1,
942
- is_lead: reviewer.is_lead === 1,
943
- approved,
944
- comments: comments ?? "",
945
- });
63
+ const response = await session.sendAndWait({ prompt: `Task delegated to you:\n\n${task}` }, 600_000);
64
+ result = response?.data?.content ?? "Task completed (no response content).";
946
65
  }
947
- catch (err) {
948
- const message = err instanceof Error ? err.message : String(err);
949
- console.error(`[io] Reviewer ${reviewer.character_name} failed:`, message);
950
- recordTaskEvent(taskId, {
951
- ts: Date.now(),
952
- type: "task.review_error",
953
- data: { reviewer: reviewer.character_name, error: message },
954
- });
66
+ finally {
67
+ await session.disconnect();
955
68
  }
956
69
  }
957
- const hasQaReviewers = reviews.some((r) => r.is_qa);
958
- const hasLeadReviewer = reviews.some((r) => r.is_lead);
959
- const qaRejection = reviews.find((r) => r.is_qa && !r.approved);
960
- // Team lead has implicit veto power equivalent to a QA reviewer. If the lead
961
- // is also a QA agent the qaRejection branch already covers it; this catches
962
- // the lead-but-not-QA case.
963
- const leadRejection = reviews.find((r) => r.is_lead && !r.is_qa && !r.approved);
964
- const advisoryRejections = reviews.filter((r) => !r.is_qa && !r.is_lead && !r.approved);
965
- if (!hasQaReviewers && !hasLeadReviewer && advisoryRejections.length > 0) {
966
- recordTaskEvent(taskId, {
967
- ts: Date.now(),
968
- type: "task.review_advisory",
969
- data: {
970
- reason: "No QA reviewers or team lead designated; rejections are advisory and do not block promotion.",
971
- rejectedBy: advisoryRejections.map((r) => r.reviewer),
972
- },
973
- });
974
- }
975
- const prMatch = taskResult.match(/https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
976
- if (qaRejection) {
977
- recordTaskEvent(taskId, {
978
- ts: Date.now(),
979
- type: "task.review_complete",
980
- data: {
981
- promoted: false,
982
- reason: `QA veto from ${qaRejection.reviewer}`,
983
- prUrl: prMatch ? prMatch[0] : null,
984
- },
985
- });
986
- return;
987
- }
988
- if (leadRejection) {
989
- recordTaskEvent(taskId, {
990
- ts: Date.now(),
991
- type: "task.review_complete",
992
- data: {
993
- promoted: false,
994
- reason: `Lead veto from ${leadRejection.reviewer}`,
995
- prUrl: prMatch ? prMatch[0] : null,
996
- },
997
- });
998
- return;
999
- }
1000
- if (!prMatch) {
1001
- recordTaskEvent(taskId, {
1002
- ts: Date.now(),
1003
- type: "task.review_complete",
1004
- data: { promoted: false, reason: "No PR URL found in task result" },
1005
- });
1006
- return;
1007
- }
1008
- const [prUrl, owner, repo, prNumber] = prMatch;
1009
- try {
1010
- execSync(`gh pr ready ${prNumber} --repo ${owner}/${repo}`, {
1011
- encoding: "utf-8",
1012
- timeout: 30_000,
1013
- env: { ...process.env, HOME: process.env.HOME || homedir() },
1014
- });
1015
- recordTaskEvent(taskId, {
1016
- ts: Date.now(),
1017
- type: "task.review_complete",
1018
- data: { promoted: true, prUrl },
1019
- });
1020
- }
1021
70
  catch (err) {
1022
- const message = err instanceof Error ? err.message : String(err);
1023
- recordTaskEvent(taskId, {
1024
- ts: Date.now(),
1025
- type: "task.review_complete",
1026
- data: { promoted: false, reason: `gh pr ready failed: ${message}`, prUrl },
1027
- });
1028
- }
1029
- }
1030
- /**
1031
- * Cancel a running agent task by aborting its session and marking the task
1032
- * cancelled. Returns true if the task existed and was running.
1033
- */
1034
- export async function cancelAgentTask(taskId) {
1035
- const task = getTask(taskId);
1036
- if (!task || task.status !== "running")
1037
- return false;
1038
- const sessionKey = task.agent_slug;
1039
- const session = agentSessions.get(sessionKey);
1040
- if (session) {
1041
- try {
1042
- await session.abort();
1043
- }
1044
- catch (err) {
1045
- console.error("[io] Error aborting agent session:", err instanceof Error ? err.message : err);
1046
- }
1047
- }
1048
- cancelTask(taskId);
1049
- recordTaskEvent(taskId, { ts: Date.now(), type: "task.cancelled", data: { reason: "Cancelled by user" } });
1050
- // sessionKey is "squadSlug" or "squadSlug:characterName"
1051
- const [squadSlug, characterName] = sessionKey.split(":");
1052
- if (squadSlug) {
1053
- try {
1054
- updateSquadStatus(squadSlug, "idle");
1055
- }
1056
- catch { /* ignore */ }
1057
- }
1058
- if (squadSlug && characterName) {
1059
- try {
1060
- updateAgentStatus(squadSlug, characterName, "idle");
1061
- }
1062
- catch { /* ignore */ }
1063
- }
1064
- return true;
71
+ const errMsg = err instanceof Error ? err.message : "Unknown error";
72
+ updateTaskStatus(taskRecord.id, "failed", errMsg);
73
+ updateAgentStatus(lead.id, "idle");
74
+ throw err;
75
+ }
76
+ // Update task and agent status
77
+ updateTaskStatus(taskRecord.id, "done", result);
78
+ updateAgentStatus(lead.id, "idle");
79
+ // Post to feed
80
+ postFeedItem(`squad-${squadId}`, `Task completed by ${lead.character_name}`, result.slice(0, 2000));
81
+ return result;
1065
82
  }
1066
83
  //# sourceMappingURL=agents.js.map