ace-handbook-integration-pi 0.5.0 → 0.7.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/CHANGELOG.md +18 -84
- data/README.md +2 -2
- data/handbook/extensions/ace-wake/index.js +22 -0
- data/handbook/extensions/ace-wake/wake/eyes-runtime.js +145 -0
- data/handbook/extensions/ace-wake/wake/eyes.js +150 -0
- data/handbook/extensions/ace-wake/wake/inbox-discovery.js +111 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +388 -43
- data/handbook/prompts/continue-work.md +38 -0
- data/lib/ace/handbook/integration/pi/version.rb +1 -1
- metadata +34 -3
- data/handbook/prompts/work-backlog.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: 9a4d798b4819221f1fc95c725700fc5911859b62a197ce3973389d18e208f6d4
|
|
4
|
+
data.tar.gz: 66c216a3a9c9ebd4164b1e6ea2e3d096b0e0f4468d3efc9ebb7941e6f652be47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b2818ac03f7a22254d4247ae0a6374e738dbdad853ebf06d08c92c76619bc746ed0d9365f42590d1057322bdede120459ec6de718b795cd702182f24a1d3047
|
|
7
|
+
data.tar.gz: 8b31425debbb283d7694fbb8e38222271f55762f4eae14c59f84e3719d97d41b96d85e6921605e5c14e76a1564b4593513324fa5d65a3f8518794343d8f79533
|
data/CHANGELOG.md
CHANGED
|
@@ -4,100 +4,34 @@ All notable changes to ace-handbook-integration-pi will be documented in this fi
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
## [0.
|
|
7
|
+
## [0.7.0] - 2026-10-23
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
|
-
- Shipped the **ace-wake** Pi extension (`handbook/extensions/ace-wake/index.js`, projected to `.pi/extensions/`): session-scoped `/loop` timers and `/watch` file subscriptions that wake the live agent through queued `sendUserMessage` messages only. Wakes are bounded, source-prefixed, coalesce per source while one is queued, and reach busy agents as queued follow-ups that never interrupt an in-flight tool operation. Concurrent sources deliver into an active run immediately (serialization guards only the idle-to-running transition). Delivery ambiguities from Pi's void `sendUserMessage` resolve toward redelivery over loss: unconfirmed attempts re-fire on a bounded recovery window, and watch changes refused while no model is selected retry on that window without needing another filesystem event. Definitions persist as session entries and reconcile exactly once per session start, reload, or branch switch with no missed-tick replay. No cron, systemd, or external heartbeat dependency.
|
|
11
|
-
- Provider manifest now declares `extensions_dir: .pi/extensions`; `ace-handbook sync` projects extension assets and prunes stale projected files via a projection receipt without touching user-authored extensions.
|
|
12
|
-
- Command flags accept quoted values both after the flag (`--message "check the build"`) and inline (`--message="check the build"`); an unterminated quote fails the command instead of storing a truncated value, while prose apostrophes mid-token stay literal.
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
- Renamed the backlog-running prompt from `/loop` to `/work-backlog` (template `handbook/prompts/work-backlog.md`) so the timer command owns the `/loop` name. Behavior unchanged; no alias is provided.
|
|
16
|
-
- Package verification now includes the JavaScript wake suite (run on the installed Node runtime) and an installed-extension acceptance scenario inside the real Pi SDK runtime (`test/feat/`).
|
|
17
|
-
|
|
18
|
-
## [0.4.0] - 2026-09-27
|
|
19
|
-
|
|
20
|
-
### Added
|
|
21
|
-
- Provider manifest now declares `prompts_dir: .pi/prompts`; canonical pi prompt templates from `handbook/prompts/` project there via `ace-handbook sync`.
|
|
22
|
-
- 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`.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
## [0.3.11] - 2026-09-02
|
|
26
|
-
|
|
27
|
-
### Technical
|
|
28
|
-
- Included in the coordinated all-package patch release preparation for ACE monorepo review.
|
|
29
|
-
## [0.3.10] - 2026-08-12
|
|
30
|
-
|
|
31
|
-
### Technical
|
|
32
|
-
- Raised the `ace-handbook` dependency floor to `~> 0.31` after the generic release workflow baselines release.
|
|
33
|
-
|
|
34
|
-
## [0.3.9] - 2026-08-12
|
|
35
|
-
|
|
36
|
-
### Technical
|
|
37
|
-
- Aligned gemspec dependency floors with current ACE package minor release lines and safe external minor dependency bumps (no major version jumps).
|
|
38
|
-
|
|
39
|
-
## [0.3.8] - 2026-04-22
|
|
40
|
-
|
|
41
|
-
### Technical
|
|
42
|
-
- **ace-handbook-integration-pi v0.3.8**: Bumped patch release to keep dependency constraints aligned with `ace-handbook v0.28.0`.
|
|
43
|
-
|
|
44
|
-
## [0.3.7] - 2026-04-13
|
|
45
|
-
|
|
46
|
-
### Changed
|
|
47
|
-
- **ace-handbook-integration-pi v0.3.7**: Published handbook and HITL migration release changes for PI handbook integration.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
## [0.3.6] - 2026-04-11
|
|
51
|
-
|
|
52
|
-
### Technical
|
|
53
|
-
- Migrated deterministic coverage to `test/fast/` and documented `ace-handbook-integration-pi` as a fast-only package (no `test/feat/` or `test/e2e/` layer).
|
|
11
|
+
- Add durable in-process semantic eyes, coalescing, explicit deadlines, bounded budgets and actual Pi notification acceptance; retire the actor’s old loops when enabled.
|
|
54
12
|
|
|
55
|
-
## [0.3.5] - 2026-03-29
|
|
56
13
|
|
|
57
|
-
|
|
58
|
-
- Updated the `ace-handbook` runtime dependency range from `~> 0.21` to `~> 0.22` to follow the new handbook minor release.
|
|
59
|
-
|
|
60
|
-
## [0.3.4] - 2026-03-29
|
|
61
|
-
|
|
62
|
-
### Technical
|
|
63
|
-
- Normalized published gem metadata so RubyGems and Ruby Toolbox use current release information instead of the 1980 fallback date.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## [0.3.3] - 2026-03-29
|
|
14
|
+
## [0.6.0] - 2026-10-20
|
|
67
15
|
|
|
68
16
|
### Fixed
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- Standardized PI provider casing in README terminology (`PI-specific`, `PI-native`) to match package naming and changelog language.
|
|
75
|
-
|
|
76
|
-
## [0.3.1] - 2026-03-22
|
|
77
|
-
|
|
78
|
-
### Technical
|
|
79
|
-
- Refreshed the package README with standardized handbook integration sections, PI-specific provider wording, and ACE/`ace-handbook` linkage.
|
|
80
|
-
|
|
81
|
-
## [0.3.0] - 2026-03-12
|
|
82
|
-
|
|
83
|
-
### Changed
|
|
84
|
-
- Replaced the legacy `ace-handbook-integration-agent` dependency with a direct `ace-handbook` runtime dependency.
|
|
85
|
-
- Updated the package entrypoint and docs to treat PI integration as a thin provider plugin on top of `ace-handbook`.
|
|
86
|
-
|
|
87
|
-
## [0.2.0] - 2026-03-10
|
|
17
|
+
- `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.
|
|
18
|
+
- `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.
|
|
19
|
+
- `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.
|
|
20
|
+
- `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.
|
|
21
|
+
- `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.
|
|
88
22
|
|
|
89
23
|
### Added
|
|
90
|
-
-
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
91
26
|
|
|
92
27
|
### Changed
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
## [0.1.1] - 2026-03-10
|
|
28
|
+
- `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.
|
|
29
|
+
- 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.
|
|
96
30
|
|
|
97
|
-
###
|
|
98
|
-
-
|
|
99
|
-
|
|
100
|
-
|
|
31
|
+
### Fixed
|
|
32
|
+
- `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).
|
|
33
|
+
- 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.
|
|
34
|
+
- `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`.
|
|
35
|
+
- `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.
|
|
36
|
+
- `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.
|
|
101
37
|
|
|
102
|
-
### Added
|
|
103
|
-
- Initial package scaffold for PI handbook integration support.
|
data/README.md
CHANGED
|
@@ -21,9 +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
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,
|
|
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).
|
|
27
27
|
|
|
28
28
|
**Keep provider updates constrained** - update projection assets inside this package instead of canonical definitions, keeping changes isolated from [ace-handbook](../ace-handbook).
|
|
29
29
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { clearInterval, setInterval } from "node:timers";
|
|
20
20
|
|
|
21
|
+
import { registerEyes } from "./wake/eyes-runtime.js";
|
|
21
22
|
import { WakeDispatcher } from "./wake/wake-dispatcher.js";
|
|
22
23
|
import { parseWakeCommand } from "./wake/wake-command-parser.js";
|
|
23
24
|
import { createLoopPort } from "./wake/loop-subscription.js";
|
|
@@ -47,6 +48,7 @@ export default function (pi, ports = {}) {
|
|
|
47
48
|
/** @type {{registry: WakeRegistry} | undefined} */
|
|
48
49
|
let runtime;
|
|
49
50
|
|
|
51
|
+
let eyes;
|
|
50
52
|
const getRuntime = () => runtime;
|
|
51
53
|
const bindRuntime = (ctx) => {
|
|
52
54
|
runtime?.registry.dispose();
|
|
@@ -57,6 +59,9 @@ export default function (pi, ports = {}) {
|
|
|
57
59
|
pi.registerCommand("loop", {
|
|
58
60
|
description: "Manage named timer loops that wake this agent (add | list | remove)",
|
|
59
61
|
handler: async (args, ctx) => {
|
|
62
|
+
if (eyes.active() && args.trim().startsWith("add")) {
|
|
63
|
+
ctx.ui?.notify?.("Legacy loops are retired while eyes owns observation", "error"); return;
|
|
64
|
+
}
|
|
60
65
|
await handleCommand(ctx, "loop", args, getRuntime, bindRuntime);
|
|
61
66
|
},
|
|
62
67
|
});
|
|
@@ -64,6 +69,9 @@ export default function (pi, ports = {}) {
|
|
|
64
69
|
pi.registerCommand("watch", {
|
|
65
70
|
description: "Manage named file watches that wake this agent (add | list | remove)",
|
|
66
71
|
handler: async (args, ctx) => {
|
|
72
|
+
if (eyes.active() && args.trim().startsWith("add")) {
|
|
73
|
+
ctx.ui?.notify?.("Legacy watches are retired while eyes owns observation", "error"); return;
|
|
74
|
+
}
|
|
67
75
|
await handleCommand(ctx, "watch", args, getRuntime, bindRuntime);
|
|
68
76
|
},
|
|
69
77
|
});
|
|
@@ -78,6 +86,16 @@ export default function (pi, ports = {}) {
|
|
|
78
86
|
bindRuntime(ctx);
|
|
79
87
|
});
|
|
80
88
|
|
|
89
|
+
eyes = registerEyes(pi, { ...ports.eyes,
|
|
90
|
+
retireLegacy: () => {
|
|
91
|
+
if (!runtime) return;
|
|
92
|
+
const list = runtime.registry.list();
|
|
93
|
+
for (const entry of list) {
|
|
94
|
+
if (entry.kind === "loop") runtime.registry.removeLoop(entry.name);
|
|
95
|
+
else runtime.registry.removeWatch(entry.name);
|
|
96
|
+
}
|
|
97
|
+
} });
|
|
98
|
+
|
|
81
99
|
// A run has begun: wakes dispatched concurrently during the idle-to-running
|
|
82
100
|
// transition can now enter the follow-up queue safely, so the registry
|
|
83
101
|
// drains anything it serialized.
|
|
@@ -169,6 +187,10 @@ function createRegistry(pi, ctx, ports) {
|
|
|
169
187
|
watchFactory: ports.watchFactory ?? nodeWatchFactory,
|
|
170
188
|
statFn: ports.statFn ?? nodeStatFn,
|
|
171
189
|
baseDir: ctx.cwd,
|
|
190
|
+
// The report-inbox watch covers every linked worktree's
|
|
191
|
+
// .ace-local/hitl by default (the same git worktree discovery
|
|
192
|
+
// ace-hitl's list uses); injectable for deterministic tests.
|
|
193
|
+
resolveTargets: ports.resolveTargets,
|
|
172
194
|
scheduleReconcile: ports.scheduleReconcile,
|
|
173
195
|
onDeactivate: () => registry?.refreshStatus(),
|
|
174
196
|
}),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { Eyes, EYES_ENTRY } from "./eyes.js";
|
|
6
|
+
|
|
7
|
+
const execute = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/** Existing native sync owns canonical import; HITL owns event projection.
|
|
10
|
+
* No Ruby parsing, private text, shell command strings or model work here. */
|
|
11
|
+
export async function observeHitl(cwd, config) {
|
|
12
|
+
const options = { cwd, timeout: config.timeoutMs, maxBuffer: 1024 * 1024, encoding: "utf8" };
|
|
13
|
+
for (const route of config.syncRoutes) {
|
|
14
|
+
const { stdout } = await execute("ace-hitl-hermes", ["sync", "--config", route], options);
|
|
15
|
+
const result = JSON.parse(stdout);
|
|
16
|
+
if (!Array.isArray(result.errors) || result.errors.length) throw new Error("Native synchronization incomplete");
|
|
17
|
+
}
|
|
18
|
+
const { stdout } = await execute("ace-overseer", ["observe", "--owner", config.recipient, "--scope", config.scope, "--limit", String(config.maxEvents)], options);
|
|
19
|
+
return JSON.parse(stdout);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerEyes(pi, { retireLegacy, observe = observeHitl, now = Date.now,
|
|
23
|
+
schedule = (fn, ms) => setInterval(fn, ms), cancel = clearInterval } = {}) {
|
|
24
|
+
let eyes, timer, attempt, compacting = false, running = false;
|
|
25
|
+
const stopTimer = () => { if (timer !== undefined) cancel(timer); timer = undefined; };
|
|
26
|
+
function arm() {
|
|
27
|
+
stopTimer();
|
|
28
|
+
if (eyes?.s.enabled && !eyes.s.retired && !eyes.s.breaker) {
|
|
29
|
+
timer = schedule(() => { void eyes.tick(); }, eyes.s.config.intervalSeconds * 1000);
|
|
30
|
+
timer?.unref?.();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const bind = (ctx) => {
|
|
34
|
+
stopTimer(); eyes?.dispose(); attempt = undefined; compacting = false; running = false;
|
|
35
|
+
const entries = ctx.sessionManager.getBranch();
|
|
36
|
+
const snapshot = [...entries].reverse().find((e) => e.type === "custom" && e.customType === EYES_ENTRY)?.data;
|
|
37
|
+
if (snapshot?.enabled || snapshot?.retired) retireLegacy?.();
|
|
38
|
+
const ownerCwd = path.resolve(ctx.cwd);
|
|
39
|
+
if (snapshot?.ownerCwd && snapshot.ownerCwd !== ownerCwd) {
|
|
40
|
+
eyes = undefined;
|
|
41
|
+
ctx.ui?.setStatus?.("ace-eyes", "eyes blocked: session belongs to another project cwd");
|
|
42
|
+
throw new Error("Eyes project owner mismatch; preserve this actor and use its original project session");
|
|
43
|
+
}
|
|
44
|
+
eyes = new Eyes({ snapshot, now,
|
|
45
|
+
save: (data) => pi.appendEntry(EYES_ENTRY, { ...data, ownerCwd }),
|
|
46
|
+
status: (state) => {
|
|
47
|
+
ctx.ui?.setStatus?.("ace-eyes", `eyes ${state.retired ? "retired" : state.breaker?.reason || (state.enabled ? "observing" : "off")} pending:${state.pending}`);
|
|
48
|
+
if (state.breaker || state.retired) stopTimer();
|
|
49
|
+
},
|
|
50
|
+
observe: (config) => observe(ctx.cwd, config),
|
|
51
|
+
canDeliver: () => !compacting && (ctx.isIdle() || running),
|
|
52
|
+
deliveryStatus: () => {
|
|
53
|
+
if (attempt?.accepted) return "accepted";
|
|
54
|
+
// sendUserMessage is a void wrapper. Busy/queued is uncertain, not
|
|
55
|
+
// acceptance or rejection. Idle without confirmation is uncertain:
|
|
56
|
+
// async input/auth preflight may still be running, so never blind-retry.
|
|
57
|
+
return compacting || !ctx.isIdle() || ctx.hasPendingMessages() ? "waiting" : "uncertain";
|
|
58
|
+
},
|
|
59
|
+
deliver: async (batch) => {
|
|
60
|
+
if (!ctx.model) return false;
|
|
61
|
+
const text = `[ace-eyes] Pending canonical HITL/deadline revisions: ${JSON.stringify(batch)}. ` +
|
|
62
|
+
"Read original events; adopt reports through ace-overseer process-reports and existing task/assignment gates. " +
|
|
63
|
+
"Reconcile uncertain effects; never repeat external actions on wake alone. After handling each exact revision, " +
|
|
64
|
+
"acknowledge with the ace_eyes tool (action ack, key, revision). Settlement alone is not acknowledgement.";
|
|
65
|
+
attempt = { text, accepted: false };
|
|
66
|
+
const result = pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
67
|
+
// Some hosts expose a promise, the standard ExtensionAPI exposes void.
|
|
68
|
+
// Neither completion nor void is proof of message consumption.
|
|
69
|
+
if (result?.then) await result;
|
|
70
|
+
return result !== false;
|
|
71
|
+
} });
|
|
72
|
+
if (eyes.s.enabled || eyes.s.retired) retireLegacy?.();
|
|
73
|
+
arm();
|
|
74
|
+
if (eyes.s.enabled) void eyes.tick();
|
|
75
|
+
};
|
|
76
|
+
pi.on("session_start", async (_event, ctx) => bind(ctx));
|
|
77
|
+
pi.on("session_tree", async (_event, ctx) => bind(ctx));
|
|
78
|
+
pi.on("agent_start", async () => { running = true; });
|
|
79
|
+
pi.on("agent_settled", async () => { running = false; });
|
|
80
|
+
pi.on("message_start", async (event) => {
|
|
81
|
+
if (event.message?.role !== "user" || !attempt) return;
|
|
82
|
+
const text = typeof event.message.content === "string" ? event.message.content :
|
|
83
|
+
(event.message.content || []).filter(part => part.type === "text").map(part => part.text).join("");
|
|
84
|
+
if (text === attempt.text) attempt.accepted = true;
|
|
85
|
+
});
|
|
86
|
+
pi.on("session_before_compact", async (event) => { if (event.reason === "manual") compacting = true; });
|
|
87
|
+
pi.on("session_compact", async () => { compacting = false; eyes?.persist(); });
|
|
88
|
+
pi.on("session_compact_failed", async () => { compacting = false; });
|
|
89
|
+
pi.on("session_shutdown", async () => { stopTimer(); eyes?.dispose(); });
|
|
90
|
+
pi.on("message_end", async (event) => {
|
|
91
|
+
const message = event.message;
|
|
92
|
+
if (message?.role === "assistant" && message.stopReason === "error") eyes?.quota(message.errorMessage);
|
|
93
|
+
});
|
|
94
|
+
pi.registerTool?.({
|
|
95
|
+
name: "ace_eyes", label: "Eyes acknowledgement", description: "Read pending semantic events or acknowledge an exact handled revision. Does not accept reports, authorize effects, start actors or reset quota. Use existing report/assignment owners first. Deadlines require an operation identity and revision; cancel when no longer active.",
|
|
96
|
+
parameters: { type: "object", properties: {
|
|
97
|
+
action: { type: "string", enum: ["status", "ack", "deadline", "cancel"] },
|
|
98
|
+
key: { type: "string" }, revision: { type: "string" }, at: { type: "integer" }
|
|
99
|
+
}, required: ["action"], additionalProperties: false },
|
|
100
|
+
execute: async (_id, args, _signal, _onUpdate, ctx) => {
|
|
101
|
+
if (!eyes) {
|
|
102
|
+
if (!ctx) throw new Error("Eyes session not initialized");
|
|
103
|
+
bind(ctx);
|
|
104
|
+
}
|
|
105
|
+
if (args.action === "ack") eyes.ack(args.key, args.revision);
|
|
106
|
+
else if (args.action === "deadline") eyes.deadline(args.key, args.at, args.revision);
|
|
107
|
+
else if (args.action === "cancel") eyes.cancelDeadline(args.key);
|
|
108
|
+
else if (args.action !== "status") throw new Error("Unknown eyes action");
|
|
109
|
+
// Do not send a second wake from inside the running model turn. The next
|
|
110
|
+
// observation delivers remaining coalesced revisions through the same owner.
|
|
111
|
+
const details = { ...eyes.summary(), pending: eyes.s.pending, deadlines: eyes.s.deadlines };
|
|
112
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
pi.registerCommand("eyes", { description: "Event-driven HITL observation: start CONFIG | status | ack KEY REV | deadline NAME REVISION EPOCH_MS | cancel NAME | resume quota-verified | retire",
|
|
116
|
+
handler: async (args, ctx) => {
|
|
117
|
+
try {
|
|
118
|
+
if (!eyes) bind(ctx);
|
|
119
|
+
const [command, a, b, ...rest] = args.trim().split(/\s+/);
|
|
120
|
+
if (rest.length && command !== "deadline") throw new Error("Too many arguments");
|
|
121
|
+
switch (command) {
|
|
122
|
+
case "start": {
|
|
123
|
+
if (!a || b) throw new Error("Use eyes start CONFIG_JSON");
|
|
124
|
+
const config = JSON.parse(fs.readFileSync(path.resolve(ctx.cwd, a), "utf8"));
|
|
125
|
+
eyes.start(config); retireLegacy?.(); arm(); await eyes.tick(); break;
|
|
126
|
+
}
|
|
127
|
+
case "status": ctx.ui?.notify?.(JSON.stringify({ ...eyes.summary(), pending: eyes.s.pending, deadlines: eyes.s.deadlines }), "info"); break;
|
|
128
|
+
case "ack": eyes.ack(a, b); await eyes.tick(); break;
|
|
129
|
+
case "deadline":
|
|
130
|
+
if (rest.length !== 1) throw new Error("Deadline requires operation identity, revision and epoch milliseconds");
|
|
131
|
+
eyes.deadline(a, Number(rest[0]), b); await eyes.tick(); break;
|
|
132
|
+
case "cancel": eyes.cancelDeadline(a); break;
|
|
133
|
+
case "resume":
|
|
134
|
+
if (a !== "quota-verified" || b) throw new Error("Explicit owner verification required: eyes resume quota-verified");
|
|
135
|
+
await eyes.resume(); arm(); break;
|
|
136
|
+
case "retire": eyes.retire(); retireLegacy?.(); stopTimer(); break;
|
|
137
|
+
default: throw new Error("Unknown eyes command; use start, status, ack, deadline, cancel, resume or retire");
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// Never echo process stdout/stderr: a failed native sync may carry private data.
|
|
141
|
+
ctx.ui?.notify?.(`Eyes operation refused (${error.name}); check configuration or /eyes status`, "error");
|
|
142
|
+
}
|
|
143
|
+
} });
|
|
144
|
+
return { active: () => Boolean(eyes?.s.enabled || eyes?.s.retired) };
|
|
145
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
4
|
+
export const EYES_ENTRY = "ace-wake-eyes";
|
|
5
|
+
export const defaults = { intervalSeconds: 30, maxEvents: 500, maxObservationsPerHour: 120, maxWakesPerHour: 20,
|
|
6
|
+
timeoutMs: 10000, maxErrors: 3, maxPending: 2000, scope: "current", recipient: "self", syncRoutes: [] };
|
|
7
|
+
|
|
8
|
+
export function validateConfig(input) {
|
|
9
|
+
if (!input || typeof input !== "object" || Array.isArray(input) ||
|
|
10
|
+
Object.keys(input).some(key => !(key in defaults))) throw new Error("Invalid eyes configuration");
|
|
11
|
+
const config = { ...defaults, ...input };
|
|
12
|
+
for (const [key, min, max] of [["intervalSeconds", 1, 3600], ["maxEvents", 1, 2000],
|
|
13
|
+
["maxObservationsPerHour", 1, 3600], ["maxWakesPerHour", 1, 1000], ["timeoutMs", 100, 30000], ["maxErrors", 1, 10], ["maxPending", 1, 10000]]) {
|
|
14
|
+
if (!Number.isInteger(config[key]) || config[key] < min || config[key] > max) throw new Error(`Invalid ${key}`);
|
|
15
|
+
}
|
|
16
|
+
if (typeof config.recipient !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(config.recipient)) throw new Error("Invalid observer recipient");
|
|
17
|
+
if (!["current", "all"].includes(config.scope) || !Array.isArray(config.syncRoutes) || config.syncRoutes.length > 10 ||
|
|
18
|
+
config.syncRoutes.some((p) => typeof p !== "string" || !p.startsWith("/"))) throw new Error("Invalid eyes sources");
|
|
19
|
+
return config;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Observation/delivery bookkeeping in the existing Pi session, not a work
|
|
23
|
+
* queue. It never starts tasks or accepts reports on behalf of their owner. */
|
|
24
|
+
export class Eyes {
|
|
25
|
+
constructor({ snapshot, save, observe, deliver, now = Date.now, status = () => {}, deliveryStatus = () => "accepted", canDeliver = () => true }) {
|
|
26
|
+
this.s = structuredClone(snapshot || { enabled: false, retired: false, config: defaults,
|
|
27
|
+
pending: {}, acknowledged: {}, deadlines: {}, breaker: null, errors: 0, window: null });
|
|
28
|
+
this.s.config = validateConfig(this.s.config);
|
|
29
|
+
for (const key of ["pending", "acknowledged", "deadlines"])
|
|
30
|
+
if (!this.s[key] || typeof this.s[key] !== "object" || Array.isArray(this.s[key])) throw new Error("Invalid persisted eyes state");
|
|
31
|
+
if (typeof this.s.enabled !== "boolean" || typeof this.s.retired !== "boolean" ||
|
|
32
|
+
!Number.isInteger(this.s.errors) || this.s.errors < 0) throw new Error("Invalid persisted eyes state");
|
|
33
|
+
this.save = save; this.observe = observe; this.deliver = deliver; this.now = now; this.status = status;
|
|
34
|
+
this.deliveryStatus = deliveryStatus; this.canDeliver = canDeliver;
|
|
35
|
+
this.busy = false; this.disposed = false;
|
|
36
|
+
// A process restart does not imply receipt acknowledgement. Reconcile the
|
|
37
|
+
// same pending revisions; business-effect idempotence remains with .5.4.
|
|
38
|
+
this.inflight = false;
|
|
39
|
+
this.sent = new Map();
|
|
40
|
+
this.generation = 0;
|
|
41
|
+
}
|
|
42
|
+
persist() { this.save(structuredClone(this.s)); this.status(this.summary()); }
|
|
43
|
+
summary() { return { enabled: this.s.enabled, retired: this.s.retired, pending: Object.keys(this.s.pending).length,
|
|
44
|
+
breaker: this.s.breaker, errors: this.s.errors, observations: this.s.window?.count || 0, wakes: this.s.window?.wakes || 0 }; }
|
|
45
|
+
start(config) {
|
|
46
|
+
if (this.s.retired) throw new Error("Retired actor cannot restart its eyes; hand off pending to its owner");
|
|
47
|
+
if (this.s.breaker) throw new Error("Resolve breaker with explicit resume first");
|
|
48
|
+
this.s.config = validateConfig(config); this.s.enabled = true; this.persist();
|
|
49
|
+
}
|
|
50
|
+
block(reason) {
|
|
51
|
+
if (!this.s.breaker) { this.s.breaker = { reason, at: this.now() }; this.persist(); }
|
|
52
|
+
}
|
|
53
|
+
async resume() {
|
|
54
|
+
if (this.s.retired) throw new Error("Retired actor cannot resume");
|
|
55
|
+
// Explicit operator action; check source/transport readiness without a model
|
|
56
|
+
// request. Quota availability must have been verified by the owner.
|
|
57
|
+
const result = await this.observe(this.s.config);
|
|
58
|
+
this.validate(result);
|
|
59
|
+
this.s.breaker = null; this.s.errors = 0; this.s.window = null; this.inflight = false;
|
|
60
|
+
this.s.enabled = true; this.persist();
|
|
61
|
+
await this.tick();
|
|
62
|
+
}
|
|
63
|
+
retire() { this.generation++; this.s.enabled = false; this.s.retired = true; this.persist(); }
|
|
64
|
+
dispose() { this.disposed = true; this.generation++; }
|
|
65
|
+
quota(message) {
|
|
66
|
+
if (/quota|insufficient.?(?:credit|balance)|billing.?limit|budget.?exceed|usage.?limit|rate.?limit|\b429\b/i.test(String(message))) this.block("quota-or-budget");
|
|
67
|
+
}
|
|
68
|
+
deadline(name, at, operationRevision) {
|
|
69
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name) || !Number.isSafeInteger(at) || at < 0 || typeof operationRevision !== "string" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(operationRevision)) throw new Error("Invalid deadline");
|
|
70
|
+
const key = `deadline:${name}`;
|
|
71
|
+
delete this.s.pending[key];
|
|
72
|
+
this.sent.delete(key); this.inflight = this.sent.size > 0;
|
|
73
|
+
this.s.deadlines[name] = { at, operationRevision }; this.persist();
|
|
74
|
+
}
|
|
75
|
+
cancelDeadline(name) {
|
|
76
|
+
delete this.s.deadlines[name]; delete this.s.pending[`deadline:${name}`];
|
|
77
|
+
this.sent.delete(`deadline:${name}`); this.inflight = this.sent.size > 0; this.persist();
|
|
78
|
+
}
|
|
79
|
+
ack(key, revision) {
|
|
80
|
+
if (this.s.pending[key]?.revision !== revision && this.sent.get(key) !== revision)
|
|
81
|
+
throw new Error("Ack does not match an observed pending or delivered revision");
|
|
82
|
+
this.s.acknowledged[key] = revision;
|
|
83
|
+
if (this.s.pending[key]?.revision === revision) delete this.s.pending[key];
|
|
84
|
+
this.sent.delete(key);
|
|
85
|
+
this.inflight = this.sent.size > 0; this.persist();
|
|
86
|
+
}
|
|
87
|
+
validate(result) {
|
|
88
|
+
if (result?.schema !== "ace.hitl.observation/v1" || !Array.isArray(result.events) || result.events.length > this.s.config.maxEvents)
|
|
89
|
+
throw new Error("Invalid or over-budget HITL observation");
|
|
90
|
+
if (result.settled !== undefined && !Array.isArray(result.settled)) throw new Error("Invalid settlement projection");
|
|
91
|
+
const rows = [...result.events, ...(result.settled || [])];
|
|
92
|
+
if (rows.length > this.s.config.maxEvents) throw new Error("Over-budget projection");
|
|
93
|
+
const keys = new Set();
|
|
94
|
+
for (const event of rows) {
|
|
95
|
+
if (!/^[a-f0-9]{64}$/.test(event.key) || !/^[a-f0-9]{64}$/.test(event.revision) ||
|
|
96
|
+
typeof event.id !== "string" || !/^[a-z0-9_-]{1,64}$/i.test(event.id) || keys.has(event.key)) throw new Error("Invalid event identity");
|
|
97
|
+
keys.add(event.key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async tick() {
|
|
101
|
+
if (this.busy || this.disposed || !this.s.enabled || this.s.retired || this.s.breaker) return;
|
|
102
|
+
const now = this.now();
|
|
103
|
+
if (!this.s.window || now - this.s.window.start >= 3600000) this.s.window = { start: now, count: 0, wakes: 0 };
|
|
104
|
+
if (this.s.window.count >= this.s.config.maxObservationsPerHour) { this.block("observation-budget"); return; }
|
|
105
|
+
this.s.window.count++; this.persist(); // Budget survives restart too.
|
|
106
|
+
this.busy = true;
|
|
107
|
+
const generation = this.generation;
|
|
108
|
+
try {
|
|
109
|
+
const result = await this.observe(this.s.config);
|
|
110
|
+
if (generation !== this.generation || this.disposed) return;
|
|
111
|
+
this.validate(result);
|
|
112
|
+
for (const e of result.settled || []) {
|
|
113
|
+
this.s.acknowledged[e.key] = e.revision;
|
|
114
|
+
if (this.s.pending[e.key]?.revision === e.revision) delete this.s.pending[e.key];
|
|
115
|
+
this.sent.delete(e.key);
|
|
116
|
+
}
|
|
117
|
+
if (this.inflight && this.sent.size === 0) this.inflight = false;
|
|
118
|
+
for (const e of result.events) {
|
|
119
|
+
if (this.s.acknowledged[e.key] !== e.revision) this.s.pending[e.key] = { id: e.id, revision: e.revision };
|
|
120
|
+
}
|
|
121
|
+
for (const [name, deadline] of Object.entries(this.s.deadlines)) {
|
|
122
|
+
const { at, operationRevision } = deadline;
|
|
123
|
+
const key = `deadline:${name}`, revision = hash([name, at, operationRevision]);
|
|
124
|
+
if (now >= at && this.s.acknowledged[key] !== revision) this.s.pending[key] = { id: name, revision };
|
|
125
|
+
}
|
|
126
|
+
if (Object.keys(this.s.pending).length + Object.keys(this.s.acknowledged).length > this.s.config.maxPending) {
|
|
127
|
+
this.block("retention-budget"); return;
|
|
128
|
+
}
|
|
129
|
+
this.persist();
|
|
130
|
+
if (this.inflight && this.deliveryStatus() === "uncertain") {
|
|
131
|
+
this.block("notification-unconfirmed"); return;
|
|
132
|
+
}
|
|
133
|
+
if (!this.inflight && !this.s.breaker && Object.keys(this.s.pending).length && this.canDeliver()) {
|
|
134
|
+
// Retain identities before attempting delivery. A rejected send leaves
|
|
135
|
+
// pending intact; acceptance by Pi is not application acknowledgement.
|
|
136
|
+
if ((this.s.window.wakes || 0) >= this.s.config.maxWakesPerHour) { this.block("wake-budget"); return; }
|
|
137
|
+
this.s.window.wakes = (this.s.window.wakes || 0) + 1;
|
|
138
|
+
this.persist(); // Includes uncertain notification attempts, never token billing.
|
|
139
|
+
const batch = Object.entries(this.s.pending).slice(0, 20);
|
|
140
|
+
this.inflight = Boolean(await this.deliver(batch));
|
|
141
|
+
if (this.inflight) this.sent = new Map(batch.map(([key, value]) => [key, value.revision]));
|
|
142
|
+
if (!this.inflight) throw new Error("wake delivery refused");
|
|
143
|
+
}
|
|
144
|
+
if (this.s.errors && (!this.inflight || this.deliveryStatus() === "accepted")) { this.s.errors = 0; this.persist(); }
|
|
145
|
+
} catch {
|
|
146
|
+
this.s.errors++; this.persist();
|
|
147
|
+
if (this.s.errors >= this.s.config.maxErrors) this.block("observation-or-delivery-failure");
|
|
148
|
+
} finally { this.busy = false; }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -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
|
+
}
|