heyio 0.5.0 → 0.6.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/dist/api/server.js +31 -1
- package/dist/copilot/agents.js +172 -12
- package/dist/copilot/orchestrator.js +30 -3
- package/dist/copilot/session-timeout.js +112 -0
- package/dist/copilot/system-message.js +12 -8
- package/dist/copilot/tools.js +314 -18
- package/dist/store/db.js +6 -0
- package/dist/store/squads.js +10 -0
- package/dist/store/tasks.js +122 -0
- package/package.json +1 -1
- package/web-dist/assets/{index-BYoiwmlj.js → index-BlZDeDCS.js} +17 -17
- package/web-dist/index.html +1 -1
package/dist/api/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import express from "express";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
|
-
import { listSkills } from "../copilot/skills.js";
|
|
6
|
+
import { listSkills, installSkill } from "../copilot/skills.js";
|
|
7
7
|
import { listSquads, createSquad, listSquadAgents } from "../store/squads.js";
|
|
8
8
|
import { getAgentInfo, cancelAgentTask, getTaskEvents, subscribeToTaskEvents } from "../copilot/agents.js";
|
|
9
9
|
import { summarize, summarizeEvent } from "../copilot/event-summary.js";
|
|
@@ -67,6 +67,36 @@ export async function startApiServer() {
|
|
|
67
67
|
res.status(500).json({ error: "Failed to list skills" });
|
|
68
68
|
}
|
|
69
69
|
});
|
|
70
|
+
// Install a skill from a git repo URL (mirrors the skill_install tool)
|
|
71
|
+
api.post("/skills", async (req, res) => {
|
|
72
|
+
const { repoUrl } = req.body;
|
|
73
|
+
if (repoUrl === undefined || repoUrl === null || typeof repoUrl !== "string") {
|
|
74
|
+
res.status(400).json({ error: "Missing required field: repoUrl" });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (repoUrl.trim() === "") {
|
|
78
|
+
res.status(400).json({ error: "repoUrl must not be empty" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const trimmed = repoUrl.trim();
|
|
82
|
+
const looksLikeGitUrl = trimmed.startsWith("http://") ||
|
|
83
|
+
trimmed.startsWith("https://") ||
|
|
84
|
+
trimmed.startsWith("git@") ||
|
|
85
|
+
trimmed.startsWith("git://") ||
|
|
86
|
+
trimmed.endsWith(".git");
|
|
87
|
+
if (!looksLikeGitUrl) {
|
|
88
|
+
res.status(400).json({ error: "repoUrl does not look like a git repository URL" });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const skill = await installSkill(trimmed);
|
|
93
|
+
res.status(201).json({ skill });
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
console.error("Error installing skill:", e);
|
|
97
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
70
100
|
// Squads endpoints
|
|
71
101
|
api.get("/squads", (_req, res) => {
|
|
72
102
|
try {
|
package/dist/copilot/agents.js
CHANGED
|
@@ -7,8 +7,9 @@ import { homedir } from "os";
|
|
|
7
7
|
import { defineTool, approveAll } from "@github/copilot-sdk";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { getClient } from "./client.js";
|
|
10
|
+
import { sendWithIdleTimeout } from "./session-timeout.js";
|
|
10
11
|
import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
|
|
11
|
-
import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
|
|
12
|
+
import { getSquad, updateSquadSession, updateSquadStatus, getDecisions, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
|
|
12
13
|
import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
|
|
13
14
|
import { SESSIONS_DIR } from "../paths.js";
|
|
14
15
|
import { getUniverse } from "./universes.js";
|
|
@@ -18,6 +19,16 @@ const agentSessionModels = new Map();
|
|
|
18
19
|
function agentSessionKey(squadSlug, characterName) {
|
|
19
20
|
return characterName ? `${squadSlug}:${characterName}` : squadSlug;
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Drop the in-memory cached Copilot session (and model) for an agent so the
|
|
24
|
+
* next task creates a fresh one. Pairs with `clearAgentSession` in the
|
|
25
|
+
* store, which nulls the persisted copilot_session_id.
|
|
26
|
+
*/
|
|
27
|
+
export function clearAgentInMemorySession(squadSlug, characterName) {
|
|
28
|
+
const key = agentSessionKey(squadSlug, characterName);
|
|
29
|
+
agentSessions.delete(key);
|
|
30
|
+
agentSessionModels.delete(key);
|
|
31
|
+
}
|
|
21
32
|
export function getAgentInfo() {
|
|
22
33
|
const activeTasks = getActiveTasks();
|
|
23
34
|
const tasksByAgent = new Map();
|
|
@@ -104,6 +115,49 @@ export function subscribeToTaskEvents(taskId, listener) {
|
|
|
104
115
|
taskEventEmitter.on(taskId, listener);
|
|
105
116
|
return () => taskEventEmitter.off(taskId, listener);
|
|
106
117
|
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Task prompt envelope (issue #54)
|
|
120
|
+
//
|
|
121
|
+
// Before sending a task to an agent we prepend a short "Recent squad
|
|
122
|
+
// decisions" preamble and append a tail that asks the agent to call
|
|
123
|
+
// squad_log_decision if their work involved a non-trivial architectural
|
|
124
|
+
// choice. This is the lowest-friction nudge we can give: agents see what
|
|
125
|
+
// they're augmenting AND a reminder to capture institutional knowledge.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
const RECENT_DECISIONS_LIMIT = 5;
|
|
128
|
+
function buildTaskPromptEnvelope(squadSlug, task) {
|
|
129
|
+
const recent = getDecisions(squadSlug, RECENT_DECISIONS_LIMIT);
|
|
130
|
+
const preamble = recent.length === 0
|
|
131
|
+
? `## Recent squad decisions
|
|
132
|
+
_(None recorded yet — be the first to log one with \`squad_log_decision\` if your work involves a real architectural choice.)_`
|
|
133
|
+
: `## Recent squad decisions (last ${recent.length})
|
|
134
|
+
You should treat these as load-bearing context. Reverse them only with a clear reason and a new \`squad_log_decision\` entry.
|
|
135
|
+
|
|
136
|
+
${recent
|
|
137
|
+
.slice()
|
|
138
|
+
.reverse()
|
|
139
|
+
.map((d) => {
|
|
140
|
+
const ctx = d.context ? ` — _${d.context}_` : "";
|
|
141
|
+
return `- [${d.created_at}] **${d.decision}**${ctx}`;
|
|
142
|
+
})
|
|
143
|
+
.join("\n")}`;
|
|
144
|
+
const tail = `## Capturing institutional knowledge
|
|
145
|
+
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:
|
|
146
|
+
- 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)."
|
|
147
|
+
- decision: "Veto power expanded to lead + QA + test engineers" / context: "Single-reviewer veto was too narrow when test engineer wasn't designated QA."
|
|
148
|
+
|
|
149
|
+
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.`;
|
|
150
|
+
return `${preamble}
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Task
|
|
155
|
+
${task}
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
${tail}`;
|
|
160
|
+
}
|
|
107
161
|
export async function delegateToAgent(squadSlug, task, onComplete, targetAgent) {
|
|
108
162
|
const squad = getSquad(squadSlug);
|
|
109
163
|
if (!squad) {
|
|
@@ -131,13 +185,26 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
|
|
|
131
185
|
}
|
|
132
186
|
}
|
|
133
187
|
}
|
|
188
|
+
const agentKey = agent
|
|
189
|
+
? agentSessionKey(squadSlug, agent.character_name)
|
|
190
|
+
: squadSlug;
|
|
191
|
+
// Idempotency: if an identical task is already running on this agent_slug,
|
|
192
|
+
// join the existing task instead of racing a second instance. (Issue #53)
|
|
193
|
+
const normalizedTask = task.trim();
|
|
194
|
+
const duplicate = getActiveTasks().find((t) => t.agent_slug === agentKey && t.description.trim() === normalizedTask);
|
|
195
|
+
if (duplicate) {
|
|
196
|
+
console.error(`[io] Dedup: task with identical description already running on ${agentKey} (taskId=${duplicate.task_id}); returning existing taskId.`);
|
|
197
|
+
recordTaskEvent(duplicate.task_id, {
|
|
198
|
+
ts: Date.now(),
|
|
199
|
+
type: "task.dedup_joined",
|
|
200
|
+
data: { agentKey, description: normalizedTask },
|
|
201
|
+
});
|
|
202
|
+
return duplicate.task_id;
|
|
203
|
+
}
|
|
134
204
|
const session = agent
|
|
135
205
|
? await getOrCreateAgentSession(squadSlug, agent, task)
|
|
136
206
|
: await getOrCreateSession(squadSlug, task);
|
|
137
207
|
const taskId = randomUUID();
|
|
138
|
-
const agentKey = agent
|
|
139
|
-
? agentSessionKey(squadSlug, agent.character_name)
|
|
140
|
-
: squadSlug;
|
|
141
208
|
createTask(taskId, agentKey, task);
|
|
142
209
|
updateSquadStatus(squadSlug, "working");
|
|
143
210
|
if (agent)
|
|
@@ -161,8 +228,40 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
|
|
|
161
228
|
// Run the task in the background — return taskId immediately
|
|
162
229
|
void (async () => {
|
|
163
230
|
try {
|
|
164
|
-
const
|
|
165
|
-
const
|
|
231
|
+
const envelopedTask = buildTaskPromptEnvelope(squadSlug, task);
|
|
232
|
+
const sendResult = await sendWithIdleTimeout(session, envelopedTask, {
|
|
233
|
+
// Reset on every progress event; only abort if the agent goes
|
|
234
|
+
// genuinely silent for this long. 10 minutes covers the longest
|
|
235
|
+
// realistic tool call (npm install, full build, large file edits)
|
|
236
|
+
// while still catching truly stuck sessions. (Issue #53)
|
|
237
|
+
idleMs: 10 * 60_000,
|
|
238
|
+
// Absolute upper bound — 60 minutes. Anything longer is almost
|
|
239
|
+
// certainly a runaway loop; cap it.
|
|
240
|
+
hardCapMs: 60 * 60_000,
|
|
241
|
+
onIdleTimeout: ({ lastEventType, idleMs }) => {
|
|
242
|
+
console.error(`[io] Agent task ${taskId} idle for ${Math.round(idleMs / 1000)}s (last event: ${lastEventType ?? "none"}) — aborting session.`);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
if (sendResult.timedOut) {
|
|
246
|
+
const partial = sendResult.content;
|
|
247
|
+
recordTaskEvent(taskId, {
|
|
248
|
+
ts: Date.now(),
|
|
249
|
+
type: "task.timeout",
|
|
250
|
+
data: {
|
|
251
|
+
reason: sendResult.timeoutReason,
|
|
252
|
+
lastEventType: sendResult.lastEventType,
|
|
253
|
+
partial,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const stamped = `[task timed out — ${sendResult.timeoutReason === "idle" ? "idle reset" : "hard cap"}; last event: ${sendResult.lastEventType ?? "none"}]\n\n${partial}`;
|
|
257
|
+
failTask(taskId, stamped);
|
|
258
|
+
updateSquadStatus(squadSlug, "idle");
|
|
259
|
+
if (agent)
|
|
260
|
+
updateAgentStatus(squadSlug, agent.character_name, "idle");
|
|
261
|
+
onComplete(taskId, stamped);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const result = sendResult.content || "Task completed (no output)";
|
|
166
265
|
completeTask(taskId, result);
|
|
167
266
|
updateSquadStatus(squadSlug, "idle");
|
|
168
267
|
if (agent)
|
|
@@ -300,10 +399,26 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
|
|
|
300
399
|
leadSection = `
|
|
301
400
|
|
|
302
401
|
## Team Lead Role
|
|
303
|
-
You are the team lead for this squad.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
402
|
+
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.
|
|
403
|
+
|
|
404
|
+
### Fan-out planning (REQUIRED before any work begins)
|
|
405
|
+
When a task arrives, BEFORE touching code or shell, you MUST:
|
|
406
|
+
|
|
407
|
+
1. **List every distinct work-area** the task touches (e.g. "API endpoint", "DB migration", "frontend component", "tests", "docs"). One bullet per area.
|
|
408
|
+
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.
|
|
409
|
+
3. **Produce a fan-out plan** as a short markdown list: \`- <area> → <teammate> — <one-sentence subtask>\`.
|
|
410
|
+
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.
|
|
411
|
+
|
|
412
|
+
### When you may implement directly
|
|
413
|
+
Only if **all** of the following are true:
|
|
414
|
+
- 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.
|
|
415
|
+
- No teammate's charter covers the work-area at all.
|
|
416
|
+
- A prior \`delegate_to_teammate\` attempt for this exact subtask failed twice with a clear, unrecoverable error.
|
|
417
|
+
|
|
418
|
+
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.
|
|
419
|
+
|
|
420
|
+
### Reviewing teammate output
|
|
421
|
+
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.
|
|
307
422
|
|
|
308
423
|
## Your Team
|
|
309
424
|
${roster}`;
|
|
@@ -324,6 +439,17 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
|
|
|
324
439
|
## Past Decisions
|
|
325
440
|
${decisions}${leadSection}
|
|
326
441
|
|
|
442
|
+
## Repository Hygiene
|
|
443
|
+
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.
|
|
444
|
+
|
|
445
|
+
1. \`cd\` to the project path above.
|
|
446
|
+
2. \`git fetch origin\` — pick up everything that has merged since your last task.
|
|
447
|
+
3. \`git checkout main && git pull origin main\` — fast-forward your local main.
|
|
448
|
+
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.
|
|
449
|
+
5. Only THEN start editing files, running tools, or delegating subtasks.
|
|
450
|
+
|
|
451
|
+
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.
|
|
452
|
+
|
|
327
453
|
## Instructions
|
|
328
454
|
You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
|
|
329
455
|
Log important decisions with squad_log_decision so they persist.
|
|
@@ -380,6 +506,17 @@ async function getOrCreateSession(squadSlug, taskDescription) {
|
|
|
380
506
|
## Past Decisions
|
|
381
507
|
${decisions}
|
|
382
508
|
|
|
509
|
+
## Repository Hygiene
|
|
510
|
+
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.
|
|
511
|
+
|
|
512
|
+
1. \`cd\` to the project path above.
|
|
513
|
+
2. \`git fetch origin\` — pick up everything that has merged since your last task.
|
|
514
|
+
3. \`git checkout main && git pull origin main\` — fast-forward your local main.
|
|
515
|
+
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.
|
|
516
|
+
5. Only THEN start editing files, running tools, or delegating subtasks.
|
|
517
|
+
|
|
518
|
+
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.
|
|
519
|
+
|
|
383
520
|
## Your Role
|
|
384
521
|
You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
|
|
385
522
|
Log important decisions with squad_log_decision so they persist.`,
|
|
@@ -549,17 +686,40 @@ function buildAgentTools(squadSlug, isLead = false) {
|
|
|
549
686
|
if (teammateAgent.is_lead === 1) {
|
|
550
687
|
return `Error: "${teammate}" is the team lead. Delegate to a non-lead teammate.`;
|
|
551
688
|
}
|
|
689
|
+
// Record this sub-delegation as a first-class task so the squad's
|
|
690
|
+
// work-distribution stats reflect real fan-out (issue #51).
|
|
691
|
+
const childTaskId = randomUUID();
|
|
692
|
+
const childAgentKey = agentSessionKey(squadSlug, teammateAgent.character_name);
|
|
693
|
+
createTask(childTaskId, childAgentKey, task, "delegate_to_teammate");
|
|
552
694
|
updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
|
|
553
695
|
try {
|
|
554
696
|
const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
|
|
555
|
-
const
|
|
556
|
-
|
|
697
|
+
const envelopedTask = buildTaskPromptEnvelope(squadSlug, task);
|
|
698
|
+
// Idle-reset timeout: 10min between progress events, 30min
|
|
699
|
+
// hard cap. (Issue #53 — replaces #51's 30min wall-clock cap
|
|
700
|
+
// that still killed agents mid-tool-call when they had
|
|
701
|
+
// long-running shell work between assistant messages.)
|
|
702
|
+
const sendResult = await sendWithIdleTimeout(session, envelopedTask, {
|
|
703
|
+
idleMs: 10 * 60_000,
|
|
704
|
+
hardCapMs: 30 * 60_000,
|
|
705
|
+
onIdleTimeout: ({ lastEventType }) => {
|
|
706
|
+
console.error(`[io] Teammate ${teammateAgent.character_name} idle (last event: ${lastEventType ?? "none"}) — aborting.`);
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
const result = sendResult.content || "(teammate returned no output)";
|
|
557
710
|
updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
|
|
711
|
+
if (sendResult.timedOut) {
|
|
712
|
+
const stamped = `[teammate timed out — ${sendResult.timeoutReason === "idle" ? "idle reset" : "hard cap"}; last event: ${sendResult.lastEventType ?? "none"}]\n\n${result}`;
|
|
713
|
+
failTask(childTaskId, stamped);
|
|
714
|
+
return stamped;
|
|
715
|
+
}
|
|
716
|
+
completeTask(childTaskId, result);
|
|
558
717
|
return result;
|
|
559
718
|
}
|
|
560
719
|
catch (err) {
|
|
561
720
|
updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
|
|
562
721
|
const message = err instanceof Error ? err.message : String(err);
|
|
722
|
+
failTask(childTaskId, message);
|
|
563
723
|
return `Error from teammate "${teammate}": ${message}`;
|
|
564
724
|
}
|
|
565
725
|
}
|
|
@@ -3,8 +3,8 @@ import { approveAll, } from "@github/copilot-sdk";
|
|
|
3
3
|
import { config } from "../config.js";
|
|
4
4
|
import { SESSIONS_DIR, IO_VERSION } from "../paths.js";
|
|
5
5
|
import { getState, setState, deleteState, logConversation } from "../store/db.js";
|
|
6
|
-
import { clearStaleTasks, getTask, getTaskReviews } from "../store/tasks.js";
|
|
7
|
-
import { getSquad, listSquads, createSquad, deleteSquad, logDecision, getDecisionsSummary, updateSquadStatus, addSquadAgent, listSquadAgents, removeSquadAgent, setSquadLead, getSquadLead, setSquadQA, } from "../store/squads.js";
|
|
6
|
+
import { clearStaleTasks, getAgentTaskStats, getSquadWorkDistribution, getStalestSpecialist, getTask, getTaskReviews } from "../store/tasks.js";
|
|
7
|
+
import { getSquad, listSquads, createSquad, deleteSquad, logDecision, getDecisions, getDecisionsSummary, updateSquadStatus, addSquadAgent, listSquadAgents, removeSquadAgent, updateAgentStatus, clearAgentSession, setSquadLead, getSquadLead, setSquadQA, } from "../store/squads.js";
|
|
8
8
|
import { readPage, writePage, assertPagePath, deletePage, listPages } from "../wiki/fs.js";
|
|
9
9
|
import { resolveModelTiers } from "./model-router.js";
|
|
10
10
|
import { searchWiki, getWikiSummary } from "../wiki/search.js";
|
|
@@ -12,7 +12,7 @@ import { getOrchestratorSystemMessage } from "./system-message.js";
|
|
|
12
12
|
import { createTools } from "./tools.js";
|
|
13
13
|
import { getSkillDirectories, listSkills, installSkill, removeSkill, searchSkillsRegistry } from "./skills.js";
|
|
14
14
|
import { resetClient } from "./client.js";
|
|
15
|
-
import { delegateToAgent, getActiveAgentTasks } from "./agents.js";
|
|
15
|
+
import { delegateToAgent, getActiveAgentTasks, clearAgentInMemorySession } from "./agents.js";
|
|
16
16
|
import { saveConfig } from "../config.js";
|
|
17
17
|
import { checkForUpdate } from "../update.js";
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +56,11 @@ function getToolDeps() {
|
|
|
56
56
|
deleteSquad,
|
|
57
57
|
logDecision,
|
|
58
58
|
getDecisionsSummary,
|
|
59
|
+
getRecentDecisions: (slug, limit) => getDecisions(slug, limit ?? 5).map((d) => ({
|
|
60
|
+
decision: d.decision,
|
|
61
|
+
context: d.context,
|
|
62
|
+
created_at: d.created_at,
|
|
63
|
+
})),
|
|
59
64
|
updateSquadStatus,
|
|
60
65
|
delegateToAgent,
|
|
61
66
|
getTask,
|
|
@@ -77,6 +82,25 @@ function getToolDeps() {
|
|
|
77
82
|
is_qa: a.is_qa,
|
|
78
83
|
})),
|
|
79
84
|
removeSquadAgent,
|
|
85
|
+
resetSquadAgent: (squadSlug, characterName) => {
|
|
86
|
+
const agents = listSquadAgents(squadSlug);
|
|
87
|
+
const target = agents.find((a) => a.character_name === characterName);
|
|
88
|
+
if (!target) {
|
|
89
|
+
return { found: false, previousStatus: "", agent: null };
|
|
90
|
+
}
|
|
91
|
+
const previousStatus = target.status;
|
|
92
|
+
updateAgentStatus(squadSlug, characterName, "idle");
|
|
93
|
+
clearAgentSession(squadSlug, characterName);
|
|
94
|
+
clearAgentInMemorySession(squadSlug, characterName);
|
|
95
|
+
return {
|
|
96
|
+
found: true,
|
|
97
|
+
previousStatus,
|
|
98
|
+
agent: {
|
|
99
|
+
character_name: target.character_name,
|
|
100
|
+
role_title: target.role_title,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
80
104
|
setSquadLead,
|
|
81
105
|
getSquadLead: (slug) => {
|
|
82
106
|
const lead = getSquadLead(slug);
|
|
@@ -91,6 +115,9 @@ function getToolDeps() {
|
|
|
91
115
|
comments: r.comments,
|
|
92
116
|
squad_slug: r.squad_slug,
|
|
93
117
|
})),
|
|
118
|
+
getSquadWorkDistribution: (slug, limit) => getSquadWorkDistribution(slug, limit),
|
|
119
|
+
getAgentTaskStats: (squadSlug, characterNames) => getAgentTaskStats(squadSlug, characterNames),
|
|
120
|
+
getStalestSpecialist: (squadSlug, characterNames, options) => getStalestSpecialist(squadSlug, characterNames, options),
|
|
94
121
|
listSkills,
|
|
95
122
|
installSkill,
|
|
96
123
|
removeSkill,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle timeout helper for agent task execution (issue #53).
|
|
3
|
+
*
|
|
4
|
+
* The Copilot SDK's `sendAndWait(prompt, timeout)` enforces a wall-clock
|
|
5
|
+
* timeout. Long-running squad tasks were silently killed at 600s even when
|
|
6
|
+
* the agent was actively making progress (#42, #45). This helper replaces
|
|
7
|
+
* the wall-clock timeout with an **idle-reset** timeout: every progress
|
|
8
|
+
* event (tool execution, assistant message, turn boundary) resets the
|
|
9
|
+
* timer. The agent is only killed if it stops emitting events for `idleMs`
|
|
10
|
+
* — i.e. it is actually stuck, not just slow.
|
|
11
|
+
*
|
|
12
|
+
* On graceful timeout we capture the partial content emitted so far and
|
|
13
|
+
* surface it to the caller instead of throwing.
|
|
14
|
+
*/
|
|
15
|
+
const PROGRESS_EVENT_TYPES = new Set([
|
|
16
|
+
"assistant.turn_start",
|
|
17
|
+
"assistant.message_delta",
|
|
18
|
+
"assistant.message",
|
|
19
|
+
"assistant.turn_end",
|
|
20
|
+
"assistant.reasoning",
|
|
21
|
+
"assistant.reasoning_delta",
|
|
22
|
+
"tool.execution_start",
|
|
23
|
+
"tool.execution_progress",
|
|
24
|
+
"tool.execution_partial_result",
|
|
25
|
+
"tool.execution_complete",
|
|
26
|
+
]);
|
|
27
|
+
export async function sendWithIdleTimeout(session, prompt, opts) {
|
|
28
|
+
let accumulated = "";
|
|
29
|
+
let lastEventType;
|
|
30
|
+
let idleTimer;
|
|
31
|
+
let aborted = false;
|
|
32
|
+
let abortReason;
|
|
33
|
+
const triggerIdleAbort = () => {
|
|
34
|
+
if (aborted)
|
|
35
|
+
return;
|
|
36
|
+
aborted = true;
|
|
37
|
+
abortReason = "idle";
|
|
38
|
+
opts.onIdleTimeout?.({ lastEventType, idleMs: opts.idleMs });
|
|
39
|
+
void session.abort().catch(() => {
|
|
40
|
+
/* best-effort */
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const resetIdle = () => {
|
|
44
|
+
if (idleTimer)
|
|
45
|
+
clearTimeout(idleTimer);
|
|
46
|
+
idleTimer = setTimeout(triggerIdleAbort, opts.idleMs);
|
|
47
|
+
};
|
|
48
|
+
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
49
|
+
const delta = event?.data?.deltaContent;
|
|
50
|
+
if (typeof delta === "string")
|
|
51
|
+
accumulated += delta;
|
|
52
|
+
});
|
|
53
|
+
const unsubAll = session.on((event) => {
|
|
54
|
+
if (PROGRESS_EVENT_TYPES.has(event.type)) {
|
|
55
|
+
lastEventType = event.type;
|
|
56
|
+
opts.onProgress?.(event.type);
|
|
57
|
+
resetIdle();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
resetIdle();
|
|
61
|
+
try {
|
|
62
|
+
const response = await session.sendAndWait({ prompt }, opts.hardCapMs);
|
|
63
|
+
if (aborted) {
|
|
64
|
+
return {
|
|
65
|
+
content: response?.data?.content ?? accumulated,
|
|
66
|
+
timedOut: true,
|
|
67
|
+
timeoutReason: abortReason,
|
|
68
|
+
lastEventType,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
content: response?.data?.content ?? accumulated,
|
|
73
|
+
timedOut: false,
|
|
74
|
+
lastEventType,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
const looksLikeTimeout = /timeout/i.test(message);
|
|
80
|
+
if (aborted || looksLikeTimeout) {
|
|
81
|
+
if (!aborted && looksLikeTimeout) {
|
|
82
|
+
abortReason = "hard_cap";
|
|
83
|
+
opts.onHardCap?.();
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
content: accumulated ||
|
|
87
|
+
`(no output captured before timeout; last event: ${lastEventType ?? "none"})`,
|
|
88
|
+
timedOut: true,
|
|
89
|
+
timeoutReason: abortReason ?? "hard_cap",
|
|
90
|
+
lastEventType,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (idleTimer)
|
|
97
|
+
clearTimeout(idleTimer);
|
|
98
|
+
try {
|
|
99
|
+
unsubDelta();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
unsubAll();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=session-timeout.js.map
|
|
@@ -88,24 +88,27 @@ Squads are persistent project teams with **named specialist agents**. Each squad
|
|
|
88
88
|
Only specify an \`agent\` when the user **explicitly asks** to target a specific squad member by name.
|
|
89
89
|
|
|
90
90
|
### Team Leads
|
|
91
|
-
Every squad
|
|
91
|
+
Every squad **must** have a **dedicated team lead** — a PM / Senior Engineer whose **sole** responsibility is coordinating the team, delegating tasks, and reviewing results. The lead must NOT also own a hands-on engineering domain (no "Frontend Lead", "Test Manager", or "QA Lead" — those mix coordination with domain ownership). When building the squad, explicitly add a lead agent with a role title like "Senior Engineering Lead", "Project Manager", "Tech Lead", or "Principal Engineer" *in addition to* the domain specialists, then designate them with \`squad_set_lead\`. The lead receives delegated tasks (when no specific agent is targeted), breaks them into subtasks, assigns work to teammates via the lead-only \`delegate_to_teammate\` tool, and holds automatic veto power on PR promotion. This keeps coordination inside the squad rather than forcing IO to micro-manage assignments.
|
|
92
92
|
|
|
93
93
|
### Peer Review & QA Approvals
|
|
94
94
|
When an agent finishes a task, the other squad members automatically review the work and vote APPROVED or REJECTED. Reviews are recorded and emitted as \`task.review\` events.
|
|
95
95
|
|
|
96
|
-
- **Required**: every squad must have at least one agent designated as QA via \`squad_set_qa\`,
|
|
97
|
-
- \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when
|
|
98
|
-
- **QA agents and the team lead have veto power**: if any
|
|
96
|
+
- **Required**: every squad must have (1) a **dedicated team lead** designated via \`squad_set_lead\` whose role is coordination-only with no domain ownership, (2) at least one agent designated as QA via \`squad_set_qa\`, and (3) at least one agent whose role title implies a testing/quality focus (e.g. role contains "test", "qa", or "quality"). The QA and test-engineer roles can be the same agent, but the lead must be separate from the domain specialists.
|
|
97
|
+
- \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when any of these are missing — including when a lead is set but their role title looks like a domain specialist. Delegation is not blocked, but you should fix the gap before promoting work.
|
|
98
|
+
- **QA agents, test engineers, and the team lead all have veto power**: if any of them rejects, the PR stays as a draft. The lead's veto is automatic — no need to also designate them as QA. Designating your test engineer as QA gives them the same explicit veto authority.
|
|
99
99
|
- Non-QA rejections are advisory — they're recorded but don't block promotion.
|
|
100
100
|
- When all QA approvals pass (or no QA agents exist) and the task result contains a GitHub PR URL, the PR is automatically promoted from draft to ready via \`gh pr ready\`.
|
|
101
101
|
- Use \`squad_task_reviews\` to inspect the reviews on any completed task.
|
|
102
102
|
|
|
103
103
|
### Squad Build Checklist
|
|
104
104
|
After \`squad_create\`, before delegating real work:
|
|
105
|
-
1. Add agents with \`squad_add_agent\` (use roles tailored to the project's stack).
|
|
106
|
-
2.
|
|
107
|
-
3.
|
|
108
|
-
4. Designate
|
|
105
|
+
1. Add domain-specialist agents with \`squad_add_agent\` (use roles tailored to the project's stack).
|
|
106
|
+
2. Add a **dedicated team lead agent** with a coordination-only role like "Senior Engineering Lead", "Project Manager", "Tech Lead", or "Principal Engineer". The lead must NOT also own a hands-on domain (no "Frontend Lead" — that's still a frontend engineer).
|
|
107
|
+
3. Include at least one **test/quality engineer** role (e.g. "Integration Test Engineer", "QA Specialist", "Quality Reviewer"). This is a separate agent from the lead. Their charter should explicitly own the project's test suite — for the IO squad this means owning \`src/**/*.test.ts\` plus running \`npm run build\` / \`vue-tsc\` on every PR before promotion.
|
|
108
|
+
4. Designate the team lead with \`squad_set_lead\`. The lead automatically holds veto power on PR promotion.
|
|
109
|
+
5. Designate at least one QA reviewer with \`squad_set_qa\` (often the same agent as the test engineer). QA reviewers also hold veto power.
|
|
110
|
+
|
|
111
|
+
**No exemptions.** The squad that owns the IO codebase itself (\`michaeljolley-io\`) is held to the same checklist as every other squad. If \`squad_status\` ever shows a coverage warning for the IO squad, fix it before shipping further work — IO does not get to ship rules it doesn't follow.
|
|
109
112
|
|
|
110
113
|
### Scheduled Stand-ups
|
|
111
114
|
Squads can be put on a recurring cron-style schedule. At the scheduled time IO wakes the team lead, who runs the agenda by delegating to teammates. This runs in the background even when no human is in the TUI/Telegram.
|
|
@@ -187,6 +190,7 @@ The model is selected automatically. Tell the user which model tier was chosen w
|
|
|
187
190
|
7. **Use your tools proactively.** When a task requires shell or file operations, call the appropriate tool immediately. Do not describe what command you *would* run — just run it. For git operations, use the \`shell\` tool. For file operations, use \`file_ops\` or \`shell\`.
|
|
188
191
|
8. **Never fabricate errors.** Only report errors that a tool actually returned. If you haven't called a tool, you don't know whether it will succeed or fail.
|
|
189
192
|
9. **Prefer your custom tools over built-in tools.** Always use \`shell\` instead of \`bash\`. Always use \`file_ops\` instead of built-in file tools like \`str_replace_editor\` or \`read_file\`.
|
|
193
|
+
10. **Pull main before starting code work.** Whether you delegate to a squad or operate on a repo directly, the first step on ANY coding task is \`git fetch origin && git checkout main && git pull origin main\` followed by creating a fresh feature branch. Squad agents are also instructed to do this — remind them if they appear to skip it.
|
|
190
194
|
${selfEditBlock}${memoryBlock}`;
|
|
191
195
|
}
|
|
192
196
|
//# sourceMappingURL=system-message.js.map
|