ace-handbook-integration-pi 0.4.0 → 0.6.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 +4 -4
- data/.ace-defaults/handbook/providers/pi.yml +1 -0
- data/CHANGELOG.md +16 -77
- data/README.md +7 -4
- data/handbook/extensions/ace-wake/index.js +345 -0
- data/handbook/extensions/ace-wake/package.json +3 -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 +78 -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 +738 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +692 -0
- data/handbook/prompts/continue-work.md +38 -0
- data/lib/ace/handbook/integration/pi/version.rb +1 -1
- metadata +14 -5
- data/handbook/prompts/loop.md +0 -38
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e1e1f851408ab94551b1f23f43b258fdd064b8df2790f0bb2a80ab05363983a
|
|
4
|
+
data.tar.gz: 7fa298d85b1ce4844320dc980d323b28079c2a05a37dd26ea3dc46d159a41f47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75c4ed2da5fce4f238a8e8d7b401ee4ac524548f15fb3e5f574a9bd5f526f7b73eecbefc80241e814e6ac71d055253e619dffb2c87443b8c949f94b34ce304d1
|
|
7
|
+
data.tar.gz: 53bff4c326bad616aff1ddfcf417d16496cd3177f38c6cb35c95d40b2bd5fdcbe6b923883e55241af306c5f03671b96d1538dd2c244417af5928b96fd769560c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,89 +4,28 @@ All notable changes to ace-handbook-integration-pi will be documented in this fi
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
## [0.4.0] - 2026-09-27
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
- Provider manifest now declares `prompts_dir: .pi/prompts`; canonical pi prompt templates from `handbook/prompts/` project there via `ace-handbook sync`.
|
|
11
|
-
- Shipped the canonical `loop` prompt template: the ACE overseer loop running in-process inside a pi session (`/loop`), cycling the task backlog through the `as-task-work` discipline with executed-evidence stop conditions. Projected to `.pi/prompts/loop.md`.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## [0.3.11] - 2026-09-02
|
|
15
|
-
|
|
16
|
-
### Technical
|
|
17
|
-
- Included in the coordinated all-package patch release preparation for ACE monorepo review.
|
|
18
|
-
## [0.3.10] - 2026-08-12
|
|
19
|
-
|
|
20
|
-
### Technical
|
|
21
|
-
- Raised the `ace-handbook` dependency floor to `~> 0.31` after the generic release workflow baselines release.
|
|
22
|
-
|
|
23
|
-
## [0.3.9] - 2026-08-12
|
|
24
|
-
|
|
25
|
-
### Technical
|
|
26
|
-
- Aligned gemspec dependency floors with current ACE package minor release lines and safe external minor dependency bumps (no major version jumps).
|
|
27
|
-
|
|
28
|
-
## [0.3.8] - 2026-04-22
|
|
29
|
-
|
|
30
|
-
### Technical
|
|
31
|
-
- **ace-handbook-integration-pi v0.3.8**: Bumped patch release to keep dependency constraints aligned with `ace-handbook v0.28.0`.
|
|
32
|
-
|
|
33
|
-
## [0.3.7] - 2026-04-13
|
|
34
|
-
|
|
35
|
-
### Changed
|
|
36
|
-
- **ace-handbook-integration-pi v0.3.7**: Published handbook and HITL migration release changes for PI handbook integration.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
## [0.3.6] - 2026-04-11
|
|
40
|
-
|
|
41
|
-
### Technical
|
|
42
|
-
- Migrated deterministic coverage to `test/fast/` and documented `ace-handbook-integration-pi` as a fast-only package (no `test/feat/` or `test/e2e/` layer).
|
|
43
|
-
|
|
44
|
-
## [0.3.5] - 2026-03-29
|
|
45
|
-
|
|
46
|
-
### Technical
|
|
47
|
-
- Updated the `ace-handbook` runtime dependency range from `~> 0.21` to `~> 0.22` to follow the new handbook minor release.
|
|
48
|
-
|
|
49
|
-
## [0.3.4] - 2026-03-29
|
|
50
|
-
|
|
51
|
-
### Technical
|
|
52
|
-
- Normalized published gem metadata so RubyGems and Ruby Toolbox use current release information instead of the 1980 fallback date.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
## [0.3.3] - 2026-03-29
|
|
8
|
+
## [0.6.0] - 2026-10-20
|
|
56
9
|
|
|
57
10
|
### Fixed
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- Standardized PI provider casing in README terminology (`PI-specific`, `PI-native`) to match package naming and changelog language.
|
|
64
|
-
|
|
65
|
-
## [0.3.1] - 2026-03-22
|
|
66
|
-
|
|
67
|
-
### Technical
|
|
68
|
-
- Refreshed the package README with standardized handbook integration sections, PI-specific provider wording, and ACE/`ace-handbook` linkage.
|
|
69
|
-
|
|
70
|
-
## [0.3.0] - 2026-03-12
|
|
71
|
-
|
|
72
|
-
### Changed
|
|
73
|
-
- Replaced the legacy `ace-handbook-integration-agent` dependency with a direct `ace-handbook` runtime dependency.
|
|
74
|
-
- Updated the package entrypoint and docs to treat PI integration as a thin provider plugin on top of `ace-handbook`.
|
|
75
|
-
|
|
76
|
-
## [0.2.0] - 2026-03-10
|
|
11
|
+
- `ace-wake` target drop could silently lose a deferred report wake: when a discovered extra target's watcher errored (e.g. `ace-overseer prune`), `dropTarget` in `wake/watch-subscription.js` recomputed the aggregate baseline from CURRENT stats for every remaining path — absorbing an undelivered change (a deferred wake left the fingerprint at its pre-report state, `dirty=true`) into the baseline, so the next settlement boundary saw `next === fingerprint`, cleared dirty, and never woke. The baseline is now adjusted by removing ONLY the dropped target's segment from the recorded fingerprint strings (both `fingerprint` and `previousFingerprint`), keeping every remaining target at its recorded state so the pending change still delivers. Because this shape never touches the filesystem, it also removes the unguarded primary stat that previously ran inside the native `onError` callback: a primary deleted moments before the extra target's error could throw uncaught into the extension host (the primary's own watcher error still deactivates through the documented path). Covered by two new fake-layer scenarios in `test/js/watch-subscription.test.mjs`: a pending undelivered primary wake survives an extra target dropped at settlement, and `dropTarget` no longer throws when the primary was just deleted.
|
|
12
|
+
- `ace-wake` runtime discovery refresh (`wake/watch-subscription.js`): worktree discovery re-ran only inside `handleChange` — the one-shot ~500 ms post-registration reconcile plus events on already-watched paths. A worktree linked long after start whose FIRST report landed in its own (then-unwatched) inbox triggered no refresh pass at all, so the wake was missed until another watched path changed or a re-registration. Settlement boundaries now refresh discovery for EVERY active subscription: `flushDirty()` runs the full refresh pass (target discovery + ancestor→direct re-attachment) for all entries, not only dirty ones, and delivers any fingerprint change as a wake. Covered by a fake-layer scenario (late-linked worktree, report in its own inbox, no watched-path event, wake at the settlement boundary); the real-git late-worktree scenario in `test/js/report-wake-worktree.test.mjs` now expresses production ordering — it waits out the one-shot reconcile before linking the worktree and proves the settlement boundary is the channel that wakes.
|
|
13
|
+
- `ace-wake` discovery micro-race: a report created between the discovery stat proving a target absent and the baseline fingerprint recomputation was absorbed into the baseline (the `absent` state was never recorded), so the first report into that inbox never woke. The baseline now records the PROVEN "absent" state (derived from the ancestor climb, never a re-stat) at registration and at every discovery refresh, and attaching a newly discovered target schedules a follow-up reconcile pass mirroring `start()` — a race-window report stays a pending change and is delivered by that pass. A fake-layer test injects the report exactly inside the stat-throw/recompute window and asserts the wake.
|
|
14
|
+
- `ace-wake` watcher runtime errors on a discovered extra target no longer kill the whole subscription: with runtime discovery, `ace-overseer prune` removing a watched worktree used to deactivate the watch for ALL worktrees (never retried). A runtime error on the primary target keeps the documented deactivate posture; an error on a discovered extra target drops only that target's watcher (the disappearance is absorbed into the baseline — a pruned worktree is not a report) while the subscription stays active, and discovery re-attaches the target if it reappears. A fake-layer test asserts watcher teardown without deactivation and that the primary keeps waking.
|
|
15
|
+
- `ace-wake` report watches now discover worktrees linked AFTER the watch started: inbox expansion (`expandInboxWatchTargets` over `git worktree list`) re-runs on every change/reconcile refresh pass instead of only at registration, so a report filed in a task worktree created while the overseer session is already watching still wakes it — no re-registration, reload, or new queue/cache. A newly discovered target that is still absent joins the baseline fingerprint as its stable "absent" state, so only the first report into it wakes.
|
|
77
16
|
|
|
78
17
|
### Added
|
|
79
|
-
-
|
|
18
|
+
- L7.3 commissioned-work continuation (task 8xg.t.sbz.6.2): new `/continue-work` resume prompt (`handbook/prompts/continue-work.md`) for a pi overseer session. After a report wake or session restart it reads `_overseer` tasks, `ace-assign` assignments, and current project truth, then continues the SAME commissioned process through the existing `ace-overseer` gates (`process-reports`, `work-on`, `handoff`) with single-execution, delivery-repetition-≠-work-repetition, and no-self-seeking rules. Report wake wiring is documented: a `/watch` on `.ace-local/hitl` wakes a waiting session, active turns receive queued follow-ups, and restarts re-register the watch exactly once.
|
|
19
|
+
- JavaScript acceptance suite for the report wake path (`test/js/report-wake.test.mjs`): wake-on-report for an idle session, queued follow-up (never a second execution) during an active turn with coalesced repeats, exactly-once re-registration after restart without replay, and idempotence of an identical repeated notification. Shared session harness extracted to `test/js/session-harness.mjs`; a real-filesystem test proves a file created inside a watched directory (the `.ace-local/hitl` inbox wiring) wakes exactly once.
|
|
80
20
|
|
|
81
21
|
### Changed
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
## [0.1.1] - 2026-03-10
|
|
22
|
+
- `ace-wake` `/watch` now handles directory targets explicitly: the directory itself is watched and any child event is accepted (the mtime/size fingerprint filters noise), so a report file created in `.ace-local/hitl` wakes the session on every platform — Linux inotify delivers child-create events only to a watch on the directory itself, which the previous parent-watch design could never observe. File targets keep the parent-watch + basename-filter behavior that survives atomic replacement.
|
|
23
|
+
- Removed the superseded `/work-backlog` prompt template: its self-seeking backlog cycling (pick any pending project task) contradicts the approved file-based lab contract; `/continue-work` replaces it with no alias.
|
|
85
24
|
|
|
86
|
-
###
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
|
|
25
|
+
### Fixed
|
|
26
|
+
- `ace-wake` watcher re-attachment (`refreshAttachments` in `wake/watch-subscription.js`) could crash the extension host: `watchFactory` may throw synchronously (EMFILE, EPERM, stat race) and re-attachment is reached from native watcher callbacks and the reconcile timer with no guard. The replacement watcher is now attached BEFORE the old ancestor watcher is closed — on an empty result the old watch survives — and a synchronous throw is caught and deactivates the definition (`re-attachment failed: …`, all watchers closed) per the documented runtime-error posture (visible error via `onDeactivate`, never retried) instead of escaping into the host. Covered by a new fake scenario in `test/js/report-wake.test.mjs` (synchronous `watchFactory` throw during re-attachment deactivates the watch, delivers no wake, and leaves no watchers).
|
|
27
|
+
- Cross-worktree report wake (PR393 P2): `ace-hitl create` writes a coworker's report inside THAT coworker's worktree (its own `.ace-local/hitl`), so the documented watch on the main checkout's inbox never fired — reproduced with a real linked worktree, a real `ace-hitl create` and the real watcher (zero wakes). A `/watch` on `.ace-local/hitl` now expands to the inbox of every linked worktree using the same `git worktree list --porcelain` discovery the HITL list uses (new `wake/inbox-discovery.js`, the default `resolveTargets` port of `createWatchPort`, overridable for tests); no second inbox, queue, or cache. The watch fingerprint aggregates all targets — a worktree inbox that does not exist yet is a stable "absent" state watched through its nearest existing ancestor, so the first report into a freshly created inbox still wakes; expansion re-runs at every re-registration (session start/reload/branch switch). `test/js/report-wake.test.mjs` gains cross-worktree scenarios (including a not-yet-existing inbox and exactly-once re-registration across targets) and a new real-git + real-`bin/ace-hitl` scenario in `test/js/report-wake-worktree.test.mjs` fails with the old single-target watch.
|
|
28
|
+
- `ace-wake` watcher re-attachment for formerly-absent inbox targets: when a worktree's `.ace-local/hitl` did not exist at registration, the watcher attached to the nearest existing ancestor — but Linux inotify is non-recursive, so a SECOND report created inside the now-existing inbox fired no event until the next re-registration. Every change/reconcile pass now re-checks targets watched through an ancestor and, once the target exists, closes the ancestor watch and watches the real target directly (`refreshAttachments` in `wake/watch-subscription.js`). Proven by a new fake scenario in `test/js/report-wake.test.mjs` (second report wakes; the ancestor watch is closed and the inbox watched directly) and a second real-`ace-hitl create` step in `test/js/report-wake-worktree.test.mjs`.
|
|
29
|
+
- `expandInboxWatchTargets` now realpath-normalizes its results (worktree roots before joining the inbox suffix, since the inbox itself may not exist yet): symlinked roots (`/tmp` → `/private/tmp`) previously survived as distinct strings, defeating the target dedup in `createWatchPort`'s `resolveTargetsFor` and double-watching one physical inbox.
|
|
30
|
+
- `test/js/report-wake-worktree.test.mjs` no longer sleeps fixed 700/900 ms gaps: an awaited-event helper (`waitFor`, 25 ms poll with a 5 s deadline) awaits each expected wake directly, removing slow-machine flake and shortening the fast path.
|
|
90
31
|
|
|
91
|
-
### Added
|
|
92
|
-
- Initial package scaffold for PI handbook integration support.
|
data/README.md
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
**Consume canonical skills from PI tooling** - keep provider-specific behavior while preserving shared semantics, so agents running under pi-agent get the same skill intent as any other provider.
|
|
23
23
|
|
|
24
|
-
**
|
|
24
|
+
**Resume commissioned work with `/continue-work`** - the canonical `handbook/prompts/continue-work.md` template projects to `.pi/prompts/continue-work.md` (`ace-handbook sync --provider pi`). It is the L7.3 resume prompt for a pi overseer session: after a report wake or a session restart it reads `_overseer` tasks, `ace-assign` assignments, and current project truth, then continues the SAME commissioned process through the existing `ace-overseer` gates (`process-reports`, `work-on`, `handoff`) - one execution, no self-seeking of new work.
|
|
25
|
+
|
|
26
|
+
**Wake idle agents with `/loop` timers and `/watch` file subscriptions** - the bundled ace-wake extension (projected to `.pi/extensions/ace-wake/index.js`) queues bounded wake messages to the live agent through `sendUserMessage` only. Wakes coalesce per source, busy agents receive queued follow-ups instead of interruptions, definitions survive reload/restart with exactly-once re-registration, and there is no cron, daemon, heartbeat, or delivery-queue engine. See [docs/usage.md](docs/usage.md).
|
|
25
27
|
|
|
26
28
|
**Keep provider updates constrained** - update projection assets inside this package instead of canonical definitions, keeping changes isolated from [ace-handbook](../ace-handbook).
|
|
27
29
|
|
|
@@ -29,10 +31,11 @@
|
|
|
29
31
|
|
|
30
32
|
## Testing
|
|
31
33
|
|
|
32
|
-
This package
|
|
34
|
+
This package ships fast tests plus one installed-extension acceptance level.
|
|
33
35
|
|
|
34
|
-
- Deterministic
|
|
35
|
-
-
|
|
36
|
+
- Deterministic coverage (including the JavaScript wake suite run via Node) lives under `test/fast/` and `test/js/`.
|
|
37
|
+
- `test/feat/` runs the ace-wake acceptance scenario against the projected extension inside the real Pi SDK runtime; it requires the `pi` CLI and Node.
|
|
38
|
+
- This package does not introduce `test/e2e/`.
|
|
36
39
|
|
|
37
40
|
Verification commands:
|
|
38
41
|
|
|
@@ -0,0 +1,345 @@
|
|
|
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
|
+
|
|
21
|
+
import { WakeDispatcher } from "./wake/wake-dispatcher.js";
|
|
22
|
+
import { parseWakeCommand } from "./wake/wake-command-parser.js";
|
|
23
|
+
import { createLoopPort } from "./wake/loop-subscription.js";
|
|
24
|
+
import { createWatchPort, nodeStatFn, nodeWatchFactory } from "./wake/watch-subscription.js";
|
|
25
|
+
import {
|
|
26
|
+
SESSION_ENTRY_TYPE,
|
|
27
|
+
WakeError,
|
|
28
|
+
boundMessage,
|
|
29
|
+
} from "./wake/types.js";
|
|
30
|
+
import { WakeRegistry } from "./wake/wake-registry.js";
|
|
31
|
+
|
|
32
|
+
const STATUS_KEY = "ace-wake";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extension factory. Pi calls it as `factory(pi)`; the optional second
|
|
36
|
+
* argument exists so tests can inject deterministic clock, watcher, and
|
|
37
|
+
* stat ports. Production defaults are real Node timers and fs.
|
|
38
|
+
*
|
|
39
|
+
* All mutable state lives inside this factory closure: one extension runtime
|
|
40
|
+
* (one pi process, or one SDK session) owns exactly one state slot, and
|
|
41
|
+
* multiple runtimes in one process never share state.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} pi ExtensionAPI surface provided by Pi.
|
|
44
|
+
* @param {object} [ports] Injectable host ports for deterministic tests.
|
|
45
|
+
*/
|
|
46
|
+
export default function (pi, ports = {}) {
|
|
47
|
+
/** @type {{registry: WakeRegistry} | undefined} */
|
|
48
|
+
let runtime;
|
|
49
|
+
|
|
50
|
+
const getRuntime = () => runtime;
|
|
51
|
+
const bindRuntime = (ctx) => {
|
|
52
|
+
runtime?.registry.dispose();
|
|
53
|
+
runtime = { registry: createRegistry(pi, ctx, ports) };
|
|
54
|
+
runtime.registry.reconcile();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
pi.registerCommand("loop", {
|
|
58
|
+
description: "Manage named timer loops that wake this agent (add | list | remove)",
|
|
59
|
+
handler: async (args, ctx) => {
|
|
60
|
+
await handleCommand(ctx, "loop", args, getRuntime, bindRuntime);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.registerCommand("watch", {
|
|
65
|
+
description: "Manage named file watches that wake this agent (add | list | remove)",
|
|
66
|
+
handler: async (args, ctx) => {
|
|
67
|
+
await handleCommand(ctx, "watch", args, getRuntime, bindRuntime);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// session_start covers startup, reload, resume, fork, and new sessions;
|
|
72
|
+
// session_tree covers branch switches. Reconciliation is idempotent, so a
|
|
73
|
+
// reload re-registers each configured subscription exactly once.
|
|
74
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
75
|
+
bindRuntime(ctx);
|
|
76
|
+
});
|
|
77
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
78
|
+
bindRuntime(ctx);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// A run has begun: wakes dispatched concurrently during the idle-to-running
|
|
82
|
+
// transition can now enter the follow-up queue safely, so the registry
|
|
83
|
+
// drains anything it serialized.
|
|
84
|
+
pi.on("agent_start", async () => {
|
|
85
|
+
runtime?.registry.noteRunStarted();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// The agent consumed every queued continuation; same-source wakes may
|
|
89
|
+
// deliver again. Watches whose change was absorbed by a pending wake are
|
|
90
|
+
// re-checked so no change is lost without a new filesystem event.
|
|
91
|
+
pi.on("agent_settled", async () => {
|
|
92
|
+
runtime?.registry.settleAll();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Manual compaction rejects sendUserMessage asynchronously (before, during,
|
|
96
|
+
// and briefly after the compaction events) and never emits agent_settled:
|
|
97
|
+
// wakes are retained while it runs and flushed once the context is actually
|
|
98
|
+
// idle again. Automatic compaction happens inside a run whose pending wakes
|
|
99
|
+
// stay legitimately queued, so it is left untouched.
|
|
100
|
+
pi.on("session_before_compact", async (event) => {
|
|
101
|
+
if (event.reason === "manual") {
|
|
102
|
+
runtime?.registry.pauseDelivery();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
106
|
+
if (event.reason === "manual") {
|
|
107
|
+
runtime?.registry.resumeDelivery();
|
|
108
|
+
scheduleManualCompactionFlush(ctx);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
pi.on("session_compact_failed", async (event, ctx) => {
|
|
112
|
+
if (event.reason === "manual") {
|
|
113
|
+
runtime?.registry.resumeDelivery();
|
|
114
|
+
scheduleManualCompactionFlush(ctx);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_shutdown", async () => {
|
|
119
|
+
runtime?.registry.dispose();
|
|
120
|
+
runtime = undefined;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Poll until the host context is genuinely idle (no run, no compaction) and
|
|
125
|
+
* then flush retained wakes. A single deferred callback is not enough: other
|
|
126
|
+
* session_compact handlers may await asynchronous work while Pi still rejects
|
|
127
|
+
* prompts. Bounded polling covers the idle-after-compaction case; a run that
|
|
128
|
+
* continues after compaction flushes via agent_settled instead.
|
|
129
|
+
*/
|
|
130
|
+
function scheduleManualCompactionFlush(ctx) {
|
|
131
|
+
const pollMs = 100;
|
|
132
|
+
// Bound the poll to the session that scheduled it: a rebind or shutdown
|
|
133
|
+
// replaces the registry, and the old poll must not flush the new one.
|
|
134
|
+
const registry = runtime.registry;
|
|
135
|
+
const attempt = () => {
|
|
136
|
+
if (runtime?.registry !== registry) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let idle;
|
|
140
|
+
try {
|
|
141
|
+
idle = ctx.isIdle();
|
|
142
|
+
} catch {
|
|
143
|
+
return; // Stale context: session_start rebinds.
|
|
144
|
+
}
|
|
145
|
+
if (idle) {
|
|
146
|
+
registry.completeResume();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const timer = setTimeout(attempt, pollMs);
|
|
150
|
+
timer.unref?.();
|
|
151
|
+
};
|
|
152
|
+
attempt();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createRegistry(pi, ctx, ports) {
|
|
157
|
+
const dispatcher = new WakeDispatcher({
|
|
158
|
+
deliver: (sourceKey, text) => deliverWake(pi, ctx, sourceKey, text),
|
|
159
|
+
});
|
|
160
|
+
/** The registry is created after its watch port, so deactivation hooks go
|
|
161
|
+
* through this holder once it exists. */
|
|
162
|
+
let registry;
|
|
163
|
+
const built = new WakeRegistry({
|
|
164
|
+
loops: createLoopPort({
|
|
165
|
+
setIntervalFn: ports.setIntervalFn ?? ((callback, ms) => setInterval(callback, ms)),
|
|
166
|
+
clearIntervalFn: ports.clearIntervalFn ?? ((handle) => clearInterval(handle)),
|
|
167
|
+
}),
|
|
168
|
+
watches: createWatchPort({
|
|
169
|
+
watchFactory: ports.watchFactory ?? nodeWatchFactory,
|
|
170
|
+
statFn: ports.statFn ?? nodeStatFn,
|
|
171
|
+
baseDir: ctx.cwd,
|
|
172
|
+
// The report-inbox watch covers every linked worktree's
|
|
173
|
+
// .ace-local/hitl by default (the same git worktree discovery
|
|
174
|
+
// ace-hitl's list uses); injectable for deterministic tests.
|
|
175
|
+
resolveTargets: ports.resolveTargets,
|
|
176
|
+
scheduleReconcile: ports.scheduleReconcile,
|
|
177
|
+
onDeactivate: () => registry?.refreshStatus(),
|
|
178
|
+
}),
|
|
179
|
+
state: sessionStatePort(pi, ctx),
|
|
180
|
+
status: statusPort(ctx),
|
|
181
|
+
dispatcher,
|
|
182
|
+
dispatchRecoveryMs: ports.dispatchRecoveryMs,
|
|
183
|
+
isHostQueued: () => {
|
|
184
|
+
try {
|
|
185
|
+
return ctx.hasPendingMessages();
|
|
186
|
+
} catch {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
isHostIdle: () => {
|
|
191
|
+
try {
|
|
192
|
+
return ctx.isIdle();
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
registry = built;
|
|
199
|
+
return built;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Queue-only delivery, always as a queued follow-up. Pi runs a follow-up
|
|
204
|
+
* immediately when nothing is in flight and queues it behind any active run
|
|
205
|
+
* otherwise, so no window — between runs, during a tool, or while earlier
|
|
206
|
+
* wakes are queued — can drop a wake or interrupt an in-flight operation.
|
|
207
|
+
* The complete rendered message (source prefix included) is bounded.
|
|
208
|
+
*/
|
|
209
|
+
function deliverWake(pi, ctx, sourceKey, text) {
|
|
210
|
+
const message = boundMessage(`[ace-wake ${sourceKey}] ${text}`);
|
|
211
|
+
try {
|
|
212
|
+
// Known synchronous preflight: without a selected model Pi rejects the
|
|
213
|
+
// prompt asynchronously and no lifecycle event ever acknowledges it, so
|
|
214
|
+
// the wake is refused here and the next tick re-attempts instead of
|
|
215
|
+
// coalescing behind a stranded pending marker.
|
|
216
|
+
if (!ctx.model) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
220
|
+
return true;
|
|
221
|
+
} catch {
|
|
222
|
+
// A stale context (session replaced/reloaded) or a rejecting host must
|
|
223
|
+
// not crash the timer callback; the next session_start rebinds.
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sessionStatePort(pi, ctx) {
|
|
229
|
+
return {
|
|
230
|
+
load() {
|
|
231
|
+
let snapshot;
|
|
232
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
233
|
+
if (entry.type === "custom" && entry.customType === SESSION_ENTRY_TYPE && entry.data) {
|
|
234
|
+
snapshot = entry.data;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// getBranch() returns the stored entries themselves; cloning keeps
|
|
238
|
+
// registry mutations from rewriting historical session entries, which
|
|
239
|
+
// would leak later edits back into branched-away history.
|
|
240
|
+
return snapshot ? structuredClone(snapshot) : { loops: [], watches: [] };
|
|
241
|
+
},
|
|
242
|
+
save(snapshot) {
|
|
243
|
+
pi.appendEntry(SESSION_ENTRY_TYPE, snapshot);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function statusPort(ctx) {
|
|
249
|
+
return {
|
|
250
|
+
render(entries) {
|
|
251
|
+
try {
|
|
252
|
+
// Every context access — including hasUI — throws once the context
|
|
253
|
+
// is stale after session replacement, reload, or disposal.
|
|
254
|
+
if (!ctx.hasUI) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (entries.length === 0) {
|
|
258
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const parts = entries.map((entry) => {
|
|
262
|
+
const state = entry.error ? " error" : entry.pending ? " pending" : "";
|
|
263
|
+
return `${entry.kind} ${entry.name}${state} ${entry.detail}`;
|
|
264
|
+
});
|
|
265
|
+
ctx.ui.setStatus(STATUS_KEY, parts.join(" | "));
|
|
266
|
+
} catch {
|
|
267
|
+
// Stale context: skip the render; the next session_start rebinds.
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function handleCommand(ctx, kind, args, getRuntime, bindRuntime) {
|
|
274
|
+
try {
|
|
275
|
+
if (!getRuntime()) {
|
|
276
|
+
bindRuntime(ctx);
|
|
277
|
+
}
|
|
278
|
+
const registry = getRuntime().registry;
|
|
279
|
+
const parsed = parseWakeCommand(kind, args);
|
|
280
|
+
switch (parsed.subcommand) {
|
|
281
|
+
case "add":
|
|
282
|
+
addSubscription(registry, kind, parsed);
|
|
283
|
+
notify(ctx, `${kind} "${parsed.name}" added`, "info");
|
|
284
|
+
break;
|
|
285
|
+
case "list":
|
|
286
|
+
notify(ctx, formatList(kind, registry.list()), "info");
|
|
287
|
+
break;
|
|
288
|
+
case "remove":
|
|
289
|
+
removeSubscription(registry, kind, parsed.name);
|
|
290
|
+
notify(ctx, `${kind} "${parsed.name}" removed; no further wakes will fire`, "info");
|
|
291
|
+
break;
|
|
292
|
+
default:
|
|
293
|
+
throw new WakeError(`unknown ${kind} subcommand: ${parsed.subcommand}`);
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
notify(ctx, error instanceof WakeError ? error.message : `${kind} failed: ${error.message}`, "error");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function addSubscription(registry, kind, parsed) {
|
|
301
|
+
if (kind === "loop") {
|
|
302
|
+
registry.addLoop({
|
|
303
|
+
name: parsed.name,
|
|
304
|
+
intervalSeconds: parsed.flags.interval,
|
|
305
|
+
message: parsed.message,
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
registry.addWatch({
|
|
310
|
+
name: parsed.name,
|
|
311
|
+
path: parsed.flags.path,
|
|
312
|
+
message: parsed.message,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function removeSubscription(registry, kind, name) {
|
|
317
|
+
if (kind === "loop") {
|
|
318
|
+
registry.removeLoop(name);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
registry.removeWatch(name);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
function formatList(kind, entries) {
|
|
328
|
+
const plural = kind === "watch" ? "watches" : `${kind}s`;
|
|
329
|
+
const scoped = entries.filter((entry) => entry.kind === kind);
|
|
330
|
+
if (scoped.length === 0) {
|
|
331
|
+
return `no ${plural} configured`;
|
|
332
|
+
}
|
|
333
|
+
return scoped
|
|
334
|
+
.map((entry) => {
|
|
335
|
+
const state = entry.error ? ` ERROR: ${entry.error}` : entry.pending ? " (wake pending)" : "";
|
|
336
|
+
return `${entry.name} — ${entry.detail}${state}`;
|
|
337
|
+
})
|
|
338
|
+
.join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function notify(ctx, message, type) {
|
|
342
|
+
if (ctx.hasUI) {
|
|
343
|
+
ctx.ui.notify(message, type);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import * as nodePath from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cross-worktree discovery for the ace-hitl report inbox.
|
|
7
|
+
*
|
|
8
|
+
* `ace-hitl create` writes a coworker's report inside THAT coworker's
|
|
9
|
+
* worktree (its own `.ace-local/hitl`), while the overseer session usually
|
|
10
|
+
* waits in the main checkout — a watch on the main checkout's inbox alone
|
|
11
|
+
* never fires. The HITL list already solves this with multi-worktree
|
|
12
|
+
* discovery over `git worktree list --porcelain`; this module reuses the
|
|
13
|
+
* SAME discovery (no second inbox, queue, or cache) to expand a configured
|
|
14
|
+
* `.ace-local/hitl` watch to every linked worktree's inbox directory.
|
|
15
|
+
*
|
|
16
|
+
* Expansion happens at watch registration, at every re-registration
|
|
17
|
+
* (session start/reload/branch switch), and on every watch refresh pass,
|
|
18
|
+
* so worktrees linked while the session is already watching are picked
|
|
19
|
+
* up without waiting for a re-registration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The inbox path suffix that marks a watch as the ace-hitl report inbox. */
|
|
23
|
+
export const HITL_INBOX_SEGMENTS = [".ace-local", "hitl"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List the roots of every linked worktree (including the main checkout)
|
|
27
|
+
* using the same porcelain output ace-hitl's WorktreeScopeResolver reads.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} baseDir session working directory (git runs there)
|
|
30
|
+
* @param {(args: string[], options: object) => string} [execGit] injectable
|
|
31
|
+
* git runner for tests; defaults to execFileSync with output decoding
|
|
32
|
+
* @returns {string[]} unique worktree roots, main first; [] when git fails
|
|
33
|
+
*/
|
|
34
|
+
export function discoverWorktreeRoots(baseDir, execGit = defaultExecGit) {
|
|
35
|
+
let output;
|
|
36
|
+
try {
|
|
37
|
+
output = execGit(["worktree", "list", "--porcelain"], { cwd: baseDir });
|
|
38
|
+
} catch {
|
|
39
|
+
// Not a repository (or git missing): the caller keeps whatever targets
|
|
40
|
+
// it can resolve on its own.
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return output
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.filter((line) => line.startsWith("worktree "))
|
|
46
|
+
.map((line) => line.slice("worktree ".length).trim())
|
|
47
|
+
.filter((path) => path.length > 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the watch targets for a configured path. A path that names an
|
|
52
|
+
* ace-hitl inbox (any `.ace-local/hitl` directory) expands to that same
|
|
53
|
+
* inbox in EVERY discovered worktree, so a report written inside a
|
|
54
|
+
* coworker's worktree wakes the waiting session. Any other path stays a
|
|
55
|
+
* single target — the watch never invents unrelated directories.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} baseDir session working directory
|
|
58
|
+
* @param {string} configuredPath the path the user configured
|
|
59
|
+
* @param {(args: string[], options: object) => string} [execGit]
|
|
60
|
+
* @returns {string[]} canonical target directories, sorted and unique
|
|
61
|
+
*/
|
|
62
|
+
export function expandInboxWatchTargets(baseDir, configuredPath, execGit = defaultExecGit) {
|
|
63
|
+
const primary = realpathOrSelf(nodePath.resolve(baseDir, configuredPath));
|
|
64
|
+
if (!namesInbox(primary)) {
|
|
65
|
+
return [primary];
|
|
66
|
+
}
|
|
67
|
+
const roots = discoverWorktreeRoots(baseDir, execGit);
|
|
68
|
+
if (roots.length === 0) {
|
|
69
|
+
return [primary];
|
|
70
|
+
}
|
|
71
|
+
// Realpath the (existing) worktree ROOT, not the not-yet-existing inbox:
|
|
72
|
+
// /tmp → /private/tmp style symlinked roots would otherwise survive as
|
|
73
|
+
// separate strings and defeat dedup against the canonicalized primary.
|
|
74
|
+
const targets = new Set(roots.map((root) => nodePath.join(realpathOrSelf(root), ...HITL_INBOX_SEGMENTS)));
|
|
75
|
+
targets.add(primary);
|
|
76
|
+
return [...targets].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} path
|
|
81
|
+
* @returns {string} The realpath when the path exists, the path itself otherwise.
|
|
82
|
+
*/
|
|
83
|
+
function realpathOrSelf(path) {
|
|
84
|
+
try {
|
|
85
|
+
return nodeFs.realpathSync(path);
|
|
86
|
+
} catch {
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string} path @returns {boolean} */
|
|
92
|
+
function namesInbox(path) {
|
|
93
|
+
const segments = nodePath.normalize(path).split(nodePath.sep);
|
|
94
|
+
const last = HITL_INBOX_SEGMENTS.length;
|
|
95
|
+
for (let index = segments.length - last; index >= 0; index -= 1) {
|
|
96
|
+
const window = segments.slice(index, index + last);
|
|
97
|
+
if (window.join(nodePath.sep) === HITL_INBOX_SEGMENTS.join(nodePath.sep)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string[]} args
|
|
106
|
+
* @param {{cwd: string}} options
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function defaultExecGit(args, options) {
|
|
110
|
+
return execFileSync("git", args, { ...options, encoding: "utf8" });
|
|
111
|
+
}
|