ace-wake 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c70984a4740dce94a78731fd0852370b10a23a4b05e43286450ad83b7121bad3
4
+ data.tar.gz: 757c46a35a41081cfc85666681326a0275d127d8d2490c6470e6e8dbb1db4fc6
5
+ SHA512:
6
+ metadata.gz: bd19ae88ac86295da911337b06c20aa07f6c282fc14cc5f900527b93fb3b2c6989083789863565f49af0d17e77faee14b543a90995c514a4236ab33710f9ba40
7
+ data.tar.gz: 41b39de4a48e8dfc036774424161bcbc416aa25f36090daa7d181bb021223e2b2a5e1d1b863c810f94309c5adb03760821400a6bea502bdc008729746ab11e02
data/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-10-30
9
+
10
+ First publishable release of the ace-wake asset package (task family
11
+ 8xs.t.pu0).
12
+
13
+ ### Added
14
+
15
+ - Publishable gem packaging: `ace-wake.gemspec`, `lib/ace/wake/version.rb`,
16
+ changelog, Rakefile.
17
+ - Extension source under `handbook/extensions/ace-wake/` — the standard
18
+ handbook projection tree — as the single copy (task 8xs.t.pu0.0);
19
+ deployment converged from the gem (`ace-handbook sync --provider pi`,
20
+ `ace-lab install` extension projections) instead of the manual host copy.
21
+ - Minitest wrapper executing the JavaScript suite (`node --test`) so
22
+ `ace-test ace-wake` runs the real extension tests.
23
+ - Zero-idle window coverage (8xs.t.pu0.f): 30 minutes of healthy silence
24
+ consume zero model turns while technical polling is counted separately.
25
+ - Secret-ask coverage: a redacted watch wakes with the case reference only.
26
+
27
+ ### Changed
28
+
29
+ - Eyes and user subscriptions coexist (task 8xs.t.pu0.2): `/eyes start` no
30
+ longer retires active loops/watches, and `/watch add`/`/loop add` are no
31
+ longer rejected while eyes observes — one shared delivery infrastructure,
32
+ no competing observation ownership.
33
+ - Secret asks wake with the reference only (task 8xs.t.pu0.9): a watch armed
34
+ with `redact` (secret HITL cases) strips the delta's content excerpt, so a
35
+ delivered OTP never passes through the model; the message points at the
36
+ secure channel. Normal asks still name the answer.
37
+
38
+ ### Fixed
39
+
40
+ - macOS environment fixture: watch delta path assertions compare against
41
+ the canonical realpath of the watched case file (`/var` vs
42
+ `/private/var` tmpdir).
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # ace-wake
2
+
3
+ Pi agent extension: `/loop` timers and `/watch` file subscriptions that wake
4
+ idle agents with bounded, coalesced wake messages through `sendUserMessage`
5
+ only. Definitions survive reload/restart with exactly-once re-registration;
6
+ there is no cron, daemon, heartbeat, or delivery-queue engine.
7
+
8
+ ## Provenance
9
+
10
+ This directory is the canonical source of the ace-wake extension. It was
11
+ developed and tested inside `ace-handbook-integration-pi/handbook/extensions/ace-wake/`
12
+ and moved here (verbatim, with its JavaScript test suite) as its top-level
13
+ home. The deployed copy at `~/.pi/agent/extensions/ace-wake` is root-owned,
14
+ dated 2026-10-16, and is a STALE generation of this source (no eyes, no
15
+ worktree inbox discovery, no watch deltas); it was intentionally left
16
+ untouched.
17
+
18
+ This package is a publishable gem (task 8xs.t.pu0.0). The extension source
19
+ lives at `handbook/extensions/ace-wake/` (single copy) and ships in the
20
+ `ace-wake` gem; deployment is converged, not manual:
21
+
22
+ - `ace-handbook sync --provider pi` projects it into a project's
23
+ `.pi/extensions` (the pi provider manifest declares
24
+ `extensions_package: ace-wake`);
25
+ - the `ace-lab` installer projects it from the EXACT installed gem version
26
+ into each manifest-declared extension directory (`gemset.extensions`),
27
+ reporting the loaded version and any restart need.
28
+
29
+ ## Mount-aware poll fallback
30
+
31
+ `/lab/state/hitl-address/ace` (the HITL transport folder) is a FUSE/virtiofs
32
+ mount where inotify event delivery is unreliable: a registered `/watch` never
33
+ fires there. The watch port (`wake/watch-subscription.js`) classifies every
34
+ watched target through a `statfsFn` port (`node:fs.statfsSync` by default;
35
+ string fstype or Linux `f_type` magic number) and, for targets on unreliable
36
+ mounts (virtiofs, fuse, fuseblk, fusectl), runs a 2.5 s reconcile timer that
37
+ re-stats the aggregate fingerprint and wakes on change. `fs.watch` stays
38
+ primary everywhere else; a failing or unsupported statfs keeps `fs.watch`
39
+ primary rather than failing the watch.
40
+
41
+ ## Ask bridge (the `ace-hitl ask` contract)
42
+
43
+ `ace-hitl ask` is create + send + arm + END TURN: it never blocks and never
44
+ polls. The asking agent's Ruby process drops one wake-request file into
45
+ `<session cwd>/.ace-local/hitl/wake-requests/<case id>.json`:
46
+
47
+ ```json
48
+ {"schema": "ace.wake.ask/v1", "kind": "ask", "case_id": "8xhkep",
49
+ "case_path": "/abs/worktree/.ace-local/hitl/8xhkep-slug/8xhkep-slug.hitl.s.md"}
50
+ ```
51
+
52
+ The extension consumes that folder WITHOUT any agent turn activity
53
+ (`wake/ask-bridge.js`, wired in `index.js` as `startAskBridge`): a small
54
+ internal watch port anchors on the folder — or, while it does not exist yet
55
+ (the first `ask` creates it), on its nearest existing ancestor and promotes
56
+ itself once it appears — and its reconcile passes arm one scoped registry
57
+ watch named `ask-<case id>` on the case file, then consume the request.
58
+ When the answer lands (the Captain's reply flows through the transport into
59
+ the case file), the case file's fingerprint changes and the wake fires with
60
+ the `## Answer` section as its excerpt, so the wake carries the answer
61
+ content; the agent acts immediately on wake. Arming is idempotent (an
62
+ already-armed case consumes a stale request without re-arming, which would
63
+ reset the baseline and could swallow a landed answer), an unreadable case
64
+ file leaves the request pending, and the armed watch persists through
65
+ reload/restart like any registry watch — nothing expires on the asker side.
66
+ On unreliable mounts the armed watch fires only through the poll fallback
67
+ above; that fallback is what makes ask work end to end there.
68
+
69
+ ## Tests
70
+
71
+ ```sh
72
+ node --test test/*.test.mjs
73
+ ```
74
+
75
+ Run from this directory. The glob covers the deterministic unit/acceptance
76
+ suites only; the env-gated real-runtime acceptance in `test/acceptance/` is
77
+ excluded and skipped when its environment is missing.
78
+
79
+ Two environment caveats:
80
+
81
+ - `test/report-wake-worktree.test.mjs` spawns this clone's `bin/ace-hitl`,
82
+ which needs the repository's bundler setup and `ACE_TOOLCHAIN=auto` (the
83
+ repo dev default). An inherited `ACE_TOOLCHAIN=installed` shell makes
84
+ those two tests fail.
85
+ - `test/acceptance/pi-sdk-acceptance.test.mjs` runs the extension inside the
86
+ REAL Pi SDK runtime. It needs `PI_PKG_ROOT` (the pi-coding-agent package
87
+ directory) and `EXT_PATH` (the published entrypoint
88
+ `handbook/extensions/ace-wake/index.js`), and is normally driven by the
89
+ ace-handbook-integration-pi feat harness
90
+ (`bin/ace-test ace-handbook-integration-pi feat`):
91
+
92
+ ```sh
93
+ PI_PKG_ROOT="$(npm root -g)/@earendil-works/pi-coding-agent" \
94
+ EXT_PATH="$PWD/handbook/extensions/ace-wake/index.js" \
95
+ node --test test/acceptance/pi-sdk-acceptance.test.mjs
96
+ ```
@@ -0,0 +1,457 @@
1
+ // ace-wake — in-process loop and file-watch wakes for Pi agents.
2
+ //
3
+ // Named /loop timers and /watch file subscriptions queue a bounded wake
4
+ // message to the agent through sendUserMessage only. The agent decides what
5
+ // to do after waking; the extension never executes tasks and never depends on
6
+ // cron, systemd, or any external heartbeat service. While the agent is busy a
7
+ // wake is queued as a follow-up instead of interrupting an in-flight tool
8
+ // operation. Definitions persist as session entries, survive compaction, and
9
+ // reconcile exactly once per session start, reload, or branch switch.
10
+ //
11
+ // Commands:
12
+ // /loop add NAME --interval SECONDS --message TEXT
13
+ // /loop list
14
+ // /loop remove NAME
15
+ // /watch add NAME --path PATH --message TEXT
16
+ // /watch list
17
+ // /watch remove NAME
18
+
19
+ import { clearInterval, setInterval } from "node:timers";
20
+ import * as nodePath from "node:path";
21
+
22
+ import { registerEyes } from "./wake/eyes-runtime.js";
23
+ import { WakeDispatcher } from "./wake/wake-dispatcher.js";
24
+ import { parseWakeCommand } from "./wake/wake-command-parser.js";
25
+ import { createLoopPort } from "./wake/loop-subscription.js";
26
+ import { createWatchPort, nodeReadFn, nodeStatFn, nodeStatfsFn, nodeWatchFactory } from "./wake/watch-subscription.js";
27
+ import { armPendingAskWatches, requestsDirFor } from "./wake/ask-bridge.js";
28
+ import {
29
+ SESSION_ENTRY_TYPE,
30
+ WakeError,
31
+ boundMessage,
32
+ } from "./wake/types.js";
33
+ import { WakeRegistry } from "./wake/wake-registry.js";
34
+
35
+ const STATUS_KEY = "ace-wake";
36
+
37
+ /**
38
+ * Extension factory. Pi calls it as `factory(pi)`; the optional second
39
+ * argument exists so tests can inject deterministic clock, watcher, and
40
+ * stat ports. Production defaults are real Node timers and fs.
41
+ *
42
+ * All mutable state lives inside this factory closure: one extension runtime
43
+ * (one pi process, or one SDK session) owns exactly one state slot, and
44
+ * multiple runtimes in one process never share state.
45
+ *
46
+ * @param {object} pi ExtensionAPI surface provided by Pi.
47
+ * @param {object} [ports] Injectable host ports for deterministic tests.
48
+ */
49
+ export default function (pi, ports = {}) {
50
+ /** @type {{registry: WakeRegistry} | undefined} */
51
+ let runtime;
52
+
53
+ /** The ask bridge's own watch port (one per session, replaced on re-bind). */
54
+ let askBridgePort;
55
+
56
+ let eyes;
57
+ const getRuntime = () => runtime;
58
+ const bindRuntime = (ctx) => {
59
+ runtime?.registry.dispose();
60
+ askBridgePort?.stopAll();
61
+ runtime = { registry: createRegistry(pi, ctx, ports) };
62
+ runtime.registry.reconcile();
63
+ askBridgePort = startAskBridge(ctx, ports, runtime.registry);
64
+ };
65
+
66
+ // Eyes and user subscriptions coexist by contract (8xs.t.pu0.2):
67
+ // eyes owns STANDING observation of canonical HITL events, the
68
+ // registry owns /loop timers and /watch file subscriptions, and both
69
+ // deliver through the same sendUserMessage infrastructure. Eyes never
70
+ // rejects /watch (or /loop) add and never removes an active
71
+ // subscription — there is no competing observation ownership here.
72
+ pi.registerCommand("loop", {
73
+ description: "Manage named timer loops that wake this agent (add | list | remove)",
74
+ handler: async (args, ctx) => {
75
+ await handleCommand(ctx, "loop", args, getRuntime, bindRuntime);
76
+ },
77
+ });
78
+
79
+ pi.registerCommand("watch", {
80
+ description: "Manage named file watches that wake this agent (add | list | remove)",
81
+ handler: async (args, ctx) => {
82
+ await handleCommand(ctx, "watch", args, getRuntime, bindRuntime);
83
+ },
84
+ });
85
+
86
+ // session_start covers startup, reload, resume, fork, and new sessions;
87
+ // session_tree covers branch switches. Reconciliation is idempotent, so a
88
+ // reload re-registers each configured subscription exactly once.
89
+ pi.on("session_start", async (_event, ctx) => {
90
+ bindRuntime(ctx);
91
+ });
92
+ pi.on("session_tree", async (_event, ctx) => {
93
+ bindRuntime(ctx);
94
+ });
95
+
96
+ eyes = registerEyes(pi, ports.eyes);
97
+
98
+ // A run has begun: wakes dispatched concurrently during the idle-to-running
99
+ // transition can now enter the follow-up queue safely, so the registry
100
+ // drains anything it serialized.
101
+ pi.on("agent_start", async () => {
102
+ runtime?.registry.noteRunStarted();
103
+ });
104
+
105
+ // The agent consumed every queued continuation; same-source wakes may
106
+ // deliver again. Watches whose change was absorbed by a pending wake are
107
+ // re-checked so no change is lost without a new filesystem event.
108
+ pi.on("agent_settled", async () => {
109
+ runtime?.registry.settleAll();
110
+ });
111
+
112
+ // Manual compaction rejects sendUserMessage asynchronously (before, during,
113
+ // and briefly after the compaction events) and never emits agent_settled:
114
+ // wakes are retained while it runs and flushed once the context is actually
115
+ // idle again. Automatic compaction happens inside a run whose pending wakes
116
+ // stay legitimately queued, so it is left untouched.
117
+ pi.on("session_before_compact", async (event) => {
118
+ if (event.reason === "manual") {
119
+ runtime?.registry.pauseDelivery();
120
+ }
121
+ });
122
+ pi.on("session_compact", async (event, ctx) => {
123
+ if (event.reason === "manual") {
124
+ runtime?.registry.resumeDelivery();
125
+ scheduleManualCompactionFlush(ctx);
126
+ }
127
+ });
128
+ pi.on("session_compact_failed", async (event, ctx) => {
129
+ if (event.reason === "manual") {
130
+ runtime?.registry.resumeDelivery();
131
+ scheduleManualCompactionFlush(ctx);
132
+ }
133
+ });
134
+
135
+ pi.on("session_shutdown", async () => {
136
+ runtime?.registry.dispose();
137
+ runtime = undefined;
138
+ askBridgePort?.stopAll();
139
+ askBridgePort = undefined;
140
+ });
141
+
142
+ /**
143
+ * Poll until the host context is genuinely idle (no run, no compaction) and
144
+ * then flush retained wakes. A single deferred callback is not enough: other
145
+ * session_compact handlers may await asynchronous work while Pi still rejects
146
+ * prompts. Bounded polling covers the idle-after-compaction case; a run that
147
+ * continues after compaction flushes via agent_settled instead.
148
+ */
149
+ function scheduleManualCompactionFlush(ctx) {
150
+ const pollMs = 100;
151
+ // Bound the poll to the session that scheduled it: a rebind or shutdown
152
+ // replaces the registry, and the old poll must not flush the new one.
153
+ const registry = runtime.registry;
154
+ const attempt = () => {
155
+ if (runtime?.registry !== registry) {
156
+ return;
157
+ }
158
+ let idle;
159
+ try {
160
+ idle = ctx.isIdle();
161
+ } catch {
162
+ return; // Stale context: session_start rebinds.
163
+ }
164
+ if (idle) {
165
+ registry.completeResume();
166
+ return;
167
+ }
168
+ const timer = setTimeout(attempt, pollMs);
169
+ timer.unref?.();
170
+ };
171
+ attempt();
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The ask bridge: consume `ace-hitl ask` wake requests without any agent
177
+ * turn activity. The bridge owns a small watch port anchored on the
178
+ * session's wake-request folder — or, while that folder does not exist yet
179
+ * (a fresh worktree; the first `ask` creates it), on its nearest existing
180
+ * ancestor, promoted to the real folder as soon as it appears. Its
181
+ * reconcile passes (registration, the delayed registration-gap reconcile,
182
+ * folder events — and the poll fallback on unreliable mounts) arm one
183
+ * scoped `ask-<case id>` watch per pending request through the registry,
184
+ * which owns delivery, coalescing, and persistence for the wakes.
185
+ *
186
+ * @param {object} ctx Session context (cwd selects the request folder).
187
+ * @param {object} ports Injectable host ports (same surface as the registry).
188
+ * @param {WakeRegistry} registry
189
+ * @returns {{stopAll: () => void}}
190
+ */
191
+ function startAskBridge(ctx, ports, registry) {
192
+ const requestsDir = requestsDirFor(ctx.cwd);
193
+ const statFn = ports.statFn ?? nodeStatFn;
194
+ const armPass = () => {
195
+ try {
196
+ armPendingAskWatches({ requestsDir, registry });
197
+ } catch {
198
+ // Arming must never break the extension host.
199
+ }
200
+ };
201
+ const bridgePort = createWatchPort({
202
+ watchFactory: ports.watchFactory ?? nodeWatchFactory,
203
+ statFn,
204
+ statfsFn: ports.statfsFn ?? nodeStatfsFn,
205
+ pollIntervalMs: ports.pollIntervalMs,
206
+ baseDir: ctx.cwd,
207
+ });
208
+ let anchoredTo;
209
+ const promote = () => {
210
+ if (anchoredTo === requestsDir) {
211
+ return;
212
+ }
213
+ try {
214
+ statFn(requestsDir);
215
+ } catch {
216
+ return; // Still absent: the ancestor anchor remains the channel.
217
+ }
218
+ bridgePort.stop("__ace_ask_requests");
219
+ startAnchor();
220
+ // Mirror the registration-gap posture: a request landing between the
221
+ // anchor switch and the new watcher's effectiveness re-arms once.
222
+ armPass();
223
+ };
224
+ const onEvent = () => {
225
+ armPass();
226
+ promote();
227
+ return { delivered: true };
228
+ };
229
+ function startAnchor() {
230
+ let candidate = requestsDir;
231
+ let attempts = 0;
232
+ for (;;) {
233
+ try {
234
+ statFn(candidate);
235
+ break;
236
+ } catch {
237
+ if (attempts >= 6 || candidate === nodePath.dirname(candidate)) {
238
+ return false; // Nothing watchable (synthetic host): arm pass only.
239
+ }
240
+ attempts += 1;
241
+ candidate = nodePath.dirname(candidate);
242
+ }
243
+ }
244
+ try {
245
+ bridgePort.start(
246
+ { kind: "watch", name: "__ace_ask_requests", path: candidate, message: "" },
247
+ onEvent,
248
+ );
249
+ anchoredTo = candidate;
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+ startAnchor();
256
+ // Requests that landed while the session was down arm at startup; the
257
+ // port's registration-gap reconcile covers the arming race.
258
+ armPass();
259
+ return bridgePort;
260
+ }
261
+
262
+ function createRegistry(pi, ctx, ports) {
263
+ const dispatcher = new WakeDispatcher({
264
+ deliver: (sourceKey, text) => deliverWake(pi, ctx, sourceKey, text),
265
+ });
266
+ /** The registry is created after its watch port, so deactivation hooks go
267
+ * through this holder once it exists. */
268
+ let registry;
269
+ const built = new WakeRegistry({
270
+ loops: createLoopPort({
271
+ setIntervalFn: ports.setIntervalFn ?? ((callback, ms) => setInterval(callback, ms)),
272
+ clearIntervalFn: ports.clearIntervalFn ?? ((handle) => clearInterval(handle)),
273
+ }),
274
+ watches: createWatchPort({
275
+ watchFactory: ports.watchFactory ?? nodeWatchFactory,
276
+ statFn: ports.statFn ?? nodeStatFn,
277
+ readFn: ports.readFn ?? nodeReadFn,
278
+ // Mount classifier for the poll fallback (virtiofs/fuse targets are
279
+ // deaf to fs.watch and reconcile on a timer instead); injectable for
280
+ // deterministic tests.
281
+ statfsFn: ports.statfsFn ?? nodeStatfsFn,
282
+ pollIntervalMs: ports.pollIntervalMs,
283
+ baseDir: ctx.cwd,
284
+ // The report-inbox watch covers every linked worktree's
285
+ // .ace-local/hitl by default (the same git worktree discovery
286
+ // ace-hitl's list uses); injectable for deterministic tests.
287
+ resolveTargets: ports.resolveTargets,
288
+ scheduleReconcile: ports.scheduleReconcile,
289
+ onDeactivate: () => registry?.refreshStatus(),
290
+ }),
291
+ state: sessionStatePort(pi, ctx),
292
+ status: statusPort(ctx),
293
+ dispatcher,
294
+ dispatchRecoveryMs: ports.dispatchRecoveryMs,
295
+ isHostQueued: () => {
296
+ try {
297
+ return ctx.hasPendingMessages();
298
+ } catch {
299
+ return true;
300
+ }
301
+ },
302
+ isHostIdle: () => {
303
+ try {
304
+ return ctx.isIdle();
305
+ } catch {
306
+ return false;
307
+ }
308
+ },
309
+ });
310
+ registry = built;
311
+ return built;
312
+ }
313
+
314
+ /**
315
+ * Queue-only delivery, always as a queued follow-up. Pi runs a follow-up
316
+ * immediately when nothing is in flight and queues it behind any active run
317
+ * otherwise, so no window — between runs, during a tool, or while earlier
318
+ * wakes are queued — can drop a wake or interrupt an in-flight operation.
319
+ * The complete rendered message (source prefix included) is bounded.
320
+ */
321
+ function deliverWake(pi, ctx, sourceKey, text) {
322
+ const message = boundMessage(`[ace-wake ${sourceKey}] ${text}`);
323
+ try {
324
+ // Known synchronous preflight: without a selected model Pi rejects the
325
+ // prompt asynchronously and no lifecycle event ever acknowledges it, so
326
+ // the wake is refused here and the next tick re-attempts instead of
327
+ // coalescing behind a stranded pending marker.
328
+ if (!ctx.model) {
329
+ return false;
330
+ }
331
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
332
+ return true;
333
+ } catch {
334
+ // A stale context (session replaced/reloaded) or a rejecting host must
335
+ // not crash the timer callback; the next session_start rebinds.
336
+ return false;
337
+ }
338
+ }
339
+
340
+ function sessionStatePort(pi, ctx) {
341
+ return {
342
+ load() {
343
+ let snapshot;
344
+ for (const entry of ctx.sessionManager.getBranch()) {
345
+ if (entry.type === "custom" && entry.customType === SESSION_ENTRY_TYPE && entry.data) {
346
+ snapshot = entry.data;
347
+ }
348
+ }
349
+ // getBranch() returns the stored entries themselves; cloning keeps
350
+ // registry mutations from rewriting historical session entries, which
351
+ // would leak later edits back into branched-away history.
352
+ return snapshot ? structuredClone(snapshot) : { loops: [], watches: [] };
353
+ },
354
+ save(snapshot) {
355
+ pi.appendEntry(SESSION_ENTRY_TYPE, snapshot);
356
+ },
357
+ };
358
+ }
359
+
360
+ function statusPort(ctx) {
361
+ return {
362
+ render(entries) {
363
+ try {
364
+ // Every context access — including hasUI — throws once the context
365
+ // is stale after session replacement, reload, or disposal.
366
+ if (!ctx.hasUI) {
367
+ return;
368
+ }
369
+ if (entries.length === 0) {
370
+ ctx.ui.setStatus(STATUS_KEY, undefined);
371
+ return;
372
+ }
373
+ const parts = entries.map((entry) => {
374
+ const state = entry.error ? " error" : entry.pending ? " pending" : "";
375
+ return `${entry.kind} ${entry.name}${state} ${entry.detail}`;
376
+ });
377
+ ctx.ui.setStatus(STATUS_KEY, parts.join(" | "));
378
+ } catch {
379
+ // Stale context: skip the render; the next session_start rebinds.
380
+ }
381
+ },
382
+ };
383
+ }
384
+
385
+ async function handleCommand(ctx, kind, args, getRuntime, bindRuntime) {
386
+ try {
387
+ if (!getRuntime()) {
388
+ bindRuntime(ctx);
389
+ }
390
+ const registry = getRuntime().registry;
391
+ const parsed = parseWakeCommand(kind, args);
392
+ switch (parsed.subcommand) {
393
+ case "add":
394
+ addSubscription(registry, kind, parsed);
395
+ notify(ctx, `${kind} "${parsed.name}" added`, "info");
396
+ break;
397
+ case "list":
398
+ notify(ctx, formatList(kind, registry.list()), "info");
399
+ break;
400
+ case "remove":
401
+ removeSubscription(registry, kind, parsed.name);
402
+ notify(ctx, `${kind} "${parsed.name}" removed; no further wakes will fire`, "info");
403
+ break;
404
+ default:
405
+ throw new WakeError(`unknown ${kind} subcommand: ${parsed.subcommand}`);
406
+ }
407
+ } catch (error) {
408
+ notify(ctx, error instanceof WakeError ? error.message : `${kind} failed: ${error.message}`, "error");
409
+ }
410
+ }
411
+
412
+ function addSubscription(registry, kind, parsed) {
413
+ if (kind === "loop") {
414
+ registry.addLoop({
415
+ name: parsed.name,
416
+ intervalSeconds: parsed.flags.interval,
417
+ message: parsed.message,
418
+ });
419
+ return;
420
+ }
421
+ registry.addWatch({
422
+ name: parsed.name,
423
+ path: parsed.flags.path,
424
+ message: parsed.message,
425
+ });
426
+ }
427
+
428
+ function removeSubscription(registry, kind, name) {
429
+ if (kind === "loop") {
430
+ registry.removeLoop(name);
431
+ return;
432
+ }
433
+ registry.removeWatch(name);
434
+ }
435
+
436
+ /**
437
+ * @returns {string}
438
+ */
439
+ function formatList(kind, entries) {
440
+ const plural = kind === "watch" ? "watches" : `${kind}s`;
441
+ const scoped = entries.filter((entry) => entry.kind === kind);
442
+ if (scoped.length === 0) {
443
+ return `no ${plural} configured`;
444
+ }
445
+ return scoped
446
+ .map((entry) => {
447
+ const state = entry.error ? ` ERROR: ${entry.error}` : entry.pending ? " (wake pending)" : "";
448
+ return `${entry.name} — ${entry.detail}${state}`;
449
+ })
450
+ .join("\n");
451
+ }
452
+
453
+ function notify(ctx, message, type) {
454
+ if (ctx.hasUI) {
455
+ ctx.ui.notify(message, type);
456
+ }
457
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }