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.
- package/dist/api/server.js +121 -2
- package/dist/config.js +3 -0
- package/dist/copilot/agents.js +172 -12
- package/dist/copilot/io-scheduler.js +26 -3
- package/dist/copilot/orchestrator.js +30 -3
- package/dist/copilot/scheduler.js +24 -2
- package/dist/copilot/session-timeout.js +112 -0
- package/dist/copilot/session-timeout.test.js +372 -0
- package/dist/copilot/skills.js +78 -4
- package/dist/copilot/system-message.js +12 -8
- package/dist/copilot/tools.js +316 -20
- package/dist/daemon.js +35 -2
- package/dist/notify.js +105 -0
- package/dist/notify.test.js +232 -0
- package/dist/store/db.js +47 -2
- package/dist/store/notifications.js +79 -0
- package/dist/store/notifications.test.js +197 -0
- package/dist/store/schedule-runs.js +46 -0
- package/dist/store/squads.js +10 -0
- package/dist/store/tasks.js +122 -0
- package/dist/telegram/bot.js +14 -0
- package/dist/tui/index.js +73 -0
- package/package.json +3 -2
- package/web-dist/assets/index-CUwy4ylb.js +74 -0
- package/web-dist/assets/index-oSVFpNBp.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-BYoiwmlj.js +0 -74
- package/web-dist/assets/index-DMKRXYjX.css +0 -1
package/dist/copilot/tools.js
CHANGED
|
@@ -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
|
-
//
|
|
14
|
+
// Squad coverage heuristics
|
|
15
15
|
//
|
|
16
16
|
// Every squad must have:
|
|
17
|
-
// 1.
|
|
18
|
-
//
|
|
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
|
-
|
|
30
|
-
|
|
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("
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|