heyio 0.1.33 → 0.2.1

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.
@@ -5,7 +5,9 @@ import express from "express";
5
5
  import { config } from "../config.js";
6
6
  import { listSkills } from "../copilot/skills.js";
7
7
  import { listSquads, createSquad, listSquadAgents } from "../store/squads.js";
8
- import { getAgentInfo } from "../copilot/agents.js";
8
+ import { getAgentInfo, cancelAgentTask, getTaskEvents, subscribeToTaskEvents } from "../copilot/agents.js";
9
+ import { abortOrchestrator } from "../copilot/orchestrator.js";
10
+ import { getActiveTasks, getTask, listRecentTasks } from "../store/tasks.js";
9
11
  import { IO_VERSION } from "../paths.js";
10
12
  import { requireAuth } from "./auth.js";
11
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -90,7 +92,21 @@ export async function startApiServer() {
90
92
  try {
91
93
  const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
92
94
  const agents = listSquadAgents(slug);
93
- res.json({ agents });
95
+ const activeTasks = getActiveTasks();
96
+ const taskByKey = new Map();
97
+ for (const t of activeTasks) {
98
+ taskByKey.set(t.agent_slug, { task_id: t.task_id, description: t.description });
99
+ }
100
+ const enriched = agents.map((a) => {
101
+ const key = `${slug}:${a.character_name}`;
102
+ const task = taskByKey.get(key) ?? taskByKey.get(slug);
103
+ return {
104
+ ...a,
105
+ currentTaskId: task?.task_id ?? null,
106
+ currentTask: task?.description ?? null,
107
+ };
108
+ });
109
+ res.json({ agents: enriched });
94
110
  }
95
111
  catch (e) {
96
112
  console.error("Error listing squad agents:", e);
@@ -108,6 +124,93 @@ export async function startApiServer() {
108
124
  res.status(500).json({ error: "Failed to list agents" });
109
125
  }
110
126
  });
127
+ // Task history endpoints
128
+ api.get("/tasks", (req, res) => {
129
+ try {
130
+ const limitRaw = req.query.limit;
131
+ const parsed = typeof limitRaw === "string" ? parseInt(limitRaw, 10) : NaN;
132
+ const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
133
+ const tasks = listRecentTasks(limit);
134
+ res.json({ tasks });
135
+ }
136
+ catch (e) {
137
+ console.error("Error listing tasks:", e);
138
+ res.status(500).json({ error: "Failed to list tasks" });
139
+ }
140
+ });
141
+ api.get("/tasks/:taskId", (req, res) => {
142
+ try {
143
+ const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
144
+ const task = getTask(taskId);
145
+ if (!task) {
146
+ res.status(404).json({ error: "Task not found" });
147
+ return;
148
+ }
149
+ res.json({ task });
150
+ }
151
+ catch (e) {
152
+ console.error("Error fetching task:", e);
153
+ res.status(500).json({ error: "Failed to fetch task" });
154
+ }
155
+ });
156
+ api.get("/tasks/:taskId/events", (req, res) => {
157
+ const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
158
+ res.setHeader("Content-Type", "text/event-stream");
159
+ res.setHeader("Cache-Control", "no-cache");
160
+ res.setHeader("Connection", "keep-alive");
161
+ res.setHeader("X-Accel-Buffering", "no");
162
+ res.flushHeaders();
163
+ const send = (ev) => {
164
+ try {
165
+ res.write(`data: ${JSON.stringify(ev)}\n\n`);
166
+ }
167
+ catch {
168
+ // client likely disconnected; cleanup happens on req.close
169
+ }
170
+ };
171
+ // Replay buffered events first so a late subscriber sees the full thread
172
+ for (const ev of getTaskEvents(taskId))
173
+ send(ev);
174
+ // Subscribe to live events
175
+ const unsubscribe = subscribeToTaskEvents(taskId, send);
176
+ // Heartbeat to keep proxies / browsers from closing the connection
177
+ const heartbeat = setInterval(() => {
178
+ try {
179
+ res.write(": ping\n\n");
180
+ }
181
+ catch { /* ignore */ }
182
+ }, 15000);
183
+ req.on("close", () => {
184
+ clearInterval(heartbeat);
185
+ unsubscribe();
186
+ });
187
+ });
188
+ // Stop / cancel endpoints
189
+ api.post("/orchestrator/abort", async (_req, res) => {
190
+ try {
191
+ const aborted = await abortOrchestrator();
192
+ res.json({ aborted });
193
+ }
194
+ catch (e) {
195
+ console.error("Error aborting orchestrator:", e);
196
+ res.status(500).json({ error: "Failed to abort orchestrator" });
197
+ }
198
+ });
199
+ api.post("/tasks/:taskId/cancel", async (req, res) => {
200
+ try {
201
+ const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
202
+ const cancelled = await cancelAgentTask(taskId);
203
+ if (!cancelled) {
204
+ res.status(404).json({ error: "Task not found or not running" });
205
+ return;
206
+ }
207
+ res.json({ cancelled: true });
208
+ }
209
+ catch (e) {
210
+ console.error("Error cancelling task:", e);
211
+ res.status(500).json({ error: "Failed to cancel task" });
212
+ }
213
+ });
111
214
  // Chat endpoints
112
215
  api.post("/message", async (req, res) => {
113
216
  const { text } = req.body;
@@ -150,14 +253,25 @@ export async function startApiServer() {
150
253
  app.use(express.static(WEB_DIST));
151
254
  console.log("[io] Web frontend enabled");
152
255
  }
153
- // Backward-compat: mount API at / (after static files so HTML/CSS/JS are served first)
154
- app.use("/", api);
155
- // SPA fallback — serve index.html for any unmatched route
256
+ // SPA fallback for browser navigation: when the web frontend is built,
257
+ // serve index.html for any GET request that accepts HTML and isn't an API
258
+ // call. This lets vue-router handle client-side routes like /chat, /skills,
259
+ // /squads, etc. on direct URL access and page refresh. Programmatic clients
260
+ // (curl, fetch without Accept: text/html) fall through to the backward-compat
261
+ // API mount below.
156
262
  if (existsSync(WEB_DIST)) {
157
- app.get("/{*splat}", (_req, res) => {
263
+ app.get(/.*/, (req, res, next) => {
264
+ if (req.path.startsWith("/api/"))
265
+ return next();
266
+ const accept = req.headers.accept ?? "";
267
+ if (!accept.includes("text/html"))
268
+ return next();
158
269
  res.sendFile(path.join(WEB_DIST, "index.html"));
159
270
  });
160
271
  }
272
+ // Backward-compat: mount API at / for non-browser clients (after static files
273
+ // and SPA fallback so frontend routes are not intercepted).
274
+ app.use("/", api);
161
275
  return new Promise((resolve) => {
162
276
  app.listen(config.port, () => {
163
277
  console.log(`[io] Server listening on port ${config.port}`);
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { EventEmitter } from "events";
2
3
  import { execSync } from "child_process";
3
4
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, } from "fs";
4
5
  import { join, dirname, resolve } from "path";
@@ -7,8 +8,8 @@ import { defineTool, approveAll } from "@github/copilot-sdk";
7
8
  import { z } from "zod";
8
9
  import { getClient } from "./client.js";
9
10
  import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
10
- import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
11
- import { createTask, completeTask, failTask, getActiveTasks, } from "../store/tasks.js";
11
+ import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
12
+ import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
12
13
  import { SESSIONS_DIR } from "../paths.js";
13
14
  import { getUniverse } from "./universes.js";
14
15
  // Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
@@ -20,8 +21,10 @@ function agentSessionKey(squadSlug, characterName) {
20
21
  export function getAgentInfo() {
21
22
  const activeTasks = getActiveTasks();
22
23
  const tasksByAgent = new Map();
24
+ const taskIdsByAgent = new Map();
23
25
  for (const task of activeTasks) {
24
26
  tasksByAgent.set(task.agent_slug, task.description);
27
+ taskIdsByAgent.set(task.agent_slug, task.task_id);
25
28
  }
26
29
  const agents = [];
27
30
  const seenSquads = new Set();
@@ -35,6 +38,7 @@ export function getAgentInfo() {
35
38
  if (characterName) {
36
39
  const agent = getSquadAgent(squadSlug, characterName);
37
40
  const currentTask = tasksByAgent.get(key) ?? tasksByAgent.get(squadSlug);
41
+ const currentTaskId = taskIdsByAgent.get(key) ?? taskIdsByAgent.get(squadSlug);
38
42
  agents.push({
39
43
  slug: squadSlug,
40
44
  name: agent ? `${agent.character_name} (${agent.role_title})` : characterName,
@@ -43,21 +47,61 @@ export function getAgentInfo() {
43
47
  universe: squad?.universe ?? undefined,
44
48
  status: agent?.status === "working" ? "working" : currentTask ? "working" : "idle",
45
49
  currentTask,
50
+ currentTaskId,
46
51
  });
47
52
  }
48
53
  else {
49
54
  // Legacy generic agent
50
55
  const currentTask = tasksByAgent.get(squadSlug);
56
+ const currentTaskId = taskIdsByAgent.get(squadSlug);
51
57
  agents.push({
52
58
  slug: squadSlug,
53
59
  name: squad?.name ?? squadSlug,
54
60
  status: currentTask ? "working" : squad?.status === "error" ? "error" : "idle",
55
61
  currentTask,
62
+ currentTaskId,
56
63
  });
57
64
  }
58
65
  }
59
66
  return agents;
60
67
  }
68
+ const STREAM_EVENT_TYPES = new Set([
69
+ "assistant.turn_start",
70
+ "assistant.intent",
71
+ "assistant.reasoning",
72
+ "assistant.reasoning_delta",
73
+ "assistant.message_delta",
74
+ "assistant.message",
75
+ "assistant.turn_end",
76
+ "tool.execution_start",
77
+ "tool.execution_progress",
78
+ "tool.execution_partial_result",
79
+ "tool.execution_complete",
80
+ "session.error",
81
+ "session.warning",
82
+ ]);
83
+ const MAX_TASK_EVENTS = 1000;
84
+ const taskEventBuffers = new Map();
85
+ const taskEventEmitter = new EventEmitter();
86
+ taskEventEmitter.setMaxListeners(0);
87
+ function recordTaskEvent(taskId, ev) {
88
+ let buf = taskEventBuffers.get(taskId);
89
+ if (!buf) {
90
+ buf = [];
91
+ taskEventBuffers.set(taskId, buf);
92
+ }
93
+ buf.push(ev);
94
+ if (buf.length > MAX_TASK_EVENTS)
95
+ buf.splice(0, buf.length - MAX_TASK_EVENTS);
96
+ taskEventEmitter.emit(taskId, ev);
97
+ }
98
+ export function getTaskEvents(taskId) {
99
+ return taskEventBuffers.get(taskId) ?? [];
100
+ }
101
+ export function subscribeToTaskEvents(taskId, listener) {
102
+ taskEventEmitter.on(taskId, listener);
103
+ return () => taskEventEmitter.off(taskId, listener);
104
+ }
61
105
  export async function delegateToAgent(squadSlug, task, onComplete, targetAgent) {
62
106
  const squad = getSquad(squadSlug);
63
107
  if (!squad) {
@@ -72,10 +116,17 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
72
116
  }
73
117
  }
74
118
  else {
75
- // If squad has named agents, pick the best match (first idle, or first one)
76
- const agents = listSquadAgents(squadSlug);
77
- if (agents.length > 0) {
78
- agent = agents.find((a) => a.status === "idle") ?? agents[0];
119
+ // Prefer the designated team lead if one exists; otherwise fall back to
120
+ // the first idle agent (or just the first agent on the roster).
121
+ const lead = getSquadLead(squadSlug);
122
+ if (lead) {
123
+ agent = lead;
124
+ }
125
+ else {
126
+ const agents = listSquadAgents(squadSlug);
127
+ if (agents.length > 0) {
128
+ agent = agents.find((a) => a.status === "idle") ?? agents[0];
129
+ }
79
130
  }
80
131
  }
81
132
  const session = agent
@@ -89,6 +140,22 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
89
140
  updateSquadStatus(squadSlug, "working");
90
141
  if (agent)
91
142
  updateAgentStatus(squadSlug, agent.character_name, "working");
143
+ // Subscribe to the agent session's events for the duration of this task so
144
+ // the web UI can preview the agent's "thread of consciousness" live.
145
+ recordTaskEvent(taskId, {
146
+ ts: Date.now(),
147
+ type: "task.start",
148
+ data: { taskId, agentKey, description: task },
149
+ });
150
+ const unsubscribe = session.on((event) => {
151
+ if (!STREAM_EVENT_TYPES.has(event.type))
152
+ return;
153
+ recordTaskEvent(taskId, {
154
+ ts: Date.now(),
155
+ type: event.type,
156
+ data: event.data ?? null,
157
+ });
158
+ });
92
159
  // Run the task in the background — return taskId immediately
93
160
  void (async () => {
94
161
  try {
@@ -98,6 +165,20 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
98
165
  updateSquadStatus(squadSlug, "idle");
99
166
  if (agent)
100
167
  updateAgentStatus(squadSlug, agent.character_name, "idle");
168
+ recordTaskEvent(taskId, { ts: Date.now(), type: "task.done", data: { result } });
169
+ try {
170
+ await runPeerReview(squadSlug, agent?.character_name ?? "", taskId, task, result);
171
+ }
172
+ catch (reviewErr) {
173
+ console.error("[io] Peer review error:", reviewErr instanceof Error ? reviewErr.message : reviewErr);
174
+ recordTaskEvent(taskId, {
175
+ ts: Date.now(),
176
+ type: "task.review_error",
177
+ data: {
178
+ error: reviewErr instanceof Error ? reviewErr.message : String(reviewErr),
179
+ },
180
+ });
181
+ }
101
182
  onComplete(taskId, result);
102
183
  }
103
184
  catch (err) {
@@ -106,6 +187,13 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
106
187
  updateSquadStatus(squadSlug, "error");
107
188
  if (agent)
108
189
  updateAgentStatus(squadSlug, agent.character_name, "error");
190
+ recordTaskEvent(taskId, { ts: Date.now(), type: "task.failed", data: { error: message } });
191
+ }
192
+ finally {
193
+ try {
194
+ unsubscribe();
195
+ }
196
+ catch { /* ignore */ }
109
197
  }
110
198
  })();
111
199
  const agentLabel = agent
@@ -173,7 +261,34 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
173
261
  const universeName = squad.universe
174
262
  ? getUniverse(squad.universe)?.name ?? squad.universe
175
263
  : "Unknown";
176
- const agentTools = buildAgentTools(squadSlug);
264
+ const isLead = agent.is_lead === 1;
265
+ const agentTools = buildAgentTools(squadSlug, isLead);
266
+ let leadSection = "";
267
+ if (isLead) {
268
+ const teammates = listSquadAgents(squadSlug).filter((a) => a.character_name !== agent.character_name);
269
+ const roster = teammates.length > 0
270
+ ? teammates
271
+ .map((t) => {
272
+ const charter = t.charter
273
+ ? t.charter.length > 200
274
+ ? t.charter.slice(0, 200) + "…"
275
+ : t.charter
276
+ : "(no charter)";
277
+ return `- **${t.character_name}** — ${t.role_title}: ${charter}`;
278
+ })
279
+ .join("\n")
280
+ : "_(no other agents on this squad yet — ask IO to add some)_";
281
+ leadSection = `
282
+
283
+ ## Team Lead Role
284
+ You are the team lead for this squad. When you receive a task, your job is to:
285
+ 1. Break it down into concrete subtasks
286
+ 2. Assign each subtask to the most appropriate teammate using the \`delegate_to_teammate\` tool
287
+ 3. Collect results and synthesize a final summary
288
+
289
+ ## Your Team
290
+ ${roster}`;
291
+ }
177
292
  const systemMessage = `You are ${agent.character_name}, a specialist agent on the "${squad.name}" project team (${universeName} universe).
178
293
 
179
294
  ## Your Identity
@@ -188,7 +303,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
188
303
  - **Path**: ${squad.project_path}
189
304
 
190
305
  ## Past Decisions
191
- ${decisions}
306
+ ${decisions}${leadSection}
192
307
 
193
308
  ## Instructions
194
309
  You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
@@ -275,7 +390,7 @@ Log important decisions with squad_log_decision so they persist.`,
275
390
  agentSessions.set(squadSlug, session);
276
391
  return session;
277
392
  }
278
- function buildAgentTools(squadSlug) {
393
+ function buildAgentTools(squadSlug, isLead = false) {
279
394
  const shell = defineTool("shell", {
280
395
  description: "Run a shell command. Use for git, build tools, file operations, etc.",
281
396
  skipPermission: true,
@@ -393,7 +508,50 @@ function buildAgentTools(squadSlug) {
393
508
  }
394
509
  },
395
510
  });
396
- return [shell, fileOps, squadLogDecision];
511
+ const tools = [shell, fileOps, squadLogDecision];
512
+ if (isLead) {
513
+ const delegateToTeammate = defineTool("delegate_to_teammate", {
514
+ 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.",
515
+ skipPermission: true,
516
+ parameters: z.object({
517
+ teammate: z
518
+ .string()
519
+ .describe("The teammate's character_name (e.g., 'Optimus Prime')"),
520
+ task: z
521
+ .string()
522
+ .describe("The concrete task or subtask the teammate should perform"),
523
+ }),
524
+ handler: async ({ teammate, task }) => {
525
+ try {
526
+ const teammateAgent = getSquadAgent(squadSlug, teammate);
527
+ if (!teammateAgent) {
528
+ return `Error: teammate "${teammate}" not found in squad "${squadSlug}". Use squad_agents to list the roster.`;
529
+ }
530
+ if (teammateAgent.is_lead === 1) {
531
+ return `Error: "${teammate}" is the team lead. Delegate to a non-lead teammate.`;
532
+ }
533
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
534
+ try {
535
+ const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
536
+ const response = await session.sendAndWait({ prompt: task }, 300_000);
537
+ const result = response?.data?.content ?? "(teammate returned no output)";
538
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
539
+ return result;
540
+ }
541
+ catch (err) {
542
+ updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
543
+ const message = err instanceof Error ? err.message : String(err);
544
+ return `Error from teammate "${teammate}": ${message}`;
545
+ }
546
+ }
547
+ catch (err) {
548
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
549
+ }
550
+ },
551
+ });
552
+ tools.push(delegateToTeammate);
553
+ }
554
+ return tools;
397
555
  }
398
556
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
399
557
  if (depth >= maxDepth)
@@ -413,4 +571,150 @@ function walkDirectory(dir, maxDepth = 3, depth = 0) {
413
571
  }
414
572
  return results;
415
573
  }
574
+ /**
575
+ * Run a peer review phase after a task completes. Every other agent on the
576
+ * squad reviews the work and votes APPROVED / REJECTED. QA agents
577
+ * (is_qa === 1) have veto power: if any QA agent rejects, the PR is left as
578
+ * draft. Otherwise, any GitHub PR URL found in the task result is promoted
579
+ * from draft to ready via `gh pr ready`.
580
+ */
581
+ async function runPeerReview(squadSlug, originalAgentCharacter, taskId, taskDescription, taskResult) {
582
+ const reviewers = listSquadAgents(squadSlug).filter((a) => a.character_name !== originalAgentCharacter);
583
+ if (reviewers.length === 0) {
584
+ recordTaskEvent(taskId, {
585
+ ts: Date.now(),
586
+ type: "task.review_complete",
587
+ data: { promoted: false, reason: "No other agents to review" },
588
+ });
589
+ return;
590
+ }
591
+ const reviewPrompt = `You are reviewing the following completed task:
592
+
593
+ ## Task
594
+ ${taskDescription}
595
+
596
+ ## Work Done
597
+ ${taskResult}
598
+
599
+ Review the work. Respond with:
600
+ - First line: APPROVED or REJECTED
601
+ - Remaining lines: your review comments`;
602
+ const reviews = [];
603
+ for (const reviewer of reviewers) {
604
+ try {
605
+ const session = await getOrCreateAgentSession(squadSlug, reviewer, `Peer review of task ${taskId}`);
606
+ const response = await session.sendAndWait({ prompt: reviewPrompt }, 300_000);
607
+ const content = response?.data?.content ?? "";
608
+ const lines = content.split(/\r?\n/);
609
+ const firstLine = (lines[0] ?? "").trim().toUpperCase();
610
+ const approved = firstLine.includes("APPROVED") && !firstLine.includes("REJECTED");
611
+ const comments = lines.slice(1).join("\n").trim() || null;
612
+ createReview(taskId, squadSlug, reviewer.character_name, approved, comments ?? undefined);
613
+ recordTaskEvent(taskId, {
614
+ ts: Date.now(),
615
+ type: "task.review",
616
+ data: {
617
+ reviewer: reviewer.character_name,
618
+ is_qa: reviewer.is_qa === 1,
619
+ approved,
620
+ comments,
621
+ },
622
+ });
623
+ reviews.push({
624
+ reviewer: reviewer.character_name,
625
+ is_qa: reviewer.is_qa === 1,
626
+ approved,
627
+ comments: comments ?? "",
628
+ });
629
+ }
630
+ catch (err) {
631
+ const message = err instanceof Error ? err.message : String(err);
632
+ console.error(`[io] Reviewer ${reviewer.character_name} failed:`, message);
633
+ recordTaskEvent(taskId, {
634
+ ts: Date.now(),
635
+ type: "task.review_error",
636
+ data: { reviewer: reviewer.character_name, error: message },
637
+ });
638
+ }
639
+ }
640
+ const qaRejection = reviews.find((r) => r.is_qa && !r.approved);
641
+ const prMatch = taskResult.match(/https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
642
+ if (qaRejection) {
643
+ recordTaskEvent(taskId, {
644
+ ts: Date.now(),
645
+ type: "task.review_complete",
646
+ data: {
647
+ promoted: false,
648
+ reason: `QA veto from ${qaRejection.reviewer}`,
649
+ prUrl: prMatch ? prMatch[0] : null,
650
+ },
651
+ });
652
+ return;
653
+ }
654
+ if (!prMatch) {
655
+ recordTaskEvent(taskId, {
656
+ ts: Date.now(),
657
+ type: "task.review_complete",
658
+ data: { promoted: false, reason: "No PR URL found in task result" },
659
+ });
660
+ return;
661
+ }
662
+ const [prUrl, owner, repo, prNumber] = prMatch;
663
+ try {
664
+ execSync(`gh pr ready ${prNumber} --repo ${owner}/${repo}`, {
665
+ encoding: "utf-8",
666
+ timeout: 30_000,
667
+ env: { ...process.env, HOME: process.env.HOME || homedir() },
668
+ });
669
+ recordTaskEvent(taskId, {
670
+ ts: Date.now(),
671
+ type: "task.review_complete",
672
+ data: { promoted: true, prUrl },
673
+ });
674
+ }
675
+ catch (err) {
676
+ const message = err instanceof Error ? err.message : String(err);
677
+ recordTaskEvent(taskId, {
678
+ ts: Date.now(),
679
+ type: "task.review_complete",
680
+ data: { promoted: false, reason: `gh pr ready failed: ${message}`, prUrl },
681
+ });
682
+ }
683
+ }
684
+ /**
685
+ * Cancel a running agent task by aborting its session and marking the task
686
+ * cancelled. Returns true if the task existed and was running.
687
+ */
688
+ export async function cancelAgentTask(taskId) {
689
+ const task = getTask(taskId);
690
+ if (!task || task.status !== "running")
691
+ return false;
692
+ const sessionKey = task.agent_slug;
693
+ const session = agentSessions.get(sessionKey);
694
+ if (session) {
695
+ try {
696
+ await session.abort();
697
+ }
698
+ catch (err) {
699
+ console.error("[io] Error aborting agent session:", err instanceof Error ? err.message : err);
700
+ }
701
+ }
702
+ cancelTask(taskId);
703
+ recordTaskEvent(taskId, { ts: Date.now(), type: "task.cancelled", data: { reason: "Cancelled by user" } });
704
+ // sessionKey is "squadSlug" or "squadSlug:characterName"
705
+ const [squadSlug, characterName] = sessionKey.split(":");
706
+ if (squadSlug) {
707
+ try {
708
+ updateSquadStatus(squadSlug, "idle");
709
+ }
710
+ catch { /* ignore */ }
711
+ }
712
+ if (squadSlug && characterName) {
713
+ try {
714
+ updateAgentStatus(squadSlug, characterName, "idle");
715
+ }
716
+ catch { /* ignore */ }
717
+ }
718
+ return true;
719
+ }
416
720
  //# sourceMappingURL=agents.js.map
@@ -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 } from "../store/tasks.js";
7
- import { getSquad, listSquads, createSquad, deleteSquad, logDecision, getDecisionsSummary, updateSquadStatus, addSquadAgent, listSquadAgents, removeSquadAgent, } from "../store/squads.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";
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";
@@ -73,8 +73,23 @@ function getToolDeps() {
73
73
  model_tier: a.model_tier,
74
74
  personality: a.personality,
75
75
  status: a.status,
76
+ is_lead: a.is_lead,
77
+ is_qa: a.is_qa,
76
78
  })),
77
79
  removeSquadAgent,
80
+ setSquadLead,
81
+ getSquadLead: (slug) => {
82
+ const lead = getSquadLead(slug);
83
+ return lead
84
+ ? { character_name: lead.character_name, role_title: lead.role_title }
85
+ : undefined;
86
+ },
87
+ setSquadQA,
88
+ getTaskReviews: (taskId) => getTaskReviews(taskId).map((r) => ({
89
+ reviewer_character: r.reviewer_character,
90
+ approved: r.approved,
91
+ comments: r.comments,
92
+ })),
78
93
  listSkills,
79
94
  installSkill,
80
95
  removeSkill,
@@ -92,6 +107,30 @@ function toolFingerprint(tools) {
92
107
  const names = (tools ?? []).map((t) => t.name).sort().join(",");
93
108
  return crypto.createHash("sha256").update(`${IO_VERSION}:${names}`).digest("hex").slice(0, 16);
94
109
  }
110
+ function buildSquadRoster() {
111
+ const squads = listSquads();
112
+ if (squads.length === 0)
113
+ return "";
114
+ return squads
115
+ .map((s) => {
116
+ const agents = listSquadAgents(s.slug);
117
+ const lead = agents.find((a) => a.is_lead === 1);
118
+ const agentList = agents
119
+ .map((a) => {
120
+ const badges = [
121
+ a.is_lead === 1 ? "⭐ LEAD" : "",
122
+ a.is_qa === 1 ? "šŸ›”ļø QA" : "",
123
+ ]
124
+ .filter(Boolean)
125
+ .join(", ");
126
+ return ` - ${a.character_name} (${a.role_title})${badges ? ` [${badges}]` : ""}`;
127
+ })
128
+ .join("\n");
129
+ const leadLine = lead ? `\nTeam Lead: ${lead.character_name}` : "";
130
+ return `**${s.name}** (\`${s.slug}\`) — ${s.status}\nšŸ“ ${s.project_path}${leadLine}\n${agentList || " _(no agents yet)_"}`;
131
+ })
132
+ .join("\n\n");
133
+ }
95
134
  function buildFullSessionConfig() {
96
135
  const { tools, skillDirectories } = getSessionConfig();
97
136
  return {
@@ -102,6 +141,7 @@ function buildFullSessionConfig() {
102
141
  content: getOrchestratorSystemMessage({
103
142
  selfEditEnabled: config.selfEditEnabled,
104
143
  memorySummary: getWikiSummary() || undefined,
144
+ squadRoster: buildSquadRoster() || undefined,
105
145
  }),
106
146
  },
107
147
  tools,
@@ -407,4 +447,21 @@ export async function shutdownOrchestrator() {
407
447
  clientResetPromise = undefined;
408
448
  client = undefined;
409
449
  }
450
+ /**
451
+ * Abort the orchestrator's current in-flight request. The session remains valid
452
+ * for subsequent prompts. Returns true if a session existed and abort was
453
+ * attempted, false otherwise.
454
+ */
455
+ export async function abortOrchestrator() {
456
+ if (!orchestratorSession)
457
+ return false;
458
+ try {
459
+ await orchestratorSession.abort();
460
+ return true;
461
+ }
462
+ catch (err) {
463
+ console.error("[io] Error aborting orchestrator session:", err instanceof Error ? err.message : err);
464
+ return false;
465
+ }
466
+ }
410
467
  //# sourceMappingURL=orchestrator.js.map