heyio 0.1.33 → 0.2.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.
@@ -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";
@@ -8,7 +9,7 @@ import { z } from "zod";
8
9
  import { getClient } from "./client.js";
9
10
  import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
10
11
  import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
11
- import { createTask, completeTask, failTask, getActiveTasks, } from "../store/tasks.js";
12
+ import { createTask, completeTask, 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) {
@@ -89,6 +133,22 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
89
133
  updateSquadStatus(squadSlug, "working");
90
134
  if (agent)
91
135
  updateAgentStatus(squadSlug, agent.character_name, "working");
136
+ // Subscribe to the agent session's events for the duration of this task so
137
+ // the web UI can preview the agent's "thread of consciousness" live.
138
+ recordTaskEvent(taskId, {
139
+ ts: Date.now(),
140
+ type: "task.start",
141
+ data: { taskId, agentKey, description: task },
142
+ });
143
+ const unsubscribe = session.on((event) => {
144
+ if (!STREAM_EVENT_TYPES.has(event.type))
145
+ return;
146
+ recordTaskEvent(taskId, {
147
+ ts: Date.now(),
148
+ type: event.type,
149
+ data: event.data ?? null,
150
+ });
151
+ });
92
152
  // Run the task in the background — return taskId immediately
93
153
  void (async () => {
94
154
  try {
@@ -98,6 +158,7 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
98
158
  updateSquadStatus(squadSlug, "idle");
99
159
  if (agent)
100
160
  updateAgentStatus(squadSlug, agent.character_name, "idle");
161
+ recordTaskEvent(taskId, { ts: Date.now(), type: "task.done", data: { result } });
101
162
  onComplete(taskId, result);
102
163
  }
103
164
  catch (err) {
@@ -106,6 +167,13 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
106
167
  updateSquadStatus(squadSlug, "error");
107
168
  if (agent)
108
169
  updateAgentStatus(squadSlug, agent.character_name, "error");
170
+ recordTaskEvent(taskId, { ts: Date.now(), type: "task.failed", data: { error: message } });
171
+ }
172
+ finally {
173
+ try {
174
+ unsubscribe();
175
+ }
176
+ catch { /* ignore */ }
109
177
  }
110
178
  })();
111
179
  const agentLabel = agent
@@ -413,4 +481,40 @@ function walkDirectory(dir, maxDepth = 3, depth = 0) {
413
481
  }
414
482
  return results;
415
483
  }
484
+ /**
485
+ * Cancel a running agent task by aborting its session and marking the task
486
+ * cancelled. Returns true if the task existed and was running.
487
+ */
488
+ export async function cancelAgentTask(taskId) {
489
+ const task = getTask(taskId);
490
+ if (!task || task.status !== "running")
491
+ return false;
492
+ const sessionKey = task.agent_slug;
493
+ const session = agentSessions.get(sessionKey);
494
+ if (session) {
495
+ try {
496
+ await session.abort();
497
+ }
498
+ catch (err) {
499
+ console.error("[io] Error aborting agent session:", err instanceof Error ? err.message : err);
500
+ }
501
+ }
502
+ cancelTask(taskId);
503
+ recordTaskEvent(taskId, { ts: Date.now(), type: "task.cancelled", data: { reason: "Cancelled by user" } });
504
+ // sessionKey is "squadSlug" or "squadSlug:characterName"
505
+ const [squadSlug, characterName] = sessionKey.split(":");
506
+ if (squadSlug) {
507
+ try {
508
+ updateSquadStatus(squadSlug, "idle");
509
+ }
510
+ catch { /* ignore */ }
511
+ }
512
+ if (squadSlug && characterName) {
513
+ try {
514
+ updateAgentStatus(squadSlug, characterName, "idle");
515
+ }
516
+ catch { /* ignore */ }
517
+ }
518
+ return true;
519
+ }
416
520
  //# sourceMappingURL=agents.js.map
@@ -407,4 +407,21 @@ export async function shutdownOrchestrator() {
407
407
  clientResetPromise = undefined;
408
408
  client = undefined;
409
409
  }
410
+ /**
411
+ * Abort the orchestrator's current in-flight request. The session remains valid
412
+ * for subsequent prompts. Returns true if a session existed and abort was
413
+ * attempted, false otherwise.
414
+ */
415
+ export async function abortOrchestrator() {
416
+ if (!orchestratorSession)
417
+ return false;
418
+ try {
419
+ await orchestratorSession.abort();
420
+ return true;
421
+ }
422
+ catch (err) {
423
+ console.error("[io] Error aborting orchestrator session:", err instanceof Error ? err.message : err);
424
+ return false;
425
+ }
426
+ }
410
427
  //# sourceMappingURL=orchestrator.js.map
@@ -74,7 +74,7 @@ export function getSquadAgent(squadSlug, characterName) {
74
74
  }
75
75
  export function listSquadAgents(squadSlug) {
76
76
  return getDb()
77
- .prepare("SELECT * FROM squad_agents WHERE squad_slug = ? ORDER BY created_at")
77
+ .prepare("SELECT * FROM squad_agents WHERE squad_slug = ? ORDER BY id ASC")
78
78
  .all(squadSlug);
79
79
  }
80
80
  export function removeSquadAgent(squadSlug, characterName) {
@@ -29,4 +29,14 @@ export function clearStaleTasks() {
29
29
  .prepare("UPDATE agent_tasks SET status = 'failed', result = 'Marked stale on startup', completed_at = CURRENT_TIMESTAMP WHERE status = 'running'")
30
30
  .run();
31
31
  }
32
+ export function cancelTask(taskId, reason = "Cancelled by user") {
33
+ getDb()
34
+ .prepare("UPDATE agent_tasks SET status = 'cancelled', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ? AND status = 'running'")
35
+ .run(reason, taskId);
36
+ }
37
+ export function listRecentTasks(limit = 50) {
38
+ return getDb()
39
+ .prepare("SELECT * FROM agent_tasks ORDER BY datetime(started_at) DESC, task_id DESC LIMIT ?")
40
+ .all(limit);
41
+ }
32
42
  //# sourceMappingURL=tasks.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.1.33",
3
+ "version": "0.2.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"