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 +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +96 -0
- data/handbook/extensions/ace-wake/index.js +457 -0
- data/handbook/extensions/ace-wake/package.json +3 -0
- data/handbook/extensions/ace-wake/wake/ask-bridge.js +102 -0
- data/handbook/extensions/ace-wake/wake/eyes-runtime.js +147 -0
- data/handbook/extensions/ace-wake/wake/eyes.js +152 -0
- data/handbook/extensions/ace-wake/wake/inbox-discovery.js +111 -0
- data/handbook/extensions/ace-wake/wake/loop-subscription.js +62 -0
- data/handbook/extensions/ace-wake/wake/types.js +112 -0
- data/handbook/extensions/ace-wake/wake/wake-command-parser.js +169 -0
- data/handbook/extensions/ace-wake/wake/wake-dispatcher.js +86 -0
- data/handbook/extensions/ace-wake/wake/wake-registry.js +772 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +984 -0
- data/lib/ace/wake/version.rb +7 -0
- data/lib/ace/wake.rb +25 -0
- metadata +62 -0
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
|
+
}
|