heyio 0.2.1 → 0.4.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.
@@ -6,6 +6,7 @@ import { config } from "../config.js";
6
6
  import { listSkills } 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
+ import { summarize, summarizeEvent } from "../copilot/event-summary.js";
9
10
  import { abortOrchestrator } from "../copilot/orchestrator.js";
10
11
  import { getActiveTasks, getTask, listRecentTasks } from "../store/tasks.js";
11
12
  import { IO_VERSION } from "../paths.js";
@@ -153,6 +154,18 @@ export async function startApiServer() {
153
154
  res.status(500).json({ error: "Failed to fetch task" });
154
155
  }
155
156
  });
157
+ api.get("/tasks/:taskId/activity", (req, res) => {
158
+ const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
159
+ try {
160
+ const events = getTaskEvents(taskId);
161
+ const activity = summarize(events);
162
+ res.json({ taskId, activity });
163
+ }
164
+ catch (e) {
165
+ console.error("Error building task activity:", e);
166
+ res.status(500).json({ error: "Failed to build task activity" });
167
+ }
168
+ });
156
169
  api.get("/tasks/:taskId/events", (req, res) => {
157
170
  const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
158
171
  res.setHeader("Content-Type", "text/event-stream");
@@ -162,7 +175,9 @@ export async function startApiServer() {
162
175
  res.flushHeaders();
163
176
  const send = (ev) => {
164
177
  try {
165
- res.write(`data: ${JSON.stringify(ev)}\n\n`);
178
+ const summary = summarizeEvent(ev);
179
+ const payload = { ...ev, summary };
180
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
166
181
  }
167
182
  catch {
168
183
  // client likely disconnected; cleanup happens on req.close
@@ -48,6 +48,7 @@ export function getAgentInfo() {
48
48
  status: agent?.status === "working" ? "working" : currentTask ? "working" : "idle",
49
49
  currentTask,
50
50
  currentTaskId,
51
+ model: agentSessionModels.get(key),
51
52
  });
52
53
  }
53
54
  else {
@@ -60,6 +61,7 @@ export function getAgentInfo() {
60
61
  status: currentTask ? "working" : squad?.status === "error" ? "error" : "idle",
61
62
  currentTask,
62
63
  currentTaskId,
64
+ model: agentSessionModels.get(key),
63
65
  });
64
66
  }
65
67
  }
@@ -238,21 +240,38 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
238
240
  const tierRank = { high: 3, medium: 2, low: 1 };
239
241
  const effectiveTier = tierRank[taskTier] >= tierRank[agentTier] ? taskTier : agentTier;
240
242
  const model = getModelForTier(effectiveTier);
241
- // If we have a cached session, check if the model matches; if not, destroy and recreate
243
+ // If we have a cached session, check if the model matches AND the agent
244
+ // hasn't been left in an error state by a previous task. If either is off,
245
+ // destroy and recreate. Reusing a session whose underlying SDK process has
246
+ // been throwing is how Panthro got "stuck" in error after the issue #42
247
+ // delegation timeout (issue #55).
242
248
  const existing = agentSessions.get(key);
243
249
  if (existing) {
244
- // Sessions don't expose their model, so track it separately
245
- const cachedModel = agentSessionModels.get(key);
246
- if (cachedModel === model)
247
- return existing;
248
- // Model changed — destroy old session for the upgraded model
249
- console.error(`[io] Agent ${agent.character_name}: upgrading model ${cachedModel} → ${model} for task complexity`);
250
- try {
251
- await existing.destroy();
250
+ const fresh = getSquadAgent(squadSlug, agent.character_name);
251
+ const persistedStatus = fresh?.status ?? agent.status;
252
+ if (persistedStatus === "error") {
253
+ console.error(`[io] Agent ${agent.character_name}: previous session ended in error — discarding cached session and recreating`);
254
+ try {
255
+ await existing.destroy();
256
+ }
257
+ catch { /* best-effort */ }
258
+ agentSessions.delete(key);
259
+ agentSessionModels.delete(key);
260
+ }
261
+ else {
262
+ // Sessions don't expose their model, so track it separately
263
+ const cachedModel = agentSessionModels.get(key);
264
+ if (cachedModel === model)
265
+ return existing;
266
+ // Model changed — destroy old session for the upgraded model
267
+ console.error(`[io] Agent ${agent.character_name}: upgrading model ${cachedModel} → ${model} for task complexity`);
268
+ try {
269
+ await existing.destroy();
270
+ }
271
+ catch { /* best-effort */ }
272
+ agentSessions.delete(key);
273
+ agentSessionModels.delete(key);
252
274
  }
253
- catch { /* best-effort */ }
254
- agentSessions.delete(key);
255
- agentSessionModels.delete(key);
256
275
  }
257
276
  const squad = getSquad(squadSlug);
258
277
  const client = await getClient();
@@ -571,6 +590,69 @@ function walkDirectory(dir, maxDepth = 3, depth = 0) {
571
590
  }
572
591
  return results;
573
592
  }
593
+ /**
594
+ * Parse APPROVED/REJECTED verdict from a reviewer's free-form response.
595
+ *
596
+ * Robust to common formatting variants:
597
+ * - Leading blank lines or markdown headers (e.g. "## Review\n\nAPPROVED")
598
+ * - Markdown emphasis (e.g. "**APPROVED**")
599
+ * - Verdict appearing only later in the response
600
+ * - Both tokens appearing in the same line ("I almost said REJECTED but APPROVED")
601
+ *
602
+ * Strategy:
603
+ * 1. Strip markdown noise.
604
+ * 2. Look at the first 10 non-empty lines for a *line-leading* verdict.
605
+ * 3. Fall back to the first occurrence of either token anywhere in the body.
606
+ * 4. If neither token appears, treat as REJECTED (conservative).
607
+ */
608
+ export function parseReviewVerdict(content) {
609
+ if (!content)
610
+ return false;
611
+ const stripped = content.replace(/[*_`#>]/g, "");
612
+ const lines = stripped
613
+ .split(/\r?\n/)
614
+ .map((l) => l.trim())
615
+ .filter(Boolean)
616
+ .slice(0, 10);
617
+ for (const line of lines) {
618
+ const lead = line
619
+ .toUpperCase()
620
+ .match(/^[^A-Z]*\b(APPROVED|REJECTED)\b/);
621
+ if (lead)
622
+ return lead[1] === "APPROVED";
623
+ }
624
+ const upper = stripped.toUpperCase();
625
+ const a = upper.search(/\bAPPROVED\b/);
626
+ const r = upper.search(/\bREJECTED\b/);
627
+ if (a === -1 && r === -1)
628
+ return false;
629
+ if (a === -1)
630
+ return false;
631
+ if (r === -1)
632
+ return true;
633
+ return a < r;
634
+ }
635
+ /**
636
+ * Return the reviewer's prose comments with any leading verdict line stripped.
637
+ * Preserves the original formatting (no upper-casing, no markdown stripping).
638
+ */
639
+ export function stripLeadingVerdictLine(content) {
640
+ if (!content)
641
+ return "";
642
+ const lines = content.split(/\r?\n/);
643
+ let i = 0;
644
+ while (i < lines.length && lines[i].trim() === "")
645
+ i++;
646
+ if (i < lines.length) {
647
+ const probe = lines[i]
648
+ .replace(/[*_`#>]/g, "")
649
+ .trim()
650
+ .toUpperCase();
651
+ if (/^(APPROVED|REJECTED)\b/.test(probe))
652
+ i++;
653
+ }
654
+ return lines.slice(i).join("\n").trim();
655
+ }
574
656
  /**
575
657
  * Run a peer review phase after a task completes. Every other agent on the
576
658
  * squad reviews the work and votes APPROVED / REJECTED. QA agents
@@ -605,10 +687,8 @@ Review the work. Respond with:
605
687
  const session = await getOrCreateAgentSession(squadSlug, reviewer, `Peer review of task ${taskId}`);
606
688
  const response = await session.sendAndWait({ prompt: reviewPrompt }, 300_000);
607
689
  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;
690
+ const approved = parseReviewVerdict(content);
691
+ const comments = stripLeadingVerdictLine(content) || null;
612
692
  createReview(taskId, squadSlug, reviewer.character_name, approved, comments ?? undefined);
613
693
  recordTaskEvent(taskId, {
614
694
  ts: Date.now(),
@@ -616,6 +696,7 @@ Review the work. Respond with:
616
696
  data: {
617
697
  reviewer: reviewer.character_name,
618
698
  is_qa: reviewer.is_qa === 1,
699
+ is_lead: reviewer.is_lead === 1,
619
700
  approved,
620
701
  comments,
621
702
  },
@@ -623,6 +704,7 @@ Review the work. Respond with:
623
704
  reviews.push({
624
705
  reviewer: reviewer.character_name,
625
706
  is_qa: reviewer.is_qa === 1,
707
+ is_lead: reviewer.is_lead === 1,
626
708
  approved,
627
709
  comments: comments ?? "",
628
710
  });
@@ -637,7 +719,24 @@ Review the work. Respond with:
637
719
  });
638
720
  }
639
721
  }
722
+ const hasQaReviewers = reviews.some((r) => r.is_qa);
723
+ const hasLeadReviewer = reviews.some((r) => r.is_lead);
640
724
  const qaRejection = reviews.find((r) => r.is_qa && !r.approved);
725
+ // Team lead has implicit veto power equivalent to a QA reviewer. If the lead
726
+ // is also a QA agent the qaRejection branch already covers it; this catches
727
+ // the lead-but-not-QA case.
728
+ const leadRejection = reviews.find((r) => r.is_lead && !r.is_qa && !r.approved);
729
+ const advisoryRejections = reviews.filter((r) => !r.is_qa && !r.is_lead && !r.approved);
730
+ if (!hasQaReviewers && !hasLeadReviewer && advisoryRejections.length > 0) {
731
+ recordTaskEvent(taskId, {
732
+ ts: Date.now(),
733
+ type: "task.review_advisory",
734
+ data: {
735
+ reason: "No QA reviewers or team lead designated; rejections are advisory and do not block promotion.",
736
+ rejectedBy: advisoryRejections.map((r) => r.reviewer),
737
+ },
738
+ });
739
+ }
641
740
  const prMatch = taskResult.match(/https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
642
741
  if (qaRejection) {
643
742
  recordTaskEvent(taskId, {
@@ -651,6 +750,18 @@ Review the work. Respond with:
651
750
  });
652
751
  return;
653
752
  }
753
+ if (leadRejection) {
754
+ recordTaskEvent(taskId, {
755
+ ts: Date.now(),
756
+ type: "task.review_complete",
757
+ data: {
758
+ promoted: false,
759
+ reason: `Lead veto from ${leadRejection.reviewer}`,
760
+ prUrl: prMatch ? prMatch[0] : null,
761
+ },
762
+ });
763
+ return;
764
+ }
654
765
  if (!prMatch) {
655
766
  recordTaskEvent(taskId, {
656
767
  ts: Date.now(),
@@ -0,0 +1,136 @@
1
+ // Minimal standard 5-field cron parser + next-run calculator.
2
+ //
3
+ // Fields (in order): minute, hour, day-of-month, month, day-of-week.
4
+ // Supported syntax per field:
5
+ // * — all values
6
+ // N — single value
7
+ // A,B,C — list
8
+ // A-B — range
9
+ // A-B/N or */N — step (A-B/N restricts to range; */N applies to full range)
10
+ //
11
+ // Day-of-week: 0 or 7 = Sunday, 1 = Monday … (standard Unix cron).
12
+ //
13
+ // Matching semantics: when both day-of-month and day-of-week are restricted
14
+ // (i.e. neither is "*"), Vixie cron uses an OR between them — we follow that.
15
+ const FIELD_RANGES = [
16
+ [0, 59],
17
+ [0, 23],
18
+ [1, 31],
19
+ [1, 12],
20
+ [0, 7],
21
+ ];
22
+ function parseField(raw, [min, max]) {
23
+ const out = new Set();
24
+ for (const part of raw.split(",")) {
25
+ const stepMatch = part.match(/^(.+?)\/(\d+)$/);
26
+ const body = stepMatch ? stepMatch[1] : part;
27
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
28
+ if (!(step >= 1))
29
+ throw new Error(`Invalid step in cron field: "${part}"`);
30
+ let lo, hi;
31
+ if (body === "*") {
32
+ lo = min;
33
+ hi = max;
34
+ }
35
+ else if (body.includes("-")) {
36
+ const [a, b] = body.split("-").map((n) => parseInt(n, 10));
37
+ if (Number.isNaN(a) || Number.isNaN(b)) {
38
+ throw new Error(`Invalid range in cron field: "${part}"`);
39
+ }
40
+ lo = a;
41
+ hi = b;
42
+ }
43
+ else {
44
+ const v = parseInt(body, 10);
45
+ if (Number.isNaN(v))
46
+ throw new Error(`Invalid cron field value: "${part}"`);
47
+ lo = v;
48
+ hi = v;
49
+ }
50
+ if (lo < min || hi > max || lo > hi) {
51
+ throw new Error(`Cron field out of range [${min}-${max}]: "${part}"`);
52
+ }
53
+ for (let v = lo; v <= hi; v += step)
54
+ out.add(v);
55
+ }
56
+ return out;
57
+ }
58
+ export function parseCron(expr) {
59
+ const trimmed = expr.trim().replace(/\s+/g, " ");
60
+ const parts = trimmed.split(" ");
61
+ if (parts.length !== 5) {
62
+ throw new Error(`Cron expression must have 5 fields (minute hour dom month dow), got ${parts.length}: "${expr}"`);
63
+ }
64
+ const [mRaw, hRaw, domRaw, monRaw, dowRaw] = parts;
65
+ const minutes = parseField(mRaw, FIELD_RANGES[0]);
66
+ const hours = parseField(hRaw, FIELD_RANGES[1]);
67
+ const doms = parseField(domRaw, FIELD_RANGES[2]);
68
+ const months = parseField(monRaw, FIELD_RANGES[3]);
69
+ const dowsRaw = parseField(dowRaw, FIELD_RANGES[4]);
70
+ const dows = new Set();
71
+ for (const v of dowsRaw)
72
+ dows.add(v === 7 ? 0 : v);
73
+ return {
74
+ minutes,
75
+ hours,
76
+ doms,
77
+ months,
78
+ dows,
79
+ domStar: domRaw === "*",
80
+ dowStar: dowRaw === "*",
81
+ };
82
+ }
83
+ /**
84
+ * Return the next Date strictly after `after` that matches the cron expression.
85
+ * Iterates minute-by-minute with month/day fast-forwarding. Capped at ~5
86
+ * years lookahead to guard against unsatisfiable expressions.
87
+ */
88
+ export function nextRun(expr, after = new Date()) {
89
+ const c = typeof expr === "string" ? parseCron(expr) : expr;
90
+ const cursor = new Date(after.getTime());
91
+ cursor.setSeconds(0, 0);
92
+ cursor.setMinutes(cursor.getMinutes() + 1);
93
+ const limit = new Date(after.getTime() + 5 * 366 * 24 * 60 * 60 * 1000);
94
+ while (cursor <= limit) {
95
+ const month = cursor.getMonth() + 1;
96
+ if (!c.months.has(month)) {
97
+ cursor.setMonth(cursor.getMonth() + 1, 1);
98
+ cursor.setHours(0, 0, 0, 0);
99
+ continue;
100
+ }
101
+ const dom = cursor.getDate();
102
+ const dow = cursor.getDay();
103
+ const dayOk = c.domStar && c.dowStar
104
+ ? true
105
+ : c.domStar
106
+ ? c.dows.has(dow)
107
+ : c.dowStar
108
+ ? c.doms.has(dom)
109
+ : c.doms.has(dom) || c.dows.has(dow);
110
+ if (!dayOk) {
111
+ cursor.setDate(cursor.getDate() + 1);
112
+ cursor.setHours(0, 0, 0, 0);
113
+ continue;
114
+ }
115
+ if (!c.hours.has(cursor.getHours())) {
116
+ cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
117
+ continue;
118
+ }
119
+ if (!c.minutes.has(cursor.getMinutes())) {
120
+ cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
121
+ continue;
122
+ }
123
+ return cursor;
124
+ }
125
+ throw new Error(`No next run found within 5 years for cron expression`);
126
+ }
127
+ export function validateCron(expr) {
128
+ try {
129
+ const next = nextRun(expr);
130
+ return { ok: true, next };
131
+ }
132
+ catch (err) {
133
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
134
+ }
135
+ }
136
+ //# sourceMappingURL=cron.js.map
@@ -0,0 +1,286 @@
1
+ // Tiered activity log normalizer.
2
+ //
3
+ // Converts raw TaskStreamEvent records (forwarded from the Copilot SDK
4
+ // session) into "activity entries" that present a clean human-readable
5
+ // summary. Each entry preserves the underlying raw payload for drill-down so
6
+ // no information is lost.
7
+ //
8
+ // Used by:
9
+ // - GET /tasks/:taskId/activity (full collapsed list)
10
+ // - the SSE per-event payload (single-event summary attached alongside raw)
11
+ // - the TUI /activity command
12
+ const TOOL_VERBS = {
13
+ bash: "Ran shell command",
14
+ shell: "Ran shell command",
15
+ read_file: "Read file",
16
+ view: "Read file",
17
+ create: "Created file",
18
+ edit: "Edited file",
19
+ str_replace_editor: "Edited file",
20
+ grep: "Searched code",
21
+ glob: "Searched filenames",
22
+ web_fetch: "Fetched URL",
23
+ github: "Called GitHub API",
24
+ delegate_to_teammate: "Delegated to teammate",
25
+ squad_delegate: "Delegated to squad",
26
+ squad_status: "Listed squads",
27
+ squad_agents: "Listed squad agents",
28
+ squad_set_lead: "Set squad lead",
29
+ squad_set_qa: "Set squad QA",
30
+ squad_task_status: "Checked task status",
31
+ squad_task_reviews: "Read task reviews",
32
+ squad_log_decision: "Logged squad decision",
33
+ squad_schedule_create: "Created squad schedule",
34
+ squad_schedule_list: "Listed squad schedules",
35
+ squad_schedule_run_now: "Fired squad schedule",
36
+ wiki_read: "Read wiki page",
37
+ wiki_write: "Wrote wiki page",
38
+ wiki_search: "Searched wiki",
39
+ wiki_list: "Listed wiki pages",
40
+ skill_list: "Listed skills",
41
+ skill_install: "Installed skill",
42
+ skill_remove: "Removed skill",
43
+ skill_search: "Searched skills registry",
44
+ config_update: "Updated config",
45
+ check_update: "Checked for IO update",
46
+ file_ops: "File operation",
47
+ };
48
+ function trunc(s, n) {
49
+ if (!s)
50
+ return "";
51
+ const flat = s.replace(/\s+/g, " ").trim();
52
+ return flat.length > n ? flat.slice(0, n - 1) + "…" : flat;
53
+ }
54
+ function pickArgSummary(toolName, args) {
55
+ if (!args)
56
+ return "";
57
+ const a = args;
58
+ switch (toolName) {
59
+ case "bash":
60
+ case "shell": {
61
+ const cmd = typeof a.command === "string" ? a.command : "";
62
+ return trunc(cmd, 80);
63
+ }
64
+ case "read_file":
65
+ case "view":
66
+ case "create":
67
+ case "edit":
68
+ case "str_replace_editor": {
69
+ const p = typeof a.path === "string" ? a.path : (typeof a.file_path === "string" ? a.file_path : "");
70
+ return p;
71
+ }
72
+ case "grep": {
73
+ const pat = typeof a.pattern === "string" ? `"${trunc(a.pattern, 40)}"` : "";
74
+ const paths = Array.isArray(a.paths) ? a.paths.join(", ") : (typeof a.paths === "string" ? a.paths : "");
75
+ return [pat, paths].filter(Boolean).join(" in ");
76
+ }
77
+ case "glob": {
78
+ return typeof a.pattern === "string" ? a.pattern : "";
79
+ }
80
+ case "web_fetch": {
81
+ return typeof a.url === "string" ? a.url : "";
82
+ }
83
+ case "delegate_to_teammate": {
84
+ const teammate = typeof a.teammate === "string" ? a.teammate : "";
85
+ const task = typeof a.task === "string" ? trunc(a.task, 60) : "";
86
+ return [teammate, task].filter(Boolean).join(": ");
87
+ }
88
+ case "squad_delegate": {
89
+ const slug = typeof a.slug === "string" ? a.slug : "";
90
+ const agent = typeof a.agent === "string" ? ` → ${a.agent}` : "";
91
+ const task = typeof a.task === "string" ? trunc(a.task, 50) : "";
92
+ return `${slug}${agent}${task ? `: ${task}` : ""}`;
93
+ }
94
+ case "wiki_read":
95
+ case "wiki_write":
96
+ case "wiki_delete": {
97
+ return typeof a.path === "string" ? a.path : "";
98
+ }
99
+ case "wiki_search":
100
+ case "skill_search": {
101
+ return typeof a.query === "string" ? `"${trunc(a.query, 40)}"` : "";
102
+ }
103
+ case "github": {
104
+ const ep = typeof a.endpoint === "string" ? a.endpoint : "";
105
+ const method = typeof a.method === "string" ? a.method : "";
106
+ return [method, ep].filter(Boolean).join(" ");
107
+ }
108
+ case "file_ops": {
109
+ const op = typeof a.operation === "string" ? a.operation : "";
110
+ const p = typeof a.path === "string" ? a.path : "";
111
+ return [op, p].filter(Boolean).join(" ");
112
+ }
113
+ default: {
114
+ // Best-effort: stringify the first arg value.
115
+ const k = Object.keys(a)[0];
116
+ if (!k)
117
+ return "";
118
+ const v = a[k];
119
+ const s = typeof v === "string" ? v : JSON.stringify(v);
120
+ return `${k}=${trunc(s ?? "", 50)}`;
121
+ }
122
+ }
123
+ }
124
+ /** Summarize a single TaskStreamEvent. */
125
+ export function summarizeEvent(ev) {
126
+ const data = (ev.data ?? {});
127
+ const base = { ts: ev.ts, rawType: ev.type, raw: ev.data };
128
+ switch (ev.type) {
129
+ case "assistant.intent": {
130
+ const intent = typeof data.intent === "string" ? data.intent : "";
131
+ return { ...base, kind: "reasoning", icon: "🧠", summary: intent || "(intent)" };
132
+ }
133
+ case "assistant.reasoning": {
134
+ const content = typeof data.content === "string" ? data.content : "";
135
+ return {
136
+ ...base,
137
+ kind: "reasoning",
138
+ icon: "🧠",
139
+ summary: trunc(content, 140) || "(reasoning)",
140
+ detail: content,
141
+ };
142
+ }
143
+ case "assistant.message": {
144
+ const content = typeof data.content === "string" ? data.content : "";
145
+ return {
146
+ ...base,
147
+ kind: "message",
148
+ icon: "💬",
149
+ summary: trunc(content, 200) || "(empty message)",
150
+ detail: content,
151
+ };
152
+ }
153
+ case "assistant.turn_start":
154
+ return { ...base, kind: "system", icon: "▶️", summary: "Turn started" };
155
+ case "assistant.turn_end":
156
+ return { ...base, kind: "system", icon: "⏹️", summary: "Turn ended" };
157
+ case "tool.execution_start": {
158
+ const name = typeof data.toolName === "string" ? data.toolName : "tool";
159
+ const verb = TOOL_VERBS[name] ?? `Used ${name}`;
160
+ const args = pickArgSummary(name, data.arguments);
161
+ return {
162
+ ...base,
163
+ kind: "tool",
164
+ icon: "🔧",
165
+ summary: args ? `${verb} — ${args}` : verb,
166
+ toolCallId: typeof data.toolCallId === "string" ? data.toolCallId : undefined,
167
+ status: "pending",
168
+ };
169
+ }
170
+ case "tool.execution_complete": {
171
+ const success = data.success === true;
172
+ const result = (data.result ?? {});
173
+ const content = typeof result.content === "string" ? result.content : "";
174
+ return {
175
+ ...base,
176
+ kind: "tool",
177
+ icon: success ? "✅" : "❌",
178
+ summary: success ? "Tool completed" : "Tool failed",
179
+ detail: trunc(content, 300),
180
+ toolCallId: typeof data.toolCallId === "string" ? data.toolCallId : undefined,
181
+ status: success ? "success" : "error",
182
+ };
183
+ }
184
+ case "tool.execution_progress": {
185
+ const message = typeof data.message === "string" ? data.message : "";
186
+ return {
187
+ ...base,
188
+ kind: "tool",
189
+ icon: "⏳",
190
+ summary: trunc(message, 140) || "Tool progress",
191
+ toolCallId: typeof data.toolCallId === "string" ? data.toolCallId : undefined,
192
+ status: "pending",
193
+ };
194
+ }
195
+ case "session.error": {
196
+ const msg = typeof data.message === "string" ? data.message : (typeof data.error === "string" ? data.error : "");
197
+ return { ...base, kind: "outcome", icon: "❌", summary: `Error: ${trunc(msg, 160) || "session error"}` };
198
+ }
199
+ case "session.warning": {
200
+ const msg = typeof data.message === "string" ? data.message : "";
201
+ return { ...base, kind: "outcome", icon: "⚠️", summary: `Warning: ${trunc(msg, 160) || "session warning"}` };
202
+ }
203
+ case "task.done": {
204
+ const r = typeof data.result === "string" ? data.result : "";
205
+ return { ...base, kind: "outcome", icon: "✅", summary: "Task completed", detail: trunc(r, 300) };
206
+ }
207
+ case "task.failed": {
208
+ const e = typeof data.error === "string" ? data.error : "";
209
+ return { ...base, kind: "outcome", icon: "❌", summary: `Task failed: ${trunc(e, 160)}` };
210
+ }
211
+ case "task.cancelled":
212
+ return { ...base, kind: "outcome", icon: "🛑", summary: "Task cancelled" };
213
+ case "task.review":
214
+ return {
215
+ ...base,
216
+ kind: "outcome",
217
+ icon: data.approved ? "👍" : "👎",
218
+ summary: `Review by ${data.reviewer ?? "?"}: ${data.approved ? "APPROVED" : "REJECTED"}${data.is_qa ? " (QA)" : ""}`,
219
+ detail: typeof data.comments === "string" ? data.comments : undefined,
220
+ };
221
+ case "task.review_complete":
222
+ return {
223
+ ...base,
224
+ kind: "outcome",
225
+ icon: data.promoted ? "🚀" : "ℹ️",
226
+ summary: data.promoted
227
+ ? `Promoted PR: ${data.prUrl ?? ""}`
228
+ : `Review complete: ${typeof data.reason === "string" ? data.reason : ""}`,
229
+ };
230
+ case "task.review_advisory":
231
+ return {
232
+ ...base,
233
+ kind: "outcome",
234
+ icon: "ℹ️",
235
+ summary: typeof data.reason === "string" ? data.reason : "Advisory review note",
236
+ };
237
+ case "task.review_error": {
238
+ const e = typeof data.error === "string" ? data.error : "";
239
+ return { ...base, kind: "outcome", icon: "❌", summary: `Review error: ${trunc(e, 160)}` };
240
+ }
241
+ default:
242
+ return { ...base, kind: "system", icon: "•", summary: ev.type };
243
+ }
244
+ }
245
+ /**
246
+ * Collapse a stream of events into the activity log. Tool start + complete
247
+ * pairs are merged into one entry (success/error and detail attached).
248
+ * Streaming deltas (assistant.message_delta, assistant.reasoning_delta,
249
+ * tool.execution_partial_result) are dropped — the "complete" sibling event
250
+ * is what we summarize.
251
+ */
252
+ export function summarize(events) {
253
+ const out = [];
254
+ const toolIndex = new Map(); // toolCallId → index into out
255
+ for (const ev of events) {
256
+ if (ev.type === "assistant.message_delta" ||
257
+ ev.type === "assistant.reasoning_delta" ||
258
+ ev.type === "tool.execution_partial_result") {
259
+ continue;
260
+ }
261
+ const entry = summarizeEvent(ev);
262
+ if (entry.kind === "tool" && entry.toolCallId) {
263
+ const existing = toolIndex.get(entry.toolCallId);
264
+ if (existing != null && entry.status && entry.status !== "pending") {
265
+ // Merge: keep the verb from the start event, add status/detail/icon from completion.
266
+ const prior = out[existing];
267
+ out[existing] = {
268
+ ...prior,
269
+ icon: entry.icon,
270
+ status: entry.status,
271
+ detail: entry.detail ?? prior.detail,
272
+ // Append a brief outcome marker if the start summary is the only descriptor.
273
+ summary: prior.summary,
274
+ // Keep the completion event raw available for drill-down by stashing on detail when no detail present
275
+ raw: { start: prior.raw, complete: entry.raw },
276
+ rawType: `${prior.rawType}+${entry.rawType}`,
277
+ };
278
+ continue;
279
+ }
280
+ toolIndex.set(entry.toolCallId, out.length);
281
+ }
282
+ out.push(entry);
283
+ }
284
+ return out;
285
+ }
286
+ //# sourceMappingURL=event-summary.js.map