heyio 0.6.0 → 0.9.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.
@@ -1,6 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
2
2
  import { join, basename } from "path";
3
- import { execSync } from "child_process";
3
+ import { execFileSync } from "child_process";
4
4
  import { SKILLS_DIR } from "../paths.js";
5
5
  /**
6
6
  * Scan SKILLS_DIR for subdirectories that contain a SKILL.md file.
@@ -80,28 +80,125 @@ export function listSkills() {
80
80
  }
81
81
  return skills;
82
82
  }
83
+ const GITHUB_BLOB_RE = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+\/)?SKILL\.md$/i;
84
+ const RAW_GH_RE = /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+\/)?SKILL\.md$/i;
85
+ const GENERIC_SKILL_MD_RE = /\/SKILL\.md$/i;
86
+ function deriveSlug(repo, pathPrefix) {
87
+ if (!pathPrefix)
88
+ return repo;
89
+ const segments = pathPrefix.replace(/\/$/, "").split("/").filter(Boolean);
90
+ const last = segments[segments.length - 1];
91
+ return last ? `${repo}-${last}` : repo;
92
+ }
83
93
  /**
84
- * Clone a git repo into SKILLS_DIR and return the installed skill info.
85
- * Throws if the cloned repo does not contain a SKILL.md file.
94
+ * Determine whether the input URL points to a full repo or a specific
95
+ * SKILL.md file. For GitHub blob URLs the raw download URL is derived
96
+ * automatically.
86
97
  */
87
- export async function installSkill(repoUrl) {
88
- const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
89
- const destDir = join(SKILLS_DIR, repoName);
90
- execSync(`git clone ${repoUrl} ${destDir}`, { stdio: "pipe" });
91
- const skillMdPath = join(destDir, "SKILL.md");
92
- if (!existsSync(skillMdPath)) {
93
- rmSync(destDir, { recursive: true, force: true });
94
- throw new Error(`Repository "${repoUrl}" does not contain a SKILL.md file.`);
98
+ export function parseSkillUrl(input) {
99
+ const blobMatch = input.match(GITHUB_BLOB_RE);
100
+ if (blobMatch) {
101
+ const [, owner, repo, branch, pathPrefix] = blobMatch;
102
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathPrefix ?? ""}SKILL.md`;
103
+ return { type: "file", rawUrl, slug: deriveSlug(repo, pathPrefix) };
104
+ }
105
+ const rawMatch = input.match(RAW_GH_RE);
106
+ if (rawMatch) {
107
+ const [, _owner, repo, _branch, pathPrefix] = rawMatch;
108
+ return { type: "file", rawUrl: input, slug: deriveSlug(repo, pathPrefix) };
95
109
  }
96
- const content = readFileSync(skillMdPath, "utf-8");
110
+ if (GENERIC_SKILL_MD_RE.test(input)) {
111
+ if (!input.startsWith("https://")) {
112
+ throw new Error("Only https:// URLs are supported for SKILL.md installs.");
113
+ }
114
+ let urlObj;
115
+ try {
116
+ urlObj = new URL(input);
117
+ }
118
+ catch {
119
+ throw new Error(`Invalid URL: ${input}`);
120
+ }
121
+ const segments = urlObj.pathname.split("/").filter(Boolean);
122
+ // Use the segment before SKILL.md, or the hostname as slug fallback
123
+ const slug = segments.length >= 2
124
+ ? segments[segments.length - 2]
125
+ : urlObj.hostname.replace(/\./g, "-");
126
+ return { type: "file", rawUrl: input, slug };
127
+ }
128
+ return { type: "repo", url: input };
129
+ }
130
+ async function installSkillFromFile(rawUrl, slug) {
131
+ if (!rawUrl.startsWith("https://")) {
132
+ throw new Error("Only https:// URLs are supported for SKILL.md installs.");
133
+ }
134
+ const destDir = join(SKILLS_DIR, slug);
135
+ if (existsSync(destDir)) {
136
+ throw new Error(`Skill "${slug}" is already installed.`);
137
+ }
138
+ const response = await fetch(rawUrl);
139
+ if (!response.ok) {
140
+ throw new Error(`Failed to fetch SKILL.md from ${rawUrl} (HTTP ${response.status})`);
141
+ }
142
+ const content = await response.text();
143
+ // Validate: at least one markdown heading in the first 10 lines
144
+ const first10 = content.split(/\r?\n/).slice(0, 10);
145
+ if (!first10.some((line) => /^#\s+/.test(line))) {
146
+ throw new Error("URL does not appear to contain a valid SKILL.md file.");
147
+ }
148
+ mkdirSync(destDir, { recursive: true });
149
+ writeFileSync(join(destDir, "SKILL.md"), content, "utf-8");
97
150
  const { name, description } = parseSkillMd(content);
98
151
  return {
99
- name: name || repoName,
100
- slug: repoName,
152
+ name: name || slug,
153
+ slug,
101
154
  description,
102
155
  path: destDir,
103
156
  };
104
157
  }
158
+ /**
159
+ * Install a skill from a git repo URL or a direct SKILL.md file URL.
160
+ * Throws if the repo/file does not contain a valid SKILL.md.
161
+ */
162
+ export async function installSkill(input) {
163
+ let destDir;
164
+ try {
165
+ const parsed = parseSkillUrl(input);
166
+ if (parsed.type === "file") {
167
+ return await installSkillFromFile(parsed.rawUrl, parsed.slug);
168
+ }
169
+ const repoUrl = parsed.url;
170
+ const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
171
+ if (!repoName) {
172
+ throw new Error("Could not determine skill name from URL.");
173
+ }
174
+ destDir = join(SKILLS_DIR, repoName);
175
+ execFileSync("git", ["clone", repoUrl, destDir], {
176
+ stdio: "pipe",
177
+ timeout: 60_000,
178
+ });
179
+ const skillMdPath = join(destDir, "SKILL.md");
180
+ if (!existsSync(skillMdPath)) {
181
+ rmSync(destDir, { recursive: true, force: true });
182
+ destDir = undefined;
183
+ throw new Error(`Repository "${repoUrl}" does not contain a SKILL.md file.`);
184
+ }
185
+ const content = readFileSync(skillMdPath, "utf-8");
186
+ const { name, description } = parseSkillMd(content);
187
+ return {
188
+ name: name || repoName,
189
+ slug: repoName,
190
+ description,
191
+ path: destDir,
192
+ };
193
+ }
194
+ catch (e) {
195
+ // Clean up partially-created directory on failure
196
+ if (destDir && existsSync(destDir)) {
197
+ rmSync(destDir, { recursive: true, force: true });
198
+ }
199
+ throw e instanceof Error ? e : new Error(String(e));
200
+ }
201
+ }
105
202
  /**
106
203
  * Remove a skill directory by its slug. Returns true if it existed.
107
204
  */
@@ -776,10 +776,10 @@ export function createTools(deps) {
776
776
  },
777
777
  });
778
778
  const skillInstall = defineTool("skill_install", {
779
- 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).",
780
780
  skipPermission: true,
781
781
  parameters: z.object({
782
- 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)"),
783
783
  }),
784
784
  handler: async ({ repo_url }) => {
785
785
  console.error(`[io] skill_install called: ${repo_url}`);
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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Tests for src/notify.ts — isMeaningfulOutput heuristic and notifyBackground
3
+ * dispatch routing.
4
+ *
5
+ * DB isolation: setDbPathForTests() redirects the SQLite singleton to a
6
+ * fresh tmp file so these tests never touch ~/.io/io.db.
7
+ */
8
+ import { before, after, afterEach, describe, it } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { setDbPathForTests, closeDb } from "./store/db.js";
14
+ import { config } from "./config.js";
15
+ import { isMeaningfulOutput, notifyBackground, setTelegramSender, setTuiSender, setSseBroadcaster, _resetNotifySendersForTests, } from "./notify.js";
16
+ // ── DB isolation ────────────────────────────────────────────────────────────
17
+ let tmpDir;
18
+ before(() => {
19
+ tmpDir = mkdtempSync(join(tmpdir(), "io-notify-test-"));
20
+ setDbPathForTests(join(tmpDir, "io.db"));
21
+ });
22
+ after(() => {
23
+ closeDb();
24
+ rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+ // ── Config teardown ─────────────────────────────────────────────────────────
27
+ const origMode = config.backgroundNotifyMode;
28
+ const origTelegram = config.backgroundNotifyTelegram;
29
+ const origTui = config.backgroundNotifyTui;
30
+ afterEach(() => {
31
+ config.backgroundNotifyMode = origMode;
32
+ config.backgroundNotifyTelegram = origTelegram;
33
+ config.backgroundNotifyTui = origTui;
34
+ _resetNotifySendersForTests();
35
+ });
36
+ // ── isMeaningfulOutput ───────────────────────────────────────────────────────
37
+ describe("isMeaningfulOutput", () => {
38
+ describe("returns false for short/empty input", () => {
39
+ it("empty string", () => assert.equal(isMeaningfulOutput(""), false));
40
+ it("whitespace only", () => assert.equal(isMeaningfulOutput(" "), false));
41
+ it("under 20 chars", () => assert.equal(isMeaningfulOutput("short"), false));
42
+ it("exactly 19 chars", () => assert.equal(isMeaningfulOutput("a".repeat(19)), false));
43
+ });
44
+ describe("returns false for heartbeat phrases", () => {
45
+ const phrases = [
46
+ "no active tasks",
47
+ "no active task",
48
+ "nothing to report.",
49
+ "Nothing to report.",
50
+ "ALL CLEAR",
51
+ "no updates",
52
+ "no update",
53
+ "no changes",
54
+ "no change",
55
+ "idle",
56
+ "IDLE",
57
+ "heartbeat",
58
+ "ok",
59
+ "OK",
60
+ ];
61
+ for (const phrase of phrases) {
62
+ it(`"${phrase}"`, () => assert.equal(isMeaningfulOutput(phrase), false));
63
+ }
64
+ });
65
+ it("returns false when heartbeat phrase is first non-empty line (blank preamble)", () => {
66
+ const text = "\n\n \nno active tasks\nsome more content here that is long";
67
+ assert.equal(isMeaningfulOutput(text), false);
68
+ });
69
+ it("returns false when heartbeat is surrounded by whitespace on its line", () => {
70
+ assert.equal(isMeaningfulOutput(" idle "), false);
71
+ });
72
+ it("returns true for normal multi-sentence prose ≥20 chars", () => {
73
+ assert.equal(isMeaningfulOutput("Task 1 completed successfully. Moving to step 2."), true);
74
+ });
75
+ it("returns true for a bulleted report whose first line is not a heartbeat", () => {
76
+ const text = [
77
+ "Squad status update:",
78
+ "- Lion-O: working on PR #80",
79
+ "- Tygra: reviewing PR #79",
80
+ "- Cheetara: idle",
81
+ ].join("\n");
82
+ assert.equal(isMeaningfulOutput(text), true);
83
+ });
84
+ it("returns true for exactly 20 chars", () => {
85
+ assert.equal(isMeaningfulOutput("a".repeat(20)), true);
86
+ });
87
+ });
88
+ // ── notifyBackground dispatch routing ───────────────────────────────────────
89
+ const MEANINGFUL_TEXT = "Squad task monitor: PR #80 merged. Lion-O completed the delegation stats feature. All checks passing.";
90
+ const HEARTBEAT_TEXT = "no active tasks";
91
+ const SOURCE = {
92
+ type: "squad-schedule",
93
+ scheduleId: 7,
94
+ squadSlug: "michaeljolley-io",
95
+ scheduleName: "Active Task Monitor",
96
+ };
97
+ describe("notifyBackground", () => {
98
+ it('mode="off" → skipped:"off", no senders called, no row inserted', async () => {
99
+ config.backgroundNotifyMode = "off";
100
+ let called = false;
101
+ setSseBroadcaster(() => { called = true; });
102
+ setTuiSender(() => { called = true; });
103
+ setTelegramSender(async () => { called = true; });
104
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
105
+ assert.equal(result.skipped, "off");
106
+ assert.equal(result.id, undefined);
107
+ assert.equal(called, false);
108
+ });
109
+ it("empty text → skipped:empty, no row inserted", async () => {
110
+ config.backgroundNotifyMode = "all";
111
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: " " });
112
+ assert.equal(result.skipped, "empty");
113
+ assert.equal(result.id, undefined);
114
+ });
115
+ it('mode="meaningful" + heartbeat text → skipped:"not-meaningful", no row', async () => {
116
+ config.backgroundNotifyMode = "meaningful";
117
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: HEARTBEAT_TEXT });
118
+ assert.equal(result.skipped, "not-meaningful");
119
+ assert.equal(result.id, undefined);
120
+ });
121
+ it('mode="all" + heartbeat + all senders → all dispatch, row inserted', async () => {
122
+ config.backgroundNotifyMode = "all";
123
+ config.backgroundNotifyTelegram = true;
124
+ config.backgroundNotifyTui = true;
125
+ const calls = { sse: false, tui: false, telegram: false };
126
+ setSseBroadcaster(() => { calls.sse = true; });
127
+ setTuiSender(() => { calls.tui = true; });
128
+ setTelegramSender(async () => { calls.telegram = true; });
129
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: HEARTBEAT_TEXT });
130
+ assert.ok(result.id, "should return a row id");
131
+ assert.equal(result.dispatched.sse, true);
132
+ assert.equal(result.dispatched.tui, true);
133
+ assert.equal(result.dispatched.telegram, true);
134
+ assert.equal(calls.sse, true);
135
+ assert.equal(calls.tui, true);
136
+ assert.equal(calls.telegram, true);
137
+ assert.equal(result.skipped, undefined);
138
+ });
139
+ it('mode="meaningful" + meaningful text + all senders → all dispatch', async () => {
140
+ config.backgroundNotifyMode = "meaningful";
141
+ config.backgroundNotifyTelegram = true;
142
+ config.backgroundNotifyTui = true;
143
+ const calls = { sse: false, tui: false, telegram: false };
144
+ setSseBroadcaster(() => { calls.sse = true; });
145
+ setTuiSender(() => { calls.tui = true; });
146
+ setTelegramSender(async () => { calls.telegram = true; });
147
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
148
+ assert.ok(result.id);
149
+ assert.equal(result.dispatched.sse, true);
150
+ assert.equal(result.dispatched.tui, true);
151
+ assert.equal(result.dispatched.telegram, true);
152
+ });
153
+ it("backgroundNotifyTelegram=false → telegram skipped, tui+sse still fire", async () => {
154
+ config.backgroundNotifyMode = "all";
155
+ config.backgroundNotifyTelegram = false;
156
+ config.backgroundNotifyTui = true;
157
+ const calls = { sse: false, tui: false, telegram: false };
158
+ setSseBroadcaster(() => { calls.sse = true; });
159
+ setTuiSender(() => { calls.tui = true; });
160
+ setTelegramSender(async () => { calls.telegram = true; });
161
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
162
+ assert.equal(result.dispatched.telegram, false, "telegram should be skipped");
163
+ assert.equal(result.dispatched.tui, true);
164
+ assert.equal(result.dispatched.sse, true);
165
+ assert.equal(calls.telegram, false);
166
+ });
167
+ it("backgroundNotifyTui=false → tui skipped, telegram+sse still fire", async () => {
168
+ config.backgroundNotifyMode = "all";
169
+ config.backgroundNotifyTelegram = true;
170
+ config.backgroundNotifyTui = false;
171
+ const calls = { sse: false, tui: false, telegram: false };
172
+ setSseBroadcaster(() => { calls.sse = true; });
173
+ setTuiSender(() => { calls.tui = true; });
174
+ setTelegramSender(async () => { calls.telegram = true; });
175
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
176
+ assert.equal(result.dispatched.tui, false, "tui should be skipped");
177
+ assert.equal(result.dispatched.telegram, true);
178
+ assert.equal(result.dispatched.sse, true);
179
+ assert.equal(calls.tui, false);
180
+ });
181
+ it("telegram sender throws → dispatched.telegram=false, others true, no rethrow", async () => {
182
+ config.backgroundNotifyMode = "all";
183
+ config.backgroundNotifyTelegram = true;
184
+ config.backgroundNotifyTui = true;
185
+ const calls = { sse: false, tui: false };
186
+ setSseBroadcaster(() => { calls.sse = true; });
187
+ setTuiSender(() => { calls.tui = true; });
188
+ setTelegramSender(async () => { throw new Error("telegram down"); });
189
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
190
+ assert.equal(result.dispatched.telegram, false);
191
+ assert.equal(result.dispatched.sse, true);
192
+ assert.equal(result.dispatched.tui, true);
193
+ assert.ok(result.id, "row should still be inserted");
194
+ });
195
+ it("TUI sender throws → dispatched.tui=false, others true, no rethrow", async () => {
196
+ config.backgroundNotifyMode = "all";
197
+ config.backgroundNotifyTelegram = true;
198
+ config.backgroundNotifyTui = true;
199
+ setSseBroadcaster(() => { });
200
+ setTuiSender(() => { throw new Error("tui down"); });
201
+ setTelegramSender(async () => { });
202
+ const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
203
+ assert.equal(result.dispatched.tui, false);
204
+ assert.equal(result.dispatched.sse, true);
205
+ assert.equal(result.dispatched.telegram, true);
206
+ });
207
+ it("squad-schedule source detail round-trips to SSE broadcaster payload", async () => {
208
+ config.backgroundNotifyMode = "all";
209
+ let capturedPayload;
210
+ setSseBroadcaster((payload) => { capturedPayload = payload; });
211
+ const source = {
212
+ type: "squad-schedule",
213
+ scheduleId: 42,
214
+ squadSlug: "thundercats",
215
+ scheduleName: "Morning Standup",
216
+ };
217
+ const result = await notifyBackground({
218
+ source,
219
+ title: "Morning Standup Result",
220
+ text: MEANINGFUL_TEXT,
221
+ });
222
+ assert.ok(capturedPayload, "SSE broadcaster should have been called");
223
+ assert.equal(capturedPayload.id, result.id);
224
+ assert.equal(capturedPayload.source.type, "squad-schedule");
225
+ assert.equal(capturedPayload.source.scheduleId, 42);
226
+ assert.equal(capturedPayload.source.squadSlug, "thundercats");
227
+ assert.equal(capturedPayload.source.scheduleName, "Morning Standup");
228
+ assert.equal(capturedPayload.title, "Morning Standup Result");
229
+ assert.ok(capturedPayload.createdAt, "createdAt should be set");
230
+ });
231
+ });
232
+ //# sourceMappingURL=notify.test.js.map
package/dist/store/db.js CHANGED
@@ -1,13 +1,29 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { mkdirSync } from "fs";
3
+ import { dirname } from "path";
3
4
  import { DB_PATH, IO_HOME } from "../paths.js";
4
5
  let db = null;
5
6
  let insertCount = 0;
7
+ let dbPathOverride = null;
8
+ /**
9
+ * Override the DB path for tests. Closes the existing connection (if any)
10
+ * so the next getDb() call opens a fresh DB at the given path.
11
+ * Never call this in production code.
12
+ */
13
+ export function setDbPathForTests(path) {
14
+ if (db) {
15
+ db.close();
16
+ db = null;
17
+ }
18
+ dbPathOverride = path;
19
+ }
6
20
  export function getDb() {
7
21
  if (db)
8
22
  return db;
9
- mkdirSync(IO_HOME, { recursive: true });
10
- db = new Database(DB_PATH);
23
+ const resolvedPath = dbPathOverride ?? DB_PATH;
24
+ const resolvedHome = dbPathOverride ? dirname(resolvedPath) : IO_HOME;
25
+ mkdirSync(resolvedHome, { recursive: true });
26
+ db = new Database(resolvedPath);
11
27
  db.pragma("journal_mode = WAL");
12
28
  db.exec(`
13
29
  CREATE TABLE IF NOT EXISTS io_state (
@@ -115,6 +131,29 @@ SELECT agent_slug,
115
131
  MAX(started_at) AS last_delegated_at
116
132
  FROM agent_tasks
117
133
  GROUP BY agent_slug`,
134
+ `CREATE TABLE IF NOT EXISTS background_notifications (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ source_type TEXT NOT NULL,
137
+ source_ref TEXT,
138
+ title TEXT NOT NULL,
139
+ text TEXT NOT NULL,
140
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
141
+ read_at DATETIME
142
+ )`,
143
+ `CREATE INDEX IF NOT EXISTS idx_bg_notifications_unread ON background_notifications(read_at, created_at)`,
144
+ `CREATE TABLE IF NOT EXISTS schedule_runs (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ schedule_type TEXT NOT NULL,
147
+ schedule_id INTEGER NOT NULL,
148
+ schedule_name TEXT NOT NULL,
149
+ squad_slug TEXT,
150
+ status TEXT NOT NULL DEFAULT 'running',
151
+ error_text TEXT,
152
+ notification_id INTEGER,
153
+ started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
154
+ completed_at DATETIME
155
+ )`,
156
+ `CREATE INDEX IF NOT EXISTS idx_schedule_runs_lookup ON schedule_runs(schedule_type, schedule_id, started_at)`,
118
157
  ];
119
158
  for (const migration of migrations) {
120
159
  try {