heyio 0.42.1 → 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.
- package/README.md +40 -52
- package/dist/api/auth.js +35 -38
- package/dist/api/server.js +157 -1134
- package/dist/config.js +49 -32
- package/dist/copilot/agents.js +72 -1055
- package/dist/copilot/client.js +6 -17
- package/dist/copilot/io-scheduler.js +55 -139
- package/dist/copilot/model-router.js +100 -72
- package/dist/copilot/orchestrator.js +91 -515
- package/dist/copilot/scheduler.js +67 -189
- package/dist/copilot/skills.js +41 -366
- package/dist/copilot/system-message.js +40 -200
- package/dist/copilot/tools.js +191 -2042
- package/dist/daemon.js +54 -201
- package/dist/index.js +15 -133
- package/dist/mcp/config.js +23 -31
- package/dist/mcp/index.js +2 -3
- package/dist/mcp/registry.js +33 -88
- package/dist/notify.js +18 -100
- package/dist/paths.js +13 -24
- package/dist/setup.js +35 -0
- package/dist/store/db.js +111 -297
- package/dist/store/feed.js +29 -97
- package/dist/store/instances.js +56 -121
- package/dist/store/schedules.js +21 -73
- package/dist/store/squads.js +35 -186
- package/dist/store/tasks.js +25 -168
- package/dist/telegram/bot.js +20 -312
- package/dist/telegram/handlers.js +39 -3
- package/dist/watchdog.js +31 -45
- package/dist/wiki/fs.js +38 -155
- package/dist/wiki/search.js +31 -44
- package/package.json +5 -8
- package/web-dist/assets/ChatView-EFFiln1H.js +11 -0
- package/web-dist/assets/FeedView-bN4NMOL7.js +6 -0
- package/web-dist/assets/LoginView-CNtasq3n.js +1 -0
- package/web-dist/assets/McpView-C2CHiwsi.js +1 -0
- package/web-dist/assets/SchedulesView-CyilLban.js +1 -0
- package/web-dist/assets/SettingsView-1wLXKEF4.js +1 -0
- package/web-dist/assets/SkillsView-BLsD-0u0.js +1 -0
- package/web-dist/assets/SquadDetailView-CsCw2ZLp.js +21 -0
- package/web-dist/assets/SquadsView-DQ3vFlyO.js +6 -0
- package/web-dist/assets/WikiView-19M3oqnq.js +21 -0
- package/web-dist/assets/api-WGvTsXaE.js +1 -0
- package/web-dist/assets/index-D7M5O-_l.css +1 -0
- package/web-dist/assets/index-DZOS9syn.js +95 -0
- package/web-dist/assets/plus-BOvyX1BC.js +6 -0
- package/web-dist/assets/trash-2-DHoetkC4.js +6 -0
- package/web-dist/favicon.svg +4 -1
- package/web-dist/index.html +7 -10
- package/dist/api/logout.test.js +0 -128
- package/dist/api/mcp.test.js +0 -285
- package/dist/api/wiki.test.js +0 -283
- package/dist/auth/session-logic.js +0 -79
- package/dist/auth/session-logic.test.js +0 -201
- package/dist/copilot/auto-complete-instance.test.js +0 -104
- package/dist/copilot/cron.js +0 -136
- package/dist/copilot/event-summary.js +0 -286
- package/dist/copilot/instance-deactivate.test.js +0 -119
- package/dist/copilot/model-router.test.js +0 -71
- package/dist/copilot/review-backfill.js +0 -57
- package/dist/copilot/session-timeout.js +0 -112
- package/dist/copilot/session-timeout.test.js +0 -372
- package/dist/copilot/skills.test.js +0 -55
- package/dist/copilot/universes.js +0 -469
- package/dist/instance-watchdog.js +0 -104
- package/dist/instance-watchdog.test.js +0 -183
- package/dist/mcp/client.js +0 -109
- package/dist/mcp/client.test.js +0 -99
- package/dist/mcp/config.test.js +0 -49
- package/dist/mcp/registry.test.js +0 -79
- package/dist/notify.test.js +0 -232
- package/dist/store/feed.test.js +0 -279
- package/dist/store/instances.test.js +0 -310
- package/dist/store/io-schedules.js +0 -63
- package/dist/store/notifications.js +0 -79
- package/dist/store/notifications.test.js +0 -197
- package/dist/store/schedule-runs.js +0 -46
- package/dist/store/squads.test.js +0 -405
- package/dist/store/tasks.test.js +0 -150
- package/dist/store/worktrees.js +0 -83
- package/dist/tui/index.js +0 -286
- package/dist/update.js +0 -81
- package/dist/watchdog.test.js +0 -83
- package/dist/wiki/wiki-squad.test.js +0 -54
- package/web-dist/assets/AgentActivityView-B1PaNYy8.js +0 -1
- package/web-dist/assets/ChatView-BbpWnrtC.js +0 -4
- package/web-dist/assets/FeedView-B5LaMV0I.js +0 -1
- package/web-dist/assets/InboxView-Cwqt8rH7.js +0 -1
- package/web-dist/assets/LoginView-refmPLKT.js +0 -1
- package/web-dist/assets/McpView-B1w0dRFY.js +0 -1
- package/web-dist/assets/SchedulesView-D9l2DI7X.js +0 -1
- package/web-dist/assets/SettingsTabs.vue_vue_type_script_setup_true_lang-DncOVVEB.js +0 -1
- package/web-dist/assets/SkillsView-uFX0q1mV.js +0 -1
- package/web-dist/assets/SquadsView-B1nZW4ml.js +0 -1
- package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-pTrJJwX1.js +0 -1
- package/web-dist/assets/WikiView-B54cCKIK.js +0 -1
- package/web-dist/assets/index-C0VEUWQ1.js +0 -81
- package/web-dist/assets/index-eluTyieM.css +0 -10
- package/web-dist/icons.svg +0 -24
package/dist/copilot/agents.js
CHANGED
|
@@ -1,1066 +1,83 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
##
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
923
|
-
|
|
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
|
-
|
|
948
|
-
|
|
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
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|