heyio 0.24.1 → 0.25.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/config.js CHANGED
@@ -7,6 +7,7 @@ const DEFAULT_CONFIG = {
7
7
  backgroundNotifyMode: "meaningful",
8
8
  backgroundNotifyTelegram: true,
9
9
  backgroundNotifyTui: true,
10
+ watchdogEnabled: true,
10
11
  };
11
12
  function loadConfig() {
12
13
  mkdirSync(IO_HOME, { recursive: true });
package/dist/daemon.js CHANGED
@@ -13,6 +13,7 @@ import { backfillReviewVerdicts } from "./copilot/review-backfill.js";
13
13
  import { startScheduler, stopScheduler } from "./copilot/scheduler.js";
14
14
  import { startIoScheduler, stopIoScheduler } from "./copilot/io-scheduler.js";
15
15
  import { config } from "./config.js";
16
+ import { startWatchdog } from "./watchdog.js";
16
17
  import { ensureWikiStructure } from "./wiki/fs.js";
17
18
  import { autoUpdate } from "./update.js";
18
19
  import { readdirSync, statSync, rmSync } from "fs";
@@ -129,6 +130,12 @@ export async function startDaemon() {
129
130
  // Daily cleanup — prune schedule runs and notifications older than 30 days
130
131
  const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
131
132
  const PRUNE_RETENTION_DAYS = 30;
133
+ // Start event loop watchdog
134
+ let stopWatchdog;
135
+ if (config.watchdogEnabled) {
136
+ stopWatchdog = startWatchdog();
137
+ console.error("[io] Event loop watchdog started");
138
+ }
132
139
  const pruneTimer = setInterval(() => {
133
140
  try {
134
141
  const runsDeleted = pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
package/dist/tui/index.js CHANGED
@@ -252,9 +252,14 @@ export async function startTui() {
252
252
  firstChunk = false;
253
253
  }
254
254
  if (done) {
255
+ // Finalize the current message as its own line, then reset state
256
+ // so the next message in a multi-turn response starts a fresh bubble.
255
257
  clearLine();
256
- process.stdout.write(accumulated + "\n");
257
- rl.prompt();
258
+ if (accumulated) {
259
+ process.stdout.write(accumulated + "\n");
260
+ }
261
+ accumulated = "";
262
+ firstChunk = true;
258
263
  }
259
264
  else {
260
265
  accumulated += text;
@@ -262,6 +267,10 @@ export async function startTui() {
262
267
  process.stdout.write(accumulated);
263
268
  }
264
269
  });
270
+ // Restore the prompt once the handler promise fully resolves (i.e. after
271
+ // all turns are done), rather than inside the done callback so that
272
+ // multi-turn responses don't render a premature prompt between messages.
273
+ rl.prompt();
265
274
  }
266
275
  catch (err) {
267
276
  clearLine();
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Daemon liveness watchdog.
3
+ *
4
+ * Uses a periodic setInterval to detect event loop stalls. If the interval
5
+ * fires significantly late, the event loop was blocked for that duration.
6
+ *
7
+ * - Warning logged if stall exceeds WARN_THRESHOLD_MS (30s)
8
+ * - Process exits if stall exceeds FATAL_THRESHOLD_MS (60s), relying on
9
+ * the process supervisor to restart
10
+ */
11
+ const DEFAULT_CHECK_INTERVAL_MS = 30_000;
12
+ const DEFAULT_WARN_THRESHOLD_MS = 30_000;
13
+ const DEFAULT_FATAL_THRESHOLD_MS = 60_000;
14
+ let timer = null;
15
+ let lastTick = 0;
16
+ /**
17
+ * Start the event loop watchdog. Returns a stop function.
18
+ */
19
+ export function startWatchdog(opts = {}) {
20
+ const checkInterval = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
21
+ const warnThreshold = opts.warnThresholdMs ?? DEFAULT_WARN_THRESHOLD_MS;
22
+ const fatalThreshold = opts.fatalThresholdMs ?? DEFAULT_FATAL_THRESHOLD_MS;
23
+ lastTick = Date.now();
24
+ timer = setInterval(() => {
25
+ const now = Date.now();
26
+ const elapsed = now - lastTick;
27
+ const stallMs = elapsed - checkInterval;
28
+ if (stallMs >= fatalThreshold) {
29
+ console.error(`[watchdog] FATAL: event loop stalled for ${Math.round(stallMs / 1000)}s (threshold: ${Math.round(fatalThreshold / 1000)}s) at ${new Date(now).toISOString()}`);
30
+ if (opts.onFatal) {
31
+ opts.onFatal(stallMs);
32
+ }
33
+ else {
34
+ process.exit(1);
35
+ }
36
+ }
37
+ else if (stallMs >= warnThreshold) {
38
+ console.error(`[watchdog] WARNING: event loop stalled for ${Math.round(stallMs / 1000)}s at ${new Date(now).toISOString()}`);
39
+ opts.onStall?.(stallMs);
40
+ }
41
+ lastTick = now;
42
+ }, checkInterval);
43
+ timer.unref();
44
+ return () => {
45
+ if (timer) {
46
+ clearInterval(timer);
47
+ timer = null;
48
+ }
49
+ };
50
+ }
51
+ //# sourceMappingURL=watchdog.js.map
@@ -0,0 +1,83 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { startWatchdog } from "./watchdog.js";
4
+ describe("watchdog", () => {
5
+ let stop;
6
+ beforeEach(() => {
7
+ if (stop) {
8
+ stop();
9
+ stop = undefined;
10
+ }
11
+ });
12
+ it("calls onStall when stall exceeds warn threshold", async () => {
13
+ const stalls = [];
14
+ stop = startWatchdog({
15
+ checkIntervalMs: 30,
16
+ warnThresholdMs: 40,
17
+ fatalThresholdMs: 5000,
18
+ onStall: (ms) => stalls.push(ms),
19
+ });
20
+ // Block event loop longer than checkInterval + warnThreshold
21
+ const start = Date.now();
22
+ while (Date.now() - start < 120) {
23
+ // busy-wait
24
+ }
25
+ // Let the interval fire
26
+ await new Promise((resolve) => setTimeout(resolve, 50));
27
+ assert.ok(stalls.length > 0, "onStall should have been called");
28
+ assert.ok(stalls[0] >= 40, `stall duration ${stalls[0]} should be >= 40ms`);
29
+ stop();
30
+ stop = undefined;
31
+ });
32
+ it("calls onFatal when stall exceeds fatal threshold", async () => {
33
+ const fatals = [];
34
+ stop = startWatchdog({
35
+ checkIntervalMs: 30,
36
+ warnThresholdMs: 20,
37
+ fatalThresholdMs: 50,
38
+ onStall: () => { },
39
+ onFatal: (ms) => fatals.push(ms),
40
+ });
41
+ // Block event loop longer than checkInterval + fatalThreshold
42
+ const start = Date.now();
43
+ while (Date.now() - start < 150) {
44
+ // busy-wait
45
+ }
46
+ await new Promise((resolve) => setTimeout(resolve, 50));
47
+ assert.ok(fatals.length > 0, "onFatal should have been called");
48
+ assert.ok(fatals[0] >= 50, `fatal duration ${fatals[0]} should be >= 50ms`);
49
+ stop();
50
+ stop = undefined;
51
+ });
52
+ it("does not fire when event loop is healthy", async () => {
53
+ const stalls = [];
54
+ const fatals = [];
55
+ stop = startWatchdog({
56
+ checkIntervalMs: 30,
57
+ warnThresholdMs: 500,
58
+ fatalThresholdMs: 1000,
59
+ onStall: (ms) => stalls.push(ms),
60
+ onFatal: (ms) => fatals.push(ms),
61
+ });
62
+ // Wait without blocking — interval fires on time
63
+ await new Promise((resolve) => setTimeout(resolve, 100));
64
+ assert.equal(stalls.length, 0, "onStall should not fire for healthy loop");
65
+ assert.equal(fatals.length, 0, "onFatal should not fire for healthy loop");
66
+ stop();
67
+ stop = undefined;
68
+ });
69
+ it("stop function cleans up the interval", () => {
70
+ stop = startWatchdog({
71
+ checkIntervalMs: 30,
72
+ warnThresholdMs: 10,
73
+ fatalThresholdMs: 20,
74
+ onStall: () => { },
75
+ onFatal: () => { },
76
+ });
77
+ // Should not throw on double-stop
78
+ stop();
79
+ stop();
80
+ stop = undefined;
81
+ });
82
+ });
83
+ //# sourceMappingURL=watchdog.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"