heyio 0.5.0 → 0.8.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.
@@ -11,38 +11,215 @@ import { runIoScheduleNow } from "./io-scheduler.js";
11
11
  import { createSchedule, deleteSchedule, getSchedule, listSchedules, setScheduleEnabled, } from "../store/schedules.js";
12
12
  import { runScheduleNow } from "./scheduler.js";
13
13
  // ---------------------------------------------------------------------------
14
- // QA / test coverage heuristics
14
+ // Squad coverage heuristics
15
15
  //
16
16
  // Every squad must have:
17
- // 1. At least one agent designated as QA (is_qa === 1) - see squad_set_qa.
18
- // 2. At least one agent whose role title implies a testing/quality focus.
17
+ // 1. A dedicated team lead a PM / Senior Engineer with no domain
18
+ // responsibility designated via squad_set_lead. The lead's job is
19
+ // coordination, delegation, and review only. Lead veto power on PR
20
+ // promotion is automatic (see runPeerReview in agents.ts).
21
+ // 2. At least one agent designated as QA (is_qa === 1) — see squad_set_qa.
22
+ // 3. At least one agent whose role title implies a testing/quality focus.
19
23
  //
20
24
  // These are surfaced as warnings on squad_status, squad_agents, and
21
25
  // squad_delegate so users can fix coverage gaps before promoting work.
22
26
  // ---------------------------------------------------------------------------
23
27
  const TEST_ROLE_KEYWORDS = ["test", "qa", "quality", "tester", "sdet", "qe"];
28
+ // Words in a role title that imply the agent owns a hands-on engineering
29
+ // domain (and therefore should NOT be the team lead).
30
+ const DOMAIN_ROLE_KEYWORDS = [
31
+ "frontend",
32
+ "backend",
33
+ "fullstack",
34
+ "full-stack",
35
+ "api",
36
+ "ui",
37
+ "ux",
38
+ "test",
39
+ "tester",
40
+ "qa",
41
+ "quality",
42
+ "sdet",
43
+ "qe",
44
+ "devops",
45
+ "sre",
46
+ "ops",
47
+ "infrastructure",
48
+ "platform",
49
+ "data",
50
+ "database",
51
+ "db",
52
+ "ml",
53
+ "ai",
54
+ "sdk",
55
+ "mobile",
56
+ "ios",
57
+ "android",
58
+ "web",
59
+ "security",
60
+ "embedded",
61
+ "integration",
62
+ "telegram",
63
+ "tui",
64
+ "vue",
65
+ "react",
66
+ "angular",
67
+ "express",
68
+ "sqlite",
69
+ "wiki",
70
+ ];
71
+ // Words that mark a role as coordination/leadership-focused.
72
+ const LEAD_ROLE_KEYWORDS = [
73
+ "lead",
74
+ "manager",
75
+ "pm",
76
+ "principal",
77
+ "coordinator",
78
+ "director",
79
+ ];
80
+ function containsWord(haystack, word) {
81
+ const re = new RegExp(`(^|[^a-z])${word}([^a-z]|$)`);
82
+ return re.test(haystack);
83
+ }
24
84
  export function roleLooksLikeTesting(roleTitle) {
25
85
  if (!roleTitle)
26
86
  return false;
27
87
  const lower = roleTitle.toLowerCase();
28
- return TEST_ROLE_KEYWORDS.some((kw) => {
29
- const re = new RegExp(`(^|[^a-z])${kw}([^a-z]|$)`);
30
- return re.test(lower);
31
- });
88
+ return TEST_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
89
+ }
90
+ /**
91
+ * A dedicated team lead has a role title that emphasises coordination/seniority
92
+ * and does NOT also claim a hands-on engineering domain. Examples:
93
+ * ✅ "Engineering Lead", "Project Manager", "Senior Engineering Lead",
94
+ * "Principal Engineer", "Tech Lead", "Senior Engineer"
95
+ * ❌ "Frontend Lead", "Test Manager", "QA Lead", "Backend Engineer",
96
+ * "Express API Engineer"
97
+ */
98
+ export function roleLooksLikeDedicatedLead(roleTitle) {
99
+ if (!roleTitle)
100
+ return false;
101
+ const lower = roleTitle.toLowerCase();
102
+ const hasDomainKw = DOMAIN_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
103
+ if (hasDomainKw)
104
+ return false;
105
+ const hasLeadKw = LEAD_ROLE_KEYWORDS.some((kw) => containsWord(lower, kw));
106
+ if (hasLeadKw)
107
+ return true;
108
+ // "Senior Engineer" / "Sr. Engineer" with no domain qualifier also counts.
109
+ if (/(^|[^a-z])(senior|sr\.?)\s+engineer($|[^a-z])/.test(lower))
110
+ return true;
111
+ return false;
32
112
  }
33
113
  export function assessSquadCoverage(agents) {
114
+ const leadAgent = agents.find((a) => a.is_lead === 1);
115
+ const hasLead = !!leadAgent;
116
+ const hasDedicatedLead = !!leadAgent && roleLooksLikeDedicatedLead(leadAgent.role_title);
34
117
  const hasQa = agents.some((a) => a.is_qa === 1);
35
118
  const hasTestRole = agents.some((a) => roleLooksLikeTesting(a.role_title));
36
119
  const missing = [];
120
+ if (!hasLead) {
121
+ missing.push("dedicated team lead (use squad_set_lead with a PM/Senior Engineer who owns no domain)");
122
+ }
123
+ else if (!hasDedicatedLead) {
124
+ missing.push(`dedicated lead role (current lead "${leadAgent.role_title}" looks like a domain specialist — team leads must be PM/Senior Engineer with no domain ownership)`);
125
+ }
37
126
  if (!hasQa)
38
127
  missing.push("QA reviewer (use squad_set_qa)");
39
128
  if (!hasTestRole) {
40
129
  missing.push("test/quality engineer (add an agent whose role_title contains 'test', 'qa', or 'quality')");
41
130
  }
42
131
  const warning = missing.length > 0
43
- ? `⚠️ Squad coverage gap: missing ${missing.join(" and ")}.`
132
+ ? `⚠️ Squad coverage gap: missing ${missing.join("; ")}.`
44
133
  : null;
45
- return { hasQa, hasTestRole, missing, warning };
134
+ return { hasLead, hasDedicatedLead, hasQa, hasTestRole, missing, warning };
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Work-distribution diagnostics
138
+ //
139
+ // Squads can fall into an anti-pattern (#51) where the team lead handles
140
+ // every delegated task instead of fanning out to specialists. We surface a
141
+ // soft warning on squad_status when the lead handles more than this share
142
+ // of recent tasks.
143
+ // ---------------------------------------------------------------------------
144
+ const WORK_DISTRIBUTION_WINDOW = 20;
145
+ const LEAD_OVERLOAD_THRESHOLD = 0.8;
146
+ function formatWorkDistribution(squadSlug, lead, deps) {
147
+ const dist = deps.getSquadWorkDistribution(squadSlug, WORK_DISTRIBUTION_WINDOW);
148
+ if (dist.total === 0)
149
+ return "";
150
+ const friendly = (agentSlug) => {
151
+ if (agentSlug === squadSlug)
152
+ return "(unassigned)";
153
+ const idx = agentSlug.indexOf(":");
154
+ return idx >= 0 ? agentSlug.slice(idx + 1) : agentSlug;
155
+ };
156
+ const breakdown = dist.perAgent
157
+ .map((a) => `${friendly(a.agent_slug)} ${a.count} (${Math.round((a.count / dist.total) * 100)}%)`)
158
+ .join(", ");
159
+ const lines = [];
160
+ lines.push(`\n 📊 Work distribution (last ${dist.total} task${dist.total === 1 ? "" : "s"}): ${breakdown}`);
161
+ if (lead) {
162
+ const leadKey = `${squadSlug}:${lead.character_name}`;
163
+ const leadCount = dist.perAgent.find((a) => a.agent_slug === leadKey)?.count ?? 0;
164
+ const share = leadCount / dist.total;
165
+ if (share > LEAD_OVERLOAD_THRESHOLD) {
166
+ lines.push(`\n ⚠️ Lead overload: ${lead.character_name} handled ${Math.round(share * 100)}% of recent tasks (threshold ${Math.round(LEAD_OVERLOAD_THRESHOLD * 100)}%). The lead should be delegating to specialists via delegate_to_teammate, not self-implementing — see issue #51.`);
167
+ }
168
+ }
169
+ return lines.join("");
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Per-agent delegation-stat formatters (issue #61)
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Format an ISO timestamp as a short relative time string. SQLite emits
176
+ * naive UTC strings, so we suffix "Z" before parsing if it isn't already
177
+ * timezone-qualified.
178
+ */
179
+ function formatRelativeTime(iso) {
180
+ if (!iso)
181
+ return "never";
182
+ const tzQualified = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(iso);
183
+ const ts = new Date(tzQualified ? iso : iso + "Z").getTime();
184
+ if (Number.isNaN(ts))
185
+ return "never";
186
+ const deltaMs = Date.now() - ts;
187
+ if (deltaMs < 60_000)
188
+ return "just now";
189
+ if (deltaMs < 3_600_000) {
190
+ const m = Math.round(deltaMs / 60_000);
191
+ return `${m}m ago`;
192
+ }
193
+ if (deltaMs < 86_400_000) {
194
+ const h = Math.round(deltaMs / 3_600_000);
195
+ return `${h}h ago`;
196
+ }
197
+ const d = Math.round(deltaMs / 86_400_000);
198
+ return `${d}d ago`;
199
+ }
200
+ /**
201
+ * Format a stale-hours number as a short duration. >=24h rounds to days,
202
+ * smaller values stay in hours. Floors to keep behaviour consistent across
203
+ * the squad_agents and squad_task_status renderers.
204
+ */
205
+ function formatStaleDuration(staleHours) {
206
+ if (staleHours >= 24) {
207
+ const d = Math.floor(staleHours / 24);
208
+ return `${d}d`;
209
+ }
210
+ return `${Math.floor(staleHours)}h`;
211
+ }
212
+ /**
213
+ * Build the ⚠️ stalest-specialist hint string from a getStalestSpecialist
214
+ * result. Returns "" if the input is null (squad is healthy).
215
+ */
216
+ function formatStalestHint(stalest) {
217
+ if (!stalest)
218
+ return "";
219
+ if (stalest.staleHours == null) {
220
+ return `⚠️ ${stalest.character_name} has never been delegated to`;
221
+ }
222
+ return `⚠️ ${stalest.character_name} has not been delegated to in ${formatStaleDuration(stalest.staleHours)}`;
46
223
  }
47
224
  // Ensure child processes have HOME set (systemd services often don't)
48
225
  function shellEnv() {
@@ -159,7 +336,15 @@ export function createTools(deps) {
159
336
  : "\n Agents: none — use squad_add_agent to build the team";
160
337
  const coverage = assessSquadCoverage(agents);
161
338
  const coverageLine = coverage.warning ? `\n ${coverage.warning}` : "";
162
- return `- **${s.name}** (\`${s.slug}\`) ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}\n 📁 ${s.projectPath}`;
339
+ const distLine = formatWorkDistribution(s.slug, lead, deps);
340
+ const recentDecisions = deps.getRecentDecisions(s.slug, 3);
341
+ const decisionsLine = recentDecisions.length === 0
342
+ ? "\n 📜 Recent decisions: _none recorded — squad is not capturing institutional knowledge_"
343
+ : "\n 📜 Recent decisions: " +
344
+ recentDecisions
345
+ .map((d) => `\"${d.decision.length > 80 ? d.decision.slice(0, 80) + "…" : d.decision}\"`)
346
+ .join("; ");
347
+ return `- **${s.name}** (\`${s.slug}\`) — ${s.status} — 🎬 ${universeName}${leadLine}${agentList}${coverageLine}${distLine}${decisionsLine}\n 📁 ${s.projectPath}`;
163
348
  })
164
349
  .join("\n");
165
350
  },
@@ -205,7 +390,7 @@ export function createTools(deps) {
205
390
  }, agent);
206
391
  const agentLabel = agent ? `agent "${agent}" in squad "${slug}"` : `squad "${slug}"`;
207
392
  const warningPrefix = coverage.warning
208
- ? `${coverage.warning} Reviews from this squad will not be vetoed by a designated QA agent until this is fixed.\n\n`
393
+ ? `${coverage.warning} A dedicated lead and a QA reviewer should both hold veto power on PR promotion fix gaps before promoting work.\n\n`
209
394
  : "";
210
395
  return `${warningPrefix}Task delegated to ${agentLabel}. Task ID: ${taskId}\n\nThe agent is working on this in the background. Use squad_task_status to check progress.`;
211
396
  }
@@ -233,14 +418,58 @@ export function createTools(deps) {
233
418
  const result = task.result.length > 4000 ? task.result.slice(0, 4000) + "\n[…truncated]" : task.result;
234
419
  response += `\n\nResult:\n${result}`;
235
420
  }
421
+ // Stalest-specialist hint for the squad this task belongs to (#61).
422
+ try {
423
+ const squadSlug = task.agent_slug.split(":")[0];
424
+ if (squadSlug) {
425
+ const roster = deps.listSquadAgents(squadSlug);
426
+ const characterNames = roster.map((a) => a.character_name);
427
+ const lead = roster.find((a) => a.is_lead === 1);
428
+ const stalest = deps.getStalestSpecialist(squadSlug, characterNames, {
429
+ excludeCharacters: lead ? [lead.character_name] : [],
430
+ });
431
+ const hint = formatStalestHint(stalest);
432
+ if (hint)
433
+ response += `\n\n${hint}`;
434
+ }
435
+ }
436
+ catch (err) {
437
+ console.error("[io] squad_task_status: stalest-specialist hint failed:", err);
438
+ }
236
439
  return response;
237
440
  }
238
441
  const tasks = deps.getActiveAgentTasks();
239
442
  if (tasks.length === 0)
240
443
  return "No active tasks.";
241
- return tasks
444
+ const taskLines = tasks
242
445
  .map((t) => `- **${t.taskId}** (${t.agentSlug}) — ${t.status} — ${t.description}`)
243
446
  .join("\n");
447
+ // Per-squad stalest-specialist hint block (#61).
448
+ let hintsBlock = "";
449
+ try {
450
+ const uniqueSquadSlugs = Array.from(new Set(tasks.map((t) => t.agentSlug.split(":")[0]).filter((x) => !!x)));
451
+ const hintLines = [];
452
+ for (const squadSlug of uniqueSquadSlugs) {
453
+ const roster = deps.listSquadAgents(squadSlug);
454
+ if (roster.length === 0)
455
+ continue;
456
+ const characterNames = roster.map((a) => a.character_name);
457
+ const lead = roster.find((a) => a.is_lead === 1);
458
+ const stalest = deps.getStalestSpecialist(squadSlug, characterNames, {
459
+ excludeCharacters: lead ? [lead.character_name] : [],
460
+ });
461
+ const hint = formatStalestHint(stalest);
462
+ if (hint)
463
+ hintLines.push(`- ${squadSlug}: ${hint}`);
464
+ }
465
+ if (hintLines.length > 0) {
466
+ hintsBlock = `\n\n**Distribution hints:**\n${hintLines.join("\n")}`;
467
+ }
468
+ }
469
+ catch (err) {
470
+ console.error("[io] squad_task_status: distribution hints failed:", err);
471
+ }
472
+ return `${taskLines}${hintsBlock}`;
244
473
  },
245
474
  });
246
475
  // --- Squad analyze ---
@@ -423,14 +652,49 @@ export function createTools(deps) {
423
652
  const universeName = squad.universe
424
653
  ? UNIVERSES.find((u) => u.id === squad.universe)?.name ?? squad.universe
425
654
  : "none";
655
+ // Pull per-agent task stats once and key by character_name (issue #61).
656
+ // If the helper throws (e.g. brand-new DB before view migration), fall
657
+ // back to an empty map so rendering is unchanged rather than 500-ing.
658
+ const characterNames = agents.map((a) => a.character_name);
659
+ const statsByName = new Map();
660
+ try {
661
+ for (const st of deps.getAgentTaskStats(slug, characterNames)) {
662
+ statsByName.set(st.character_name, {
663
+ task_count: st.task_count,
664
+ last_delegated_at: st.last_delegated_at,
665
+ });
666
+ }
667
+ }
668
+ catch (err) {
669
+ console.error("[io] squad_agents: getAgentTaskStats failed:", err);
670
+ }
426
671
  const lines = agents.map((a) => {
427
672
  const leadBadge = a.is_lead === 1 ? " ⭐ [LEAD]" : "";
428
673
  const qaBadge = a.is_qa === 1 ? " 🛡️ [QA]" : "";
429
- return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) ${a.status}${a.personality ? `\n _${a.personality}_` : ""}`;
674
+ const st = statsByName.get(a.character_name) ?? { task_count: 0, last_delegated_at: null };
675
+ const statsStr = st.task_count === 0
676
+ ? " — 📊 never delegated"
677
+ : ` — 📊 ${st.task_count} ${st.task_count === 1 ? "task" : "tasks"} · last ${formatRelativeTime(st.last_delegated_at)}`;
678
+ return `- **${a.character_name}**${leadBadge}${qaBadge} — ${a.role_title} (${a.model_tier}) — ${a.status}${statsStr}${a.personality ? `\n _${a.personality}_` : ""}`;
430
679
  });
431
680
  const coverage = assessSquadCoverage(agents);
432
681
  const coverageBlock = coverage.warning ? `\n\n${coverage.warning}` : "";
433
- return `**${squad.name}** 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}`;
682
+ // Stalest-specialist hint (issue #61): exclude the lead so the hint
683
+ // points at an under-utilised teammate rather than the coordinator.
684
+ let stalestBlock = "";
685
+ try {
686
+ const lead = agents.find((a) => a.is_lead === 1);
687
+ const stalest = deps.getStalestSpecialist(slug, characterNames, {
688
+ excludeCharacters: lead ? [lead.character_name] : [],
689
+ });
690
+ const hint = formatStalestHint(stalest);
691
+ if (hint)
692
+ stalestBlock = `\n\n${hint}`;
693
+ }
694
+ catch (err) {
695
+ console.error("[io] squad_agents: getStalestSpecialist failed:", err);
696
+ }
697
+ return `**${squad.name}** — 🎬 ${universeName}\n\n${lines.join("\n")}${coverageBlock}${stalestBlock}`;
434
698
  },
435
699
  });
436
700
  // --- Squad remove agent ---
@@ -449,6 +713,33 @@ export function createTools(deps) {
449
713
  : `Agent "${character_name}" not found in squad "${slug}".`;
450
714
  },
451
715
  });
716
+ const squadResetAgent = defineTool("squad_reset_agent", {
717
+ description: "Clear a squad agent's error state and return them to idle without removing them. Preserves their charter, role title, character name, and is_lead/is_qa flags. Drops the agent's in-memory and persisted Copilot session so the next task starts fresh. Safe to call on a non-error agent (no-op with a clear message).",
718
+ skipPermission: true,
719
+ parameters: z.object({
720
+ slug: z.string().describe("Squad slug"),
721
+ character_name: z.string().describe("Character name of the agent to reset"),
722
+ }),
723
+ handler: async ({ slug, character_name }) => {
724
+ console.error(`[io] squad_reset_agent called: ${slug} — ${character_name}`);
725
+ const squad = deps.getSquad(slug);
726
+ if (!squad)
727
+ return `Squad not found: ${slug}`;
728
+ const result = deps.resetSquadAgent(slug, character_name);
729
+ if (!result.found || !result.agent) {
730
+ return `Agent "${character_name}" not found in squad "${slug}".`;
731
+ }
732
+ const { previousStatus, agent } = result;
733
+ if (previousStatus === "error") {
734
+ return `🔄 ${agent.character_name} (${agent.role_title}) reset from 'error' → 'idle'. Charter and role preserved; next task will create a fresh Copilot session.`;
735
+ }
736
+ if (previousStatus === "idle") {
737
+ return `${agent.character_name} (${agent.role_title}) is already 'idle'. No-op: in-memory session cache and persisted session id were cleared anyway so the next task starts fresh.`;
738
+ }
739
+ // working / unknown
740
+ return `⚠️ ${agent.character_name} (${agent.role_title}) was in '${previousStatus}' (not 'error'). Forced to 'idle' and cleared session anyway — verify no task is actually still running for this agent (call squad_task_status).`;
741
+ },
742
+ });
452
743
  // --- Squad delete ---
453
744
  const squadDelete = defineTool("squad_delete", {
454
745
  description: "Delete a squad and all its agents and decisions. This is permanent.",
@@ -485,10 +776,10 @@ export function createTools(deps) {
485
776
  },
486
777
  });
487
778
  const skillInstall = defineTool("skill_install", {
488
- description: "Install a skill from a git repository URL. The repo must contain a SKILL.md file.",
779
+ description: "Install a skill from a git repository URL or a direct SKILL.md file URL. Accepts full repo URLs (clones the repo) and GitHub blob/raw URLs pointing to a specific SKILL.md (fetches just that file).",
489
780
  skipPermission: true,
490
781
  parameters: z.object({
491
- repo_url: z.string().describe("Git repository URL (e.g., https://github.com/user/my-skill.git)"),
782
+ repo_url: z.string().describe("Git repository URL (e.g., https://github.com/user/my-skill.git) or direct SKILL.md URL (e.g., https://github.com/user/repo/blob/main/skills/my-skill/SKILL.md)"),
492
783
  }),
493
784
  handler: async ({ repo_url }) => {
494
785
  console.error(`[io] skill_install called: ${repo_url}`);
@@ -1115,13 +1406,13 @@ export function createTools(deps) {
1115
1406
  },
1116
1407
  });
1117
1408
  const squadSetLead = defineTool("squad_set_lead", {
1118
- description: "Designate an agent as the team lead for their squad. The lead receives delegated tasks (when no specific agent is targeted) and orchestrates the team by divvying subtasks to teammates.",
1409
+ description: "Designate an agent as the team lead for their squad. The lead MUST be a dedicated PM / Senior Engineer with NO domain responsibility — their sole job is coordinating, delegating, and reviewing the team's work. Do not pick an agent who also owns the backend, frontend, tests, or any other implementation domain. The lead receives delegated tasks (when no specific agent is targeted), orchestrates the team via delegate_to_teammate, and holds automatic veto power on PR promotion.",
1119
1410
  skipPermission: true,
1120
1411
  parameters: z.object({
1121
1412
  slug: z.string().describe("Squad slug"),
1122
1413
  character_name: z
1123
1414
  .string()
1124
- .describe("Character name of the agent to make team lead"),
1415
+ .describe("Character name of the agent to make team lead. Choose a PM / Senior Engineer with no domain ownership."),
1125
1416
  }),
1126
1417
  handler: async ({ slug, character_name }) => {
1127
1418
  try {
@@ -1134,7 +1425,12 @@ export function createTools(deps) {
1134
1425
  return `Agent "${character_name}" not found in squad "${slug}". Use squad_agents to list the roster.`;
1135
1426
  }
1136
1427
  deps.setSquadLead(slug, character_name);
1137
- return `⭐ ${character_name} (${target.role_title}) is now the team lead for squad "${squad.name}".`;
1428
+ const dedicated = roleLooksLikeDedicatedLead(target.role_title);
1429
+ const base = `⭐ ${character_name} (${target.role_title}) is now the team lead for squad "${squad.name}". They have automatic veto power on PR promotion.`;
1430
+ if (!dedicated) {
1431
+ return `${base}\n\n⚠️ "${target.role_title}" looks like a domain specialist. Team leads should be a dedicated PM / Senior Engineer with no other domain responsibility — consider adding a dedicated lead agent (e.g. role "Senior Engineering Lead" or "Project Manager") and reassigning.`;
1432
+ }
1433
+ return base;
1138
1434
  }
1139
1435
  catch (err) {
1140
1436
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
@@ -1376,7 +1672,7 @@ export function createTools(deps) {
1376
1672
  return `🚀 Fired IO schedule ${id} now.`;
1377
1673
  },
1378
1674
  });
1379
- return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
1675
+ return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadResetAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github];
1380
1676
  }
1381
1677
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
1382
1678
  if (depth >= maxDepth)
package/dist/daemon.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { getClient, stopClient } from "./copilot/client.js";
2
2
  import { initOrchestrator, sendToOrchestrator, shutdownOrchestrator } from "./copilot/orchestrator.js";
3
- import { startApiServer, setMessageHandler as setApiHandler } from "./api/server.js";
4
- import { createBot, startBot, stopBot, sendProactiveMessage, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
3
+ import { startApiServer, setMessageHandler as setApiHandler, broadcastNotificationToSSE } from "./api/server.js";
4
+ import { createBot, startBot, stopBot, sendProactiveMessage, sendBackgroundNotification, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
5
+ import { setTelegramSender, setTuiSender, setSseBroadcaster } from "./notify.js";
6
+ import { pruneOldScheduleRuns } from "./store/schedule-runs.js";
7
+ import { pruneOldNotifications } from "./store/notifications.js";
8
+ import { printBackgroundNotification } from "./tui/index.js";
5
9
  import { getDb, closeDb } from "./store/db.js";
6
10
  import { clearStaleTasks } from "./store/tasks.js";
7
11
  import { reconcileAgentStatuses, reconcileSquadStatuses } from "./store/squads.js";
@@ -118,6 +122,35 @@ export async function startDaemon() {
118
122
  startScheduler();
119
123
  // Start the IO-level scheduler (squad-independent recurring tasks).
120
124
  startIoScheduler();
125
+ // Background-notification dispatch surfaces (issue #78)
126
+ setSseBroadcaster((p) => broadcastNotificationToSSE(p));
127
+ setTuiSender((opts) => printBackgroundNotification(opts));
128
+ setTelegramSender((opts) => sendBackgroundNotification(opts));
129
+ // Daily cleanup — prune schedule runs and notifications older than 30 days
130
+ const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
131
+ const PRUNE_RETENTION_DAYS = 30;
132
+ const pruneTimer = setInterval(() => {
133
+ try {
134
+ const runsDeleted = pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
135
+ const notificationsDeleted = pruneOldNotifications(PRUNE_RETENTION_DAYS);
136
+ if (runsDeleted > 0 || notificationsDeleted > 0) {
137
+ console.log(`[prune] Cleaned up ${runsDeleted} schedule runs and ${notificationsDeleted} notifications older than ${PRUNE_RETENTION_DAYS} days`);
138
+ }
139
+ }
140
+ catch (err) {
141
+ console.error("[prune] Error during cleanup:", err);
142
+ }
143
+ }, PRUNE_INTERVAL_MS);
144
+ pruneTimer.unref();
145
+ // Run once on startup after a brief delay
146
+ const pruneStartup = setTimeout(() => {
147
+ try {
148
+ pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
149
+ pruneOldNotifications(PRUNE_RETENTION_DAYS);
150
+ }
151
+ catch { /* best effort */ }
152
+ }, 5000);
153
+ pruneStartup.unref?.();
121
154
  console.log("[io] IO is fully operational.");
122
155
  // Notify Telegram if restarting
123
156
  if (config.telegramEnabled && process.env.IO_RESTARTED === "1") {
package/dist/notify.js ADDED
@@ -0,0 +1,105 @@
1
+ import { config } from "./config.js";
2
+ import { insertNotification, } from "./store/notifications.js";
3
+ const HEARTBEAT_PATTERNS = [
4
+ /^no active tasks?\.?$/i,
5
+ /^nothing to report\.?$/i,
6
+ /^all clear\.?$/i,
7
+ /^no updates?\.?$/i,
8
+ /^no changes?\.?$/i,
9
+ /^idle\.?$/i,
10
+ /^heartbeat\.?$/i,
11
+ /^ok\.?$/i,
12
+ ];
13
+ export function isMeaningfulOutput(text) {
14
+ const trimmed = (text ?? "").trim();
15
+ if (trimmed.length < 20)
16
+ return false;
17
+ const firstLine = trimmed.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
18
+ if (HEARTBEAT_PATTERNS.some((re) => re.test(firstLine)))
19
+ return false;
20
+ return true;
21
+ }
22
+ let telegramSender;
23
+ let tuiSender;
24
+ let sseBroadcaster;
25
+ export function setTelegramSender(fn) {
26
+ telegramSender = fn;
27
+ }
28
+ export function setTuiSender(fn) {
29
+ tuiSender = fn;
30
+ }
31
+ export function setSseBroadcaster(fn) {
32
+ sseBroadcaster = fn;
33
+ }
34
+ export function _resetNotifySendersForTests() {
35
+ telegramSender = undefined;
36
+ tuiSender = undefined;
37
+ sseBroadcaster = undefined;
38
+ }
39
+ export async function notifyBackground(input) {
40
+ const dispatched = { telegram: false, tui: false, sse: false };
41
+ const text = (input.text ?? "").trim();
42
+ if (text.length === 0)
43
+ return { dispatched, skipped: "empty" };
44
+ const mode = config.backgroundNotifyMode ?? "meaningful";
45
+ if (mode === "off")
46
+ return { dispatched, skipped: "off" };
47
+ if (mode === "meaningful" && !isMeaningfulOutput(text)) {
48
+ return { dispatched, skipped: "not-meaningful" };
49
+ }
50
+ const { source, title } = input;
51
+ const sourceRefJson = JSON.stringify(stripType(source));
52
+ let row;
53
+ try {
54
+ row = insertNotification({
55
+ source_type: source.type,
56
+ source_ref: sourceRefJson === "{}" ? null : sourceRefJson,
57
+ title,
58
+ text,
59
+ });
60
+ }
61
+ catch (err) {
62
+ console.error("[notify] failed to persist notification:", err);
63
+ return { dispatched };
64
+ }
65
+ if (sseBroadcaster) {
66
+ try {
67
+ sseBroadcaster({
68
+ id: row.id,
69
+ source: { type: source.type, ...stripType(source) },
70
+ title,
71
+ text,
72
+ createdAt: row.created_at,
73
+ });
74
+ dispatched.sse = true;
75
+ }
76
+ catch (err) {
77
+ console.error("[notify] sse broadcast failed:", err);
78
+ }
79
+ }
80
+ if (tuiSender && (config.backgroundNotifyTui ?? true)) {
81
+ try {
82
+ tuiSender({ title, text });
83
+ dispatched.tui = true;
84
+ }
85
+ catch (err) {
86
+ console.error("[notify] tui send failed:", err);
87
+ }
88
+ }
89
+ if (telegramSender && (config.backgroundNotifyTelegram ?? true)) {
90
+ try {
91
+ await telegramSender({ title, text });
92
+ dispatched.telegram = true;
93
+ }
94
+ catch (err) {
95
+ console.error("[notify] telegram send failed:", err);
96
+ }
97
+ }
98
+ return { id: row.id, dispatched };
99
+ }
100
+ function stripType(s) {
101
+ const { type: _t, ...rest } = s;
102
+ void _t;
103
+ return rest;
104
+ }
105
+ //# sourceMappingURL=notify.js.map