heyio 0.4.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.
@@ -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";
@@ -11,6 +11,10 @@ import { abortOrchestrator } from "../copilot/orchestrator.js";
11
11
  import { getActiveTasks, getTask, listRecentTasks } from "../store/tasks.js";
12
12
  import { IO_VERSION } from "../paths.js";
13
13
  import { requireAuth } from "./auth.js";
14
+ import { listSchedules, getSchedule, deleteSchedule, setScheduleEnabled } from "../store/schedules.js";
15
+ import { listIoSchedules, getIoSchedule, deleteIoSchedule, setIoScheduleEnabled } from "../store/io-schedules.js";
16
+ import { runScheduleNow } from "../copilot/scheduler.js";
17
+ import { runIoScheduleNow } from "../copilot/io-scheduler.js";
14
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
19
  const WEB_DIST = path.resolve(__dirname, "../../web-dist");
16
20
  let messageHandler;
@@ -29,7 +33,7 @@ export async function startApiServer() {
29
33
  app.use(express.json());
30
34
  app.use((_req, res, next) => {
31
35
  res.setHeader("Access-Control-Allow-Origin", "*");
32
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
36
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
33
37
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
34
38
  next();
35
39
  });
@@ -63,6 +67,36 @@ export async function startApiServer() {
63
67
  res.status(500).json({ error: "Failed to list skills" });
64
68
  }
65
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
+ });
66
100
  // Squads endpoints
67
101
  api.get("/squads", (_req, res) => {
68
102
  try {
@@ -226,6 +260,172 @@ export async function startApiServer() {
226
260
  res.status(500).json({ error: "Failed to cancel task" });
227
261
  }
228
262
  });
263
+ // Schedules endpoints
264
+ api.get("/schedules", (_req, res) => {
265
+ try {
266
+ const io = listIoSchedules();
267
+ const squads = listSchedules();
268
+ res.json({ io, squads });
269
+ }
270
+ catch (e) {
271
+ console.error("Error listing schedules:", e);
272
+ res.status(500).json({ error: "Failed to list schedules" });
273
+ }
274
+ });
275
+ // Squad schedule lifecycle
276
+ api.post("/schedules/squads/:id/pause", (req, res) => {
277
+ const id = Number(req.params.id);
278
+ if (Number.isNaN(id)) {
279
+ res.status(400).json({ error: "Invalid schedule id" });
280
+ return;
281
+ }
282
+ try {
283
+ const ok = setScheduleEnabled(id, false);
284
+ if (!ok) {
285
+ res.status(404).json({ error: "Squad schedule not found" });
286
+ return;
287
+ }
288
+ res.json({ ok: true, schedule: getSchedule(id) });
289
+ }
290
+ catch (e) {
291
+ console.error("Error pausing squad schedule:", e);
292
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
293
+ }
294
+ });
295
+ api.post("/schedules/squads/:id/resume", (req, res) => {
296
+ const id = Number(req.params.id);
297
+ if (Number.isNaN(id)) {
298
+ res.status(400).json({ error: "Invalid schedule id" });
299
+ return;
300
+ }
301
+ try {
302
+ const ok = setScheduleEnabled(id, true);
303
+ if (!ok) {
304
+ res.status(404).json({ error: "Squad schedule not found" });
305
+ return;
306
+ }
307
+ res.json({ ok: true, schedule: getSchedule(id) });
308
+ }
309
+ catch (e) {
310
+ console.error("Error resuming squad schedule:", e);
311
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
312
+ }
313
+ });
314
+ api.post("/schedules/squads/:id/run-now", async (req, res) => {
315
+ const id = Number(req.params.id);
316
+ if (Number.isNaN(id)) {
317
+ res.status(400).json({ error: "Invalid schedule id" });
318
+ return;
319
+ }
320
+ try {
321
+ const result = await runScheduleNow(id);
322
+ if (!result.ok) {
323
+ res.status(404).json({ error: result.error ?? "Squad schedule not found" });
324
+ return;
325
+ }
326
+ res.json({ ok: true });
327
+ }
328
+ catch (e) {
329
+ console.error("Error running squad schedule now:", e);
330
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
331
+ }
332
+ });
333
+ api.delete("/schedules/squads/:id", (req, res) => {
334
+ const id = Number(req.params.id);
335
+ if (Number.isNaN(id)) {
336
+ res.status(400).json({ error: "Invalid schedule id" });
337
+ return;
338
+ }
339
+ try {
340
+ const ok = deleteSchedule(id);
341
+ if (!ok) {
342
+ res.status(404).json({ error: "Squad schedule not found" });
343
+ return;
344
+ }
345
+ res.json({ ok: true });
346
+ }
347
+ catch (e) {
348
+ console.error("Error deleting squad schedule:", e);
349
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
350
+ }
351
+ });
352
+ // IO schedule lifecycle
353
+ api.post("/schedules/io/:id/pause", (req, res) => {
354
+ const id = Number(req.params.id);
355
+ if (Number.isNaN(id)) {
356
+ res.status(400).json({ error: "Invalid schedule id" });
357
+ return;
358
+ }
359
+ try {
360
+ const ok = setIoScheduleEnabled(id, false);
361
+ if (!ok) {
362
+ res.status(404).json({ error: "IO schedule not found" });
363
+ return;
364
+ }
365
+ res.json({ ok: true, schedule: getIoSchedule(id) });
366
+ }
367
+ catch (e) {
368
+ console.error("Error pausing IO schedule:", e);
369
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
370
+ }
371
+ });
372
+ api.post("/schedules/io/:id/resume", (req, res) => {
373
+ const id = Number(req.params.id);
374
+ if (Number.isNaN(id)) {
375
+ res.status(400).json({ error: "Invalid schedule id" });
376
+ return;
377
+ }
378
+ try {
379
+ const ok = setIoScheduleEnabled(id, true);
380
+ if (!ok) {
381
+ res.status(404).json({ error: "IO schedule not found" });
382
+ return;
383
+ }
384
+ res.json({ ok: true, schedule: getIoSchedule(id) });
385
+ }
386
+ catch (e) {
387
+ console.error("Error resuming IO schedule:", e);
388
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
389
+ }
390
+ });
391
+ api.post("/schedules/io/:id/run-now", async (req, res) => {
392
+ const id = Number(req.params.id);
393
+ if (Number.isNaN(id)) {
394
+ res.status(400).json({ error: "Invalid schedule id" });
395
+ return;
396
+ }
397
+ try {
398
+ const ok = await runIoScheduleNow(id);
399
+ if (!ok) {
400
+ res.status(404).json({ error: "IO schedule not found" });
401
+ return;
402
+ }
403
+ res.json({ ok: true });
404
+ }
405
+ catch (e) {
406
+ console.error("Error running IO schedule now:", e);
407
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
408
+ }
409
+ });
410
+ api.delete("/schedules/io/:id", (req, res) => {
411
+ const id = Number(req.params.id);
412
+ if (Number.isNaN(id)) {
413
+ res.status(400).json({ error: "Invalid schedule id" });
414
+ return;
415
+ }
416
+ try {
417
+ const ok = deleteIoSchedule(id);
418
+ if (!ok) {
419
+ res.status(404).json({ error: "IO schedule not found" });
420
+ return;
421
+ }
422
+ res.json({ ok: true });
423
+ }
424
+ catch (e) {
425
+ console.error("Error deleting IO schedule:", e);
426
+ res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
427
+ }
428
+ });
229
429
  // Chat endpoints
230
430
  api.post("/message", async (req, res) => {
231
431
  const { text } = req.body;
@@ -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 response = await session.sendAndWait({ prompt: task }, 600_000);
165
- const result = response?.data?.content ?? "Task completed (no output)";
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. When you receive a task, your job is to:
304
- 1. Break it down into concrete subtasks
305
- 2. Assign each subtask to the most appropriate teammate using the \`delegate_to_teammate\` tool
306
- 3. Collect results and synthesize a final summary
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 response = await session.sendAndWait({ prompt: task }, 300_000);
556
- const result = response?.data?.content ?? "(teammate returned no output)";
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