heyio 0.3.0 → 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.
- package/dist/copilot/agents.js +29 -12
- package/dist/copilot/io-scheduler.js +132 -0
- package/dist/copilot/review-backfill.js +57 -0
- package/dist/copilot/scheduler.js +19 -3
- package/dist/copilot/system-message.js +8 -0
- package/dist/copilot/tools.js +115 -2
- package/dist/daemon.js +27 -1
- package/dist/store/db.js +13 -0
- package/dist/store/io-schedules.js +63 -0
- package/dist/store/schedules.js +10 -0
- package/dist/store/squads.js +21 -0
- package/package.json +1 -1
package/dist/copilot/agents.js
CHANGED
|
@@ -240,21 +240,38 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
|
|
|
240
240
|
const tierRank = { high: 3, medium: 2, low: 1 };
|
|
241
241
|
const effectiveTier = tierRank[taskTier] >= tierRank[agentTier] ? taskTier : agentTier;
|
|
242
242
|
const model = getModelForTier(effectiveTier);
|
|
243
|
-
// If we have a cached session, check if the model matches
|
|
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).
|
|
244
248
|
const existing = agentSessions.get(key);
|
|
245
249
|
if (existing) {
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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);
|
|
254
274
|
}
|
|
255
|
-
catch { /* best-effort */ }
|
|
256
|
-
agentSessions.delete(key);
|
|
257
|
-
agentSessionModels.delete(key);
|
|
258
275
|
}
|
|
259
276
|
const squad = getSquad(squadSlug);
|
|
260
277
|
const client = await getClient();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// IO-level scheduler — fires recurring tasks for IO itself, independent of
|
|
2
|
+
// any squad. Mirrors the squad scheduler in shape (TICK_MS loop, in-flight
|
|
3
|
+
// guard, reconcile on startup) but dispatches into the orchestrator via
|
|
4
|
+
// sendToOrchestrator with a `background` source so IO can handle the prompt
|
|
5
|
+
// the same way it handles any other user message.
|
|
6
|
+
import { listIoSchedules, listDueIoSchedules, recordIoScheduleRun, setIoScheduleTimestamps, updateIoScheduleNextRun, } from "../store/io-schedules.js";
|
|
7
|
+
import { sendToOrchestrator } from "./orchestrator.js";
|
|
8
|
+
import { nextRun } from "./cron.js";
|
|
9
|
+
const TICK_MS = 30_000;
|
|
10
|
+
let timer;
|
|
11
|
+
const inFlight = new Set();
|
|
12
|
+
function buildPrompt(schedule) {
|
|
13
|
+
const header = `# Scheduled task: ${schedule.name}\n\n_This prompt was fired automatically by the IO scheduler. Cron expression: \`${schedule.cron_expr}\`._`;
|
|
14
|
+
const notes = schedule.notes
|
|
15
|
+
? `\n\n**Operator notes:** ${schedule.notes}`
|
|
16
|
+
: "";
|
|
17
|
+
return `${header}\n\n${schedule.prompt}${notes}`;
|
|
18
|
+
}
|
|
19
|
+
async function fireSchedule(schedule) {
|
|
20
|
+
if (inFlight.has(schedule.id))
|
|
21
|
+
return;
|
|
22
|
+
inFlight.add(schedule.id);
|
|
23
|
+
const ranAt = new Date();
|
|
24
|
+
let nextIso = null;
|
|
25
|
+
try {
|
|
26
|
+
nextIso = nextRun(schedule.cron_expr, ranAt).toISOString();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
console.error(`[io] io-scheduler: cron parse error for schedule ${schedule.id}:`, err instanceof Error ? err.message : err);
|
|
30
|
+
}
|
|
31
|
+
recordIoScheduleRun(schedule.id, ranAt, nextIso);
|
|
32
|
+
console.log(`[io] io-scheduler: firing schedule "${schedule.name}" (next run: ${nextIso ?? "never"})`);
|
|
33
|
+
try {
|
|
34
|
+
await sendToOrchestrator(buildPrompt(schedule), { type: "background" }, () => {
|
|
35
|
+
// No-op: scheduled work is fire-and-forget; output is captured in
|
|
36
|
+
// the orchestrator's conversation log.
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(`[io] io-scheduler: failed to dispatch schedule ${schedule.id}:`, err instanceof Error ? err.message : err);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
inFlight.delete(schedule.id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function tick() {
|
|
47
|
+
let due;
|
|
48
|
+
try {
|
|
49
|
+
due = listDueIoSchedules(new Date());
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
console.error("[io] io-scheduler tick failed:", err instanceof Error ? err.message : err);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const s of due) {
|
|
56
|
+
await fireSchedule(s);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Backfill next_run_at for any IO schedules that are NULL or stale. We
|
|
61
|
+
* advance to the next future occurrence rather than replaying missed runs
|
|
62
|
+
* — same semantics as the squad scheduler.
|
|
63
|
+
*/
|
|
64
|
+
export function reconcileIoSchedules(now = new Date()) {
|
|
65
|
+
for (const s of listIoSchedules()) {
|
|
66
|
+
if (!s.enabled)
|
|
67
|
+
continue;
|
|
68
|
+
let needsUpdate = false;
|
|
69
|
+
if (!s.next_run_at) {
|
|
70
|
+
needsUpdate = true;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const next = new Date(s.next_run_at);
|
|
74
|
+
if (Number.isNaN(next.getTime()) || next <= now)
|
|
75
|
+
needsUpdate = true;
|
|
76
|
+
}
|
|
77
|
+
if (!needsUpdate)
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
const next = nextRun(s.cron_expr, now);
|
|
81
|
+
updateIoScheduleNextRun(s.id, next.toISOString());
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.error(`[io] io-scheduler: invalid cron "${s.cron_expr}" on schedule ${s.id}; clearing next_run_at:`, err instanceof Error ? err.message : err);
|
|
85
|
+
updateIoScheduleNextRun(s.id, null);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function startIoScheduler() {
|
|
90
|
+
if (timer)
|
|
91
|
+
return;
|
|
92
|
+
reconcileIoSchedules();
|
|
93
|
+
timer = setInterval(() => {
|
|
94
|
+
void tick();
|
|
95
|
+
}, TICK_MS);
|
|
96
|
+
// Don't keep the event loop alive on shutdown
|
|
97
|
+
timer.unref?.();
|
|
98
|
+
}
|
|
99
|
+
export function stopIoScheduler() {
|
|
100
|
+
if (timer) {
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
timer = undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Force a schedule to run immediately. Used by the `schedule_run_now` tool.
|
|
107
|
+
*
|
|
108
|
+
* The regular tick path (`fireSchedule`) advances `last_run_at` and
|
|
109
|
+
* `next_run_at` as a side effect, which is correct for an automatic firing
|
|
110
|
+
* but is the wrong behaviour for a manual one — a user testing a schedule at
|
|
111
|
+
* 04:30 should not have the 05:00 occurrence skipped or the schedule shifted.
|
|
112
|
+
* We therefore snapshot both timestamps before firing and restore them after,
|
|
113
|
+
* leaving the persisted schedule untouched.
|
|
114
|
+
*/
|
|
115
|
+
export async function runIoScheduleNow(id) {
|
|
116
|
+
const all = listIoSchedules();
|
|
117
|
+
const s = all.find((x) => x.id === id);
|
|
118
|
+
if (!s)
|
|
119
|
+
return false;
|
|
120
|
+
const previousLast = s.last_run_at;
|
|
121
|
+
const previousNext = s.next_run_at;
|
|
122
|
+
try {
|
|
123
|
+
await fireSchedule(s);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
// Restore the original timestamps even if fireSchedule threw, so a
|
|
127
|
+
// failed manual run cannot silently shift the schedule either.
|
|
128
|
+
setIoScheduleTimestamps(id, previousLast, previousNext);
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=io-scheduler.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getDb } from "../store/db.js";
|
|
2
|
+
/**
|
|
3
|
+
* Surgically correct historical peer-review rows that were stored as REJECTED
|
|
4
|
+
* (approved=0) but whose comments unambiguously begin with `APPROVED` (issue
|
|
5
|
+
* #50). Earlier daemon builds (pre-#43) inspected only the literal first line,
|
|
6
|
+
* which silently flipped many APPROVED reviews into REJECTED whenever the
|
|
7
|
+
* agent began its response with a markdown rule, blank line, header, or a
|
|
8
|
+
* short prose preamble.
|
|
9
|
+
*
|
|
10
|
+
* We only flip 0 -> 1, and only when the *current* parser sees an explicit
|
|
11
|
+
* line-leading APPROVED token in the prose. We never flip 1 -> 0: doing so
|
|
12
|
+
* would destroy data on legitimate prose-only approvals (e.g. "Excellent
|
|
13
|
+
* work — ships it") that the conservative parser would otherwise downgrade.
|
|
14
|
+
*
|
|
15
|
+
* Returns the number of rows updated.
|
|
16
|
+
*/
|
|
17
|
+
export function backfillReviewVerdicts() {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
const rows = db
|
|
20
|
+
.prepare("SELECT id, approved, comments FROM squad_task_reviews WHERE approved = 0 AND comments IS NOT NULL AND comments != ''")
|
|
21
|
+
.all();
|
|
22
|
+
const update = db.prepare("UPDATE squad_task_reviews SET approved = 1 WHERE id = ?");
|
|
23
|
+
let fixed = 0;
|
|
24
|
+
const tx = db.transaction((batch) => {
|
|
25
|
+
for (const r of batch) {
|
|
26
|
+
if (hasExplicitApproval(r.comments ?? "")) {
|
|
27
|
+
update.run(r.id);
|
|
28
|
+
fixed++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
tx(rows);
|
|
33
|
+
return fixed;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when the comment body unambiguously starts with an APPROVED verdict —
|
|
37
|
+
* either as the first non-empty line (after stripping markdown noise) or as
|
|
38
|
+
* the verdict the current parser extracts before any REJECTED token. Anything
|
|
39
|
+
* shorter than that is left as the daemon originally recorded it.
|
|
40
|
+
*/
|
|
41
|
+
function hasExplicitApproval(content) {
|
|
42
|
+
const stripped = content.replace(/[*_`#>]/g, "");
|
|
43
|
+
const lines = stripped
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((l) => l.trim())
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.slice(0, 10);
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const lead = line
|
|
50
|
+
.toUpperCase()
|
|
51
|
+
.match(/^[^A-Z]*\b(APPROVED|REJECTED)\b/);
|
|
52
|
+
if (lead)
|
|
53
|
+
return lead[1] === "APPROVED";
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=review-backfill.js.map
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Schedules survive daemon restarts because next_run_at is persisted. On
|
|
12
12
|
// startup we backfill any next_run_at fields that became stale (or are NULL).
|
|
13
|
-
import { listSchedules, listDueSchedules, recordScheduleRun, updateNextRun } from "../store/schedules.js";
|
|
13
|
+
import { listSchedules, listDueSchedules, recordScheduleRun, setScheduleTimestamps, updateNextRun } from "../store/schedules.js";
|
|
14
14
|
import { getSquad } from "../store/squads.js";
|
|
15
15
|
import { delegateToAgent } from "./agents.js";
|
|
16
16
|
import { nextRun } from "./cron.js";
|
|
@@ -143,13 +143,29 @@ export function stopScheduler() {
|
|
|
143
143
|
clearInterval(timer);
|
|
144
144
|
timer = undefined;
|
|
145
145
|
}
|
|
146
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Manually fire a schedule. Used by squad_schedule_run_now.
|
|
148
|
+
*
|
|
149
|
+
* Snapshots last_run_at and next_run_at before firing and restores them
|
|
150
|
+
* after, so a manual fire never disturbs the regular schedule (a user
|
|
151
|
+
* testing a 05:00 schedule at 04:30 should not have today's 05:00 run
|
|
152
|
+
* skipped or shifted). The fireSchedule path itself advances both fields
|
|
153
|
+
* because that's correct for an automatic firing — only manual runs need
|
|
154
|
+
* to leave the schedule untouched.
|
|
155
|
+
*/
|
|
147
156
|
export async function runScheduleNow(scheduleId) {
|
|
148
157
|
const all = listSchedules();
|
|
149
158
|
const s = all.find((x) => x.id === scheduleId);
|
|
150
159
|
if (!s)
|
|
151
160
|
return { ok: false, error: `Schedule ${scheduleId} not found` };
|
|
152
|
-
|
|
161
|
+
const previousLast = s.last_run_at;
|
|
162
|
+
const previousNext = s.next_run_at;
|
|
163
|
+
try {
|
|
164
|
+
await fireSchedule(s);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
setScheduleTimestamps(scheduleId, previousLast, previousNext);
|
|
168
|
+
}
|
|
153
169
|
return { ok: true };
|
|
154
170
|
}
|
|
155
171
|
//# sourceMappingURL=scheduler.js.map
|
|
@@ -116,6 +116,14 @@ Squads can be put on a recurring cron-style schedule. At the scheduled time IO w
|
|
|
116
116
|
|
|
117
117
|
When a user asks something like "have the IO squad meet every weekday at 5AM to triage and prioritize", call \`squad_schedule_create\` with \`cron: "0 5 * * 1-5"\` and \`agenda: ["triage", "prioritize"]\`.
|
|
118
118
|
|
|
119
|
+
### IO-level Schedules (squad-independent)
|
|
120
|
+
For recurring work that doesn't belong to any project squad — daily digests, periodic health checks, monitoring loops, reminders — use the IO-level scheduler instead of inventing a placeholder squad.
|
|
121
|
+
|
|
122
|
+
- \`schedule_create\` — create a recurring task. Each tick the configured prompt is delivered to the orchestrator as if a user had typed it. Cron is the same 5-field syntax as squad schedules.
|
|
123
|
+
- \`schedule_list\`, \`schedule_pause\`, \`schedule_resume\`, \`schedule_delete\`, \`schedule_run_now\` mirror the squad-schedule lifecycle.
|
|
124
|
+
|
|
125
|
+
When a user asks "remind me at 9AM every day to check the dashboard" or "every hour, summarize my open PRs", reach for \`schedule_create\` — not a squad.
|
|
126
|
+
|
|
119
127
|
### Agent Roles Are Dynamic
|
|
120
128
|
**Do NOT use generic roles** like "developer" or "tester". Analyze the project first and create roles that match its actual technology stack. Examples:
|
|
121
129
|
- IO project → "Copilot SDK Specialist", "Vue.js Frontend Dev", "Express API Engineer"
|
package/dist/copilot/tools.js
CHANGED
|
@@ -6,6 +6,8 @@ import { join, dirname, resolve } from "path";
|
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { UNIVERSES } from "./universes.js";
|
|
8
8
|
import { validateCron, nextRun } from "./cron.js";
|
|
9
|
+
import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
|
|
10
|
+
import { runIoScheduleNow } from "./io-scheduler.js";
|
|
9
11
|
import { createSchedule, deleteSchedule, getSchedule, listSchedules, setScheduleEnabled, } from "../store/schedules.js";
|
|
10
12
|
import { runScheduleNow } from "./scheduler.js";
|
|
11
13
|
// ---------------------------------------------------------------------------
|
|
@@ -1253,7 +1255,7 @@ export function createTools(deps) {
|
|
|
1253
1255
|
},
|
|
1254
1256
|
});
|
|
1255
1257
|
const squadScheduleRunNow = defineTool("squad_schedule_run_now", {
|
|
1256
|
-
description: "Manually fire a squad schedule immediately (useful for testing).
|
|
1258
|
+
description: "Manually fire a squad schedule immediately (useful for testing). last_run_at and next_run_at are preserved so the regular schedule is untouched.",
|
|
1257
1259
|
skipPermission: true,
|
|
1258
1260
|
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1259
1261
|
handler: async ({ id }) => {
|
|
@@ -1263,7 +1265,118 @@ export function createTools(deps) {
|
|
|
1263
1265
|
return `🚀 Fired schedule ${id} now. Use squad_task_status to follow the resulting stand-up.`;
|
|
1264
1266
|
},
|
|
1265
1267
|
});
|
|
1266
|
-
|
|
1268
|
+
// -------------------------------------------------------------------------
|
|
1269
|
+
// IO-level (squad-independent) schedules.
|
|
1270
|
+
// -------------------------------------------------------------------------
|
|
1271
|
+
const scheduleCreate = defineTool("schedule_create", {
|
|
1272
|
+
description: "Schedule a recurring task for IO itself (no squad required). At the scheduled time, the prompt is delivered to the orchestrator as a background message, just like any TUI/Telegram input. Use for daily digests, health checks, monitoring, or any automation that does not belong to a project squad.",
|
|
1273
|
+
skipPermission: true,
|
|
1274
|
+
parameters: z.object({
|
|
1275
|
+
name: z
|
|
1276
|
+
.string()
|
|
1277
|
+
.describe("Human-friendly name, e.g. 'Morning digest' or 'Hourly health check'"),
|
|
1278
|
+
cron: z
|
|
1279
|
+
.string()
|
|
1280
|
+
.describe("Standard 5-field cron expression: 'minute hour dom month dow'. Examples: '0 5 * * *' = daily at 5:00, '0 9 * * 1-5' = 9AM weekdays, '*/15 * * * *' = every 15 minutes."),
|
|
1281
|
+
prompt: z
|
|
1282
|
+
.string()
|
|
1283
|
+
.describe("The prompt to send to the orchestrator each time the schedule fires. Treat it like the message a user would type — concrete, action-oriented."),
|
|
1284
|
+
notes: z
|
|
1285
|
+
.string()
|
|
1286
|
+
.optional()
|
|
1287
|
+
.describe("Optional operator notes appended to the prompt."),
|
|
1288
|
+
}),
|
|
1289
|
+
handler: async ({ name, cron, prompt, notes }) => {
|
|
1290
|
+
const v = validateCron(cron);
|
|
1291
|
+
if (!v.ok)
|
|
1292
|
+
return `Invalid cron expression: ${v.error}`;
|
|
1293
|
+
const created = createIoSchedule({
|
|
1294
|
+
name,
|
|
1295
|
+
cronExpr: cron,
|
|
1296
|
+
prompt,
|
|
1297
|
+
notes: notes ?? null,
|
|
1298
|
+
nextRunAt: v.next.toISOString(),
|
|
1299
|
+
});
|
|
1300
|
+
return `⏰ Scheduled IO task "${created.name}" (id ${created.id}).\n- Cron: \`${cron}\`\n- Next run: ${v.next.toISOString()}`;
|
|
1301
|
+
},
|
|
1302
|
+
});
|
|
1303
|
+
const scheduleList = defineTool("schedule_list", {
|
|
1304
|
+
description: "List all IO-level schedules (those not attached to a squad). For squad schedules, use squad_schedule_list.",
|
|
1305
|
+
skipPermission: true,
|
|
1306
|
+
parameters: z.object({}),
|
|
1307
|
+
handler: async () => {
|
|
1308
|
+
const schedules = listIoSchedules();
|
|
1309
|
+
if (schedules.length === 0) {
|
|
1310
|
+
return "No IO schedules configured.";
|
|
1311
|
+
}
|
|
1312
|
+
return schedules
|
|
1313
|
+
.map((s) => {
|
|
1314
|
+
const enabled = s.enabled ? "▶️ enabled" : "⏸️ paused";
|
|
1315
|
+
const last = s.last_run_at ? `last ${s.last_run_at}` : "never run";
|
|
1316
|
+
const next = s.next_run_at ?? "—";
|
|
1317
|
+
const promptPreview = s.prompt.length > 120 ? s.prompt.slice(0, 120) + "…" : s.prompt;
|
|
1318
|
+
return `- **${s.name}** (id ${s.id}) — ${enabled}\n cron: \`${s.cron_expr}\`\n next: ${next} — ${last}\n prompt: ${promptPreview.replace(/\n/g, " ")}${s.notes ? `\n notes: ${s.notes}` : ""}`;
|
|
1319
|
+
})
|
|
1320
|
+
.join("\n");
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
const scheduleDelete = defineTool("schedule_delete", {
|
|
1324
|
+
description: "Delete an IO schedule by id.",
|
|
1325
|
+
skipPermission: true,
|
|
1326
|
+
parameters: z.object({
|
|
1327
|
+
id: z.number().int().describe("Schedule id (from schedule_list)"),
|
|
1328
|
+
}),
|
|
1329
|
+
handler: async ({ id }) => {
|
|
1330
|
+
const existing = getIoSchedule(id);
|
|
1331
|
+
if (!existing)
|
|
1332
|
+
return `IO schedule ${id} not found.`;
|
|
1333
|
+
deleteIoSchedule(id);
|
|
1334
|
+
return `🗑️ Deleted IO schedule "${existing.name}" (id ${id}).`;
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
const schedulePause = defineTool("schedule_pause", {
|
|
1338
|
+
description: "Pause an IO schedule so it stops firing (preserves config — resume with schedule_resume).",
|
|
1339
|
+
skipPermission: true,
|
|
1340
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1341
|
+
handler: async ({ id }) => {
|
|
1342
|
+
const existing = getIoSchedule(id);
|
|
1343
|
+
if (!existing)
|
|
1344
|
+
return `IO schedule ${id} not found.`;
|
|
1345
|
+
setIoScheduleEnabled(id, false);
|
|
1346
|
+
return `⏸️ Paused IO schedule "${existing.name}" (id ${id}).`;
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
const scheduleResume = defineTool("schedule_resume", {
|
|
1350
|
+
description: "Resume a paused IO schedule. The next run is computed from now using the stored cron expression.",
|
|
1351
|
+
skipPermission: true,
|
|
1352
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1353
|
+
handler: async ({ id }) => {
|
|
1354
|
+
const existing = getIoSchedule(id);
|
|
1355
|
+
if (!existing)
|
|
1356
|
+
return `IO schedule ${id} not found.`;
|
|
1357
|
+
setIoScheduleEnabled(id, true);
|
|
1358
|
+
try {
|
|
1359
|
+
const next = nextRun(existing.cron_expr);
|
|
1360
|
+
updateIoScheduleNextRun(id, next.toISOString());
|
|
1361
|
+
return `▶️ Resumed IO schedule "${existing.name}" (id ${id}). Next run: ${next.toISOString()}`;
|
|
1362
|
+
}
|
|
1363
|
+
catch (err) {
|
|
1364
|
+
return `Resumed IO schedule "${existing.name}" but failed to compute next run: ${err instanceof Error ? err.message : String(err)}`;
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
const scheduleRunNow = defineTool("schedule_run_now", {
|
|
1369
|
+
description: "Manually fire an IO schedule immediately (useful for testing). last_run_at and next_run_at are preserved so the regular schedule is untouched.",
|
|
1370
|
+
skipPermission: true,
|
|
1371
|
+
parameters: z.object({ id: z.number().int().describe("Schedule id") }),
|
|
1372
|
+
handler: async ({ id }) => {
|
|
1373
|
+
const ok = await runIoScheduleNow(id);
|
|
1374
|
+
if (!ok)
|
|
1375
|
+
return `IO schedule ${id} not found.`;
|
|
1376
|
+
return `🚀 Fired IO schedule ${id} now.`;
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
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];
|
|
1267
1380
|
}
|
|
1268
1381
|
function walkDirectory(dir, maxDepth = 3, depth = 0) {
|
|
1269
1382
|
if (depth >= maxDepth)
|
package/dist/daemon.js
CHANGED
|
@@ -4,7 +4,10 @@ import { startApiServer, setMessageHandler as setApiHandler } from "./api/server
|
|
|
4
4
|
import { createBot, startBot, stopBot, sendProactiveMessage, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
|
|
5
5
|
import { getDb, closeDb } from "./store/db.js";
|
|
6
6
|
import { clearStaleTasks } from "./store/tasks.js";
|
|
7
|
+
import { reconcileAgentStatuses, reconcileSquadStatuses } from "./store/squads.js";
|
|
8
|
+
import { backfillReviewVerdicts } from "./copilot/review-backfill.js";
|
|
7
9
|
import { startScheduler, stopScheduler } from "./copilot/scheduler.js";
|
|
10
|
+
import { startIoScheduler, stopIoScheduler } from "./copilot/io-scheduler.js";
|
|
8
11
|
import { config } from "./config.js";
|
|
9
12
|
import { ensureWikiStructure } from "./wiki/fs.js";
|
|
10
13
|
import { autoUpdate } from "./update.js";
|
|
@@ -62,8 +65,28 @@ export async function startDaemon() {
|
|
|
62
65
|
if (wikiIsNew) {
|
|
63
66
|
console.log("[io] Created wiki at ~/.io/wiki/");
|
|
64
67
|
}
|
|
65
|
-
// Clear stale tasks from previous run
|
|
68
|
+
// Clear stale tasks from previous run, and reset any agent/squad rows left
|
|
69
|
+
// in 'working' or 'error' state — the in-memory Copilot sessions backing
|
|
70
|
+
// those rows did not survive the restart, so the persisted status is lying.
|
|
66
71
|
clearStaleTasks();
|
|
72
|
+
const resetAgents = reconcileAgentStatuses();
|
|
73
|
+
const resetSquads = reconcileSquadStatuses();
|
|
74
|
+
if (resetAgents > 0 || resetSquads > 0) {
|
|
75
|
+
console.log(`[io] Reconciled stale statuses on startup: ${resetAgents} agent(s), ${resetSquads} squad(s) → idle`);
|
|
76
|
+
}
|
|
77
|
+
// Backfill any historical peer-review rows whose recorded verdict (approved
|
|
78
|
+
// 0/1) does not match what the current parser would extract from the prose.
|
|
79
|
+
// Earlier daemon builds had a brittle first-line-only parser that flipped
|
|
80
|
+
// many APPROVED reviews into REJECTED (issue #50).
|
|
81
|
+
try {
|
|
82
|
+
const fixed = backfillReviewVerdicts();
|
|
83
|
+
if (fixed > 0) {
|
|
84
|
+
console.log(`[io] Backfilled ${fixed} peer-review verdict(s) using current parser`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error("[io] Review-verdict backfill failed:", err instanceof Error ? err.message : err);
|
|
89
|
+
}
|
|
67
90
|
// Prune old sessions
|
|
68
91
|
pruneOldSessions();
|
|
69
92
|
// Start Copilot SDK client
|
|
@@ -93,6 +116,8 @@ export async function startDaemon() {
|
|
|
93
116
|
}
|
|
94
117
|
// Start the squad scheduler (background cron-style stand-ups).
|
|
95
118
|
startScheduler();
|
|
119
|
+
// Start the IO-level scheduler (squad-independent recurring tasks).
|
|
120
|
+
startIoScheduler();
|
|
96
121
|
console.log("[io] IO is fully operational.");
|
|
97
122
|
// Notify Telegram if restarting
|
|
98
123
|
if (config.telegramEnabled && process.env.IO_RESTARTED === "1") {
|
|
@@ -121,6 +146,7 @@ async function shutdown() {
|
|
|
121
146
|
catch { /* best effort */ }
|
|
122
147
|
}
|
|
123
148
|
stopScheduler();
|
|
149
|
+
stopIoScheduler();
|
|
124
150
|
await shutdownOrchestrator();
|
|
125
151
|
try {
|
|
126
152
|
await stopClient();
|
package/dist/store/db.js
CHANGED
|
@@ -96,6 +96,19 @@ export function getDb() {
|
|
|
96
96
|
)`,
|
|
97
97
|
`CREATE INDEX IF NOT EXISTS idx_squad_schedules_due
|
|
98
98
|
ON squad_schedules (enabled, next_run_at)`,
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS io_schedules (
|
|
100
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
101
|
+
name TEXT NOT NULL,
|
|
102
|
+
cron_expr TEXT NOT NULL,
|
|
103
|
+
prompt TEXT NOT NULL,
|
|
104
|
+
notes TEXT,
|
|
105
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
106
|
+
last_run_at DATETIME,
|
|
107
|
+
next_run_at DATETIME,
|
|
108
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
109
|
+
)`,
|
|
110
|
+
`CREATE INDEX IF NOT EXISTS idx_io_schedules_due
|
|
111
|
+
ON io_schedules (enabled, next_run_at)`,
|
|
99
112
|
];
|
|
100
113
|
for (const migration of migrations) {
|
|
101
114
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
export function createIoSchedule(input) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
const info = db
|
|
5
|
+
.prepare(`INSERT INTO io_schedules
|
|
6
|
+
(name, cron_expr, prompt, notes, enabled, next_run_at)
|
|
7
|
+
VALUES (?, ?, ?, ?, 1, ?)`)
|
|
8
|
+
.run(input.name, input.cronExpr, input.prompt, input.notes ?? null, input.nextRunAt);
|
|
9
|
+
const id = Number(info.lastInsertRowid);
|
|
10
|
+
return getIoSchedule(id);
|
|
11
|
+
}
|
|
12
|
+
export function getIoSchedule(id) {
|
|
13
|
+
return getDb()
|
|
14
|
+
.prepare("SELECT * FROM io_schedules WHERE id = ?")
|
|
15
|
+
.get(id);
|
|
16
|
+
}
|
|
17
|
+
export function listIoSchedules() {
|
|
18
|
+
return getDb()
|
|
19
|
+
.prepare("SELECT * FROM io_schedules ORDER BY id ASC")
|
|
20
|
+
.all();
|
|
21
|
+
}
|
|
22
|
+
export function listDueIoSchedules(now) {
|
|
23
|
+
return getDb()
|
|
24
|
+
.prepare(`SELECT * FROM io_schedules
|
|
25
|
+
WHERE enabled = 1
|
|
26
|
+
AND next_run_at IS NOT NULL
|
|
27
|
+
AND next_run_at <= ?
|
|
28
|
+
ORDER BY next_run_at ASC`)
|
|
29
|
+
.all(now.toISOString());
|
|
30
|
+
}
|
|
31
|
+
export function deleteIoSchedule(id) {
|
|
32
|
+
const info = getDb()
|
|
33
|
+
.prepare("DELETE FROM io_schedules WHERE id = ?")
|
|
34
|
+
.run(id);
|
|
35
|
+
return info.changes > 0;
|
|
36
|
+
}
|
|
37
|
+
export function setIoScheduleEnabled(id, enabled) {
|
|
38
|
+
const info = getDb()
|
|
39
|
+
.prepare("UPDATE io_schedules SET enabled = ? WHERE id = ?")
|
|
40
|
+
.run(enabled ? 1 : 0, id);
|
|
41
|
+
return info.changes > 0;
|
|
42
|
+
}
|
|
43
|
+
export function recordIoScheduleRun(id, ranAt, nextRunAt) {
|
|
44
|
+
getDb()
|
|
45
|
+
.prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
46
|
+
.run(ranAt.toISOString(), nextRunAt, id);
|
|
47
|
+
}
|
|
48
|
+
export function updateIoScheduleNextRun(id, nextRunAt) {
|
|
49
|
+
getDb()
|
|
50
|
+
.prepare("UPDATE io_schedules SET next_run_at = ? WHERE id = ?")
|
|
51
|
+
.run(nextRunAt, id);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Overwrite both last_run_at and next_run_at directly. Unlike
|
|
55
|
+
* recordIoScheduleRun this accepts NULL for last_run_at, which is needed when
|
|
56
|
+
* restoring a schedule's "never run" state after a manual run_now.
|
|
57
|
+
*/
|
|
58
|
+
export function setIoScheduleTimestamps(id, lastRunAt, nextRunAt) {
|
|
59
|
+
getDb()
|
|
60
|
+
.prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
61
|
+
.run(lastRunAt, nextRunAt, id);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=io-schedules.js.map
|
package/dist/store/schedules.js
CHANGED
|
@@ -70,4 +70,14 @@ export function updateNextRun(id, nextRunAt) {
|
|
|
70
70
|
.prepare("UPDATE squad_schedules SET next_run_at = ? WHERE id = ?")
|
|
71
71
|
.run(nextRunAt, id);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Overwrite both last_run_at and next_run_at directly. Unlike
|
|
75
|
+
* recordScheduleRun this accepts NULL for last_run_at, which is needed when
|
|
76
|
+
* restoring a schedule's "never run" state after a manual run_now.
|
|
77
|
+
*/
|
|
78
|
+
export function setScheduleTimestamps(id, lastRunAt, nextRunAt) {
|
|
79
|
+
getDb()
|
|
80
|
+
.prepare("UPDATE squad_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
81
|
+
.run(lastRunAt, nextRunAt, id);
|
|
82
|
+
}
|
|
73
83
|
//# sourceMappingURL=schedules.js.map
|
package/dist/store/squads.js
CHANGED
|
@@ -93,6 +93,27 @@ export function updateAgentStatus(squadSlug, characterName, status) {
|
|
|
93
93
|
.prepare("UPDATE squad_agents SET status = ? WHERE squad_slug = ? AND character_name = ?")
|
|
94
94
|
.run(status, squadSlug, characterName);
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Reset any agent left in a non-idle status from a previous daemon run.
|
|
98
|
+
* The in-memory Copilot sessions don't survive a restart, so persisted
|
|
99
|
+
* "working" or "error" rows can never be accurate after startup. Returns
|
|
100
|
+
* the number of rows reset for logging.
|
|
101
|
+
*/
|
|
102
|
+
export function reconcileAgentStatuses() {
|
|
103
|
+
const info = getDb()
|
|
104
|
+
.prepare("UPDATE squad_agents SET status = 'idle' WHERE status IN ('working', 'error')")
|
|
105
|
+
.run();
|
|
106
|
+
return info.changes;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Mirror of reconcileAgentStatuses for squads themselves.
|
|
110
|
+
*/
|
|
111
|
+
export function reconcileSquadStatuses() {
|
|
112
|
+
const info = getDb()
|
|
113
|
+
.prepare("UPDATE squads SET status = 'idle' WHERE status IN ('working', 'error')")
|
|
114
|
+
.run();
|
|
115
|
+
return info.changes;
|
|
116
|
+
}
|
|
96
117
|
export function logDecision(squadSlug, decision, context) {
|
|
97
118
|
getDb()
|
|
98
119
|
.prepare("INSERT INTO squad_decisions (squad_slug, decision, context) VALUES (?, ?, ?)")
|