@maolon/pi-watcher 0.0.0-stage → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +255 -2
  4. package/dist/cli.d.ts +8 -0
  5. package/dist/cli.js +369 -0
  6. package/dist/contracts/interfaces.d.ts +262 -0
  7. package/dist/contracts/interfaces.js +1 -0
  8. package/dist/contracts/policy-defaults.json +61 -0
  9. package/dist/contracts/relay-next.interfaces.d.ts +160 -0
  10. package/dist/contracts/relay-next.interfaces.js +1 -0
  11. package/dist/engine/cards.d.ts +68 -0
  12. package/dist/engine/cards.js +76 -0
  13. package/dist/engine/engine.d.ts +178 -0
  14. package/dist/engine/engine.js +1162 -0
  15. package/dist/engine/hard-rules.d.ts +53 -0
  16. package/dist/engine/hard-rules.js +96 -0
  17. package/dist/engine/semantic.d.ts +49 -0
  18. package/dist/engine/semantic.js +125 -0
  19. package/dist/engine/service.d.ts +62 -0
  20. package/dist/engine/service.js +587 -0
  21. package/dist/engine/tool-actions.d.ts +35 -0
  22. package/dist/engine/tool-actions.js +348 -0
  23. package/dist/engine/widget.d.ts +29 -0
  24. package/dist/engine/widget.js +52 -0
  25. package/dist/ipc/client.d.ts +26 -0
  26. package/dist/ipc/client.js +106 -0
  27. package/dist/ipc/server.d.ts +78 -0
  28. package/dist/ipc/server.js +105 -0
  29. package/dist/jev/client.d.ts +38 -0
  30. package/dist/jev/client.js +239 -0
  31. package/dist/jev/consent.d.ts +12 -0
  32. package/dist/jev/consent.js +37 -0
  33. package/dist/jev/index.d.ts +9 -0
  34. package/dist/jev/index.js +9 -0
  35. package/dist/jev/mock.d.ts +30 -0
  36. package/dist/jev/mock.js +77 -0
  37. package/dist/jev/pi-registry.d.ts +66 -0
  38. package/dist/jev/pi-registry.js +127 -0
  39. package/dist/jev/questions.d.ts +15 -0
  40. package/dist/jev/questions.js +76 -0
  41. package/dist/jev/sanitizer.d.ts +10 -0
  42. package/dist/jev/sanitizer.js +59 -0
  43. package/dist/jev/types.d.ts +78 -0
  44. package/dist/jev/types.js +54 -0
  45. package/dist/pi-extension.d.ts +123 -0
  46. package/dist/pi-extension.js +687 -0
  47. package/dist/relay/managed.d.ts +120 -0
  48. package/dist/relay/managed.js +482 -0
  49. package/dist/relay/negotiate.d.ts +39 -0
  50. package/dist/relay/negotiate.js +112 -0
  51. package/dist/runtime.d.ts +74 -0
  52. package/dist/runtime.js +246 -0
  53. package/dist/source/agent-check/adapter.d.ts +57 -0
  54. package/dist/source/agent-check/adapter.js +224 -0
  55. package/dist/source/agent-file/adapter.d.ts +57 -0
  56. package/dist/source/agent-file/adapter.js +217 -0
  57. package/dist/source/task-status-v1/adapter.d.ts +36 -0
  58. package/dist/source/task-status-v1/adapter.js +263 -0
  59. package/dist/source/task-status-v1/producer.d.ts +81 -0
  60. package/dist/source/task-status-v1/producer.js +127 -0
  61. package/dist/storage/lock.d.ts +14 -0
  62. package/dist/storage/lock.js +66 -0
  63. package/dist/storage/schema.sql +166 -0
  64. package/dist/storage/store.d.ts +352 -0
  65. package/dist/storage/store.js +555 -0
  66. package/dist/util/clock.d.ts +23 -0
  67. package/dist/util/clock.js +34 -0
  68. package/dist/util/ids.d.ts +8 -0
  69. package/dist/util/ids.js +29 -0
  70. package/dist/util/result.d.ts +28 -0
  71. package/dist/util/result.js +54 -0
  72. package/package.json +101 -4
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Pi extension entry point (design 08 / 11 + decision 2026-09-21 per-session root).
3
+ * - The factory only registers tools/commands; no import side effects
4
+ * - per-session root: each session owns <root>/sessions/<sessionId>/ (store + engine + sweep loop)
5
+ * and is the only process on its own root. Version skew is structurally impossible: after a plugin upgrade,
6
+ * old and new sessions each run on their own root without interfering (2026-09-21 measurement: with a shared root, primary/attached mode
7
+ * split the action surface at random by the primary's code version, since npm updates disk code but old sessions still hold old code in memory).
8
+ * Under strict session isolation attention is only sent to the owner session anyway, so all that sharing left was one loop
9
+ * and a global panel, which is not worth this failure mode. Cost (honestly recorded): N sessions run N loops (cheap);
10
+ * the /watcher panel is session-scoped; the Jev budget is per session.
11
+ * - The IPC/primary/attached machinery stays in src/ipc/ (seed of the V4 standalone service: atomic standalone-process
12
+ * upgrade + protocol version negotiation); the embedded path no longer uses it.
13
+ * - session_shutdown: abort session-scoped I/O, clear timers, close the runtime (release this session's lock)
14
+ * - The only LLM tool: `watcher` (watch-file/watch-check/register/list/inspect/check/ack/pause/close/update)
15
+ * source/profile/owner capability are injected from the trusted context; the model cannot specify sessionId
16
+ * - ack requires relay delivery (deliveryRef); when relay is not negotiated it returns NO_DELIVERY and never fabricates a receipt
17
+ */
18
+ import { Type } from 'typebox';
19
+ import * as fs from 'node:fs';
20
+ import * as os from 'node:os';
21
+ import * as pathMod from 'node:path';
22
+ import { startRuntime } from './runtime.js';
23
+ import { deriveSourceId } from './relay/managed.js';
24
+ import { toServiceResultAsync } from './util/result.js';
25
+ import { renderWatcherWidget } from './engine/widget.js';
26
+ import { runToolAction } from './engine/tool-actions.js';
27
+ import { DEFAULT_ATTENTION_TTL_MS } from './engine/engine.js';
28
+ import { PiRegistryJudge } from './jev/pi-registry.js';
29
+ import { jevConsentState, setStoredJevConsent, consentFilePath } from './jev/consent.js';
30
+ const errorText = (e) => (e instanceof Error ? e.message : String(e));
31
+ const isRootLockHeld = (e) => typeof e === 'object' && e !== null && e.code === 'ROOT_LOCK_HELD';
32
+ /** tool_result event content to plain text (handles both the array-of-blocks and string forms). */
33
+ const contentText = (content) => {
34
+ if (typeof content === 'string')
35
+ return content;
36
+ if (Array.isArray(content)) {
37
+ return content
38
+ .map((p) => {
39
+ if (typeof p === 'string')
40
+ return p;
41
+ if (p && typeof p === 'object' && typeof p.text === 'string')
42
+ return p.text;
43
+ return '';
44
+ })
45
+ .join('\n');
46
+ }
47
+ return '';
48
+ };
49
+ /** Primary-side widget snapshot (supports per-session isolation; pure rendering). */
50
+ const widgetSnapshotOf = (rt, ownerSession) => {
51
+ const snap = rt.store.transaction(tx => {
52
+ const rawWatches = ownerSession
53
+ ? tx.listWatches(ownerSession, undefined, 200)
54
+ : tx.listAllWatches(200);
55
+ return {
56
+ watches: rawWatches.map(w => ({
57
+ lifecycle: w.lifecycle,
58
+ health: w.health,
59
+ deadlineAt: w.spec.mission.deadlineAt ?? null
60
+ })),
61
+ unresolvedEpisodes: tx.countUnresolvedEpisodes(ownerSession),
62
+ pendingAttentions: tx.countPendingAttentions(Date.now(), ownerSession)
63
+ };
64
+ });
65
+ return renderWatcherWidget({ ...snap, now: Date.now() });
66
+ };
67
+ // A real Pi ExtensionContext exposes session identity through sessionManager. Measured on pi 1.0.0: there is no direct sessionId
68
+ // field (all ExtensionContext fields in extensions/types.d.ts checked); the first branch is a defensive forward probe;
69
+ // identity derivation is handled by sessionManager.getSessionId() (the ReadonlySessionManager Pick set includes this method).
70
+ // Identity is derived from the trusted context; model parameters cannot specify it (I11).
71
+ const sessionIdOf = (ctx) => ctx.sessionId
72
+ ?? ctx.sessionManager?.getSessionId?.()
73
+ ?? 'pi-unknown-session';
74
+ /** sessionId -> filesystem-safe segment (per-session root directory name). */
75
+ const sessionDirOf = (sid) => sid.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 64) || 'session';
76
+ /**
77
+ * Session backend: local runtime, no IPC (per-session root, decision 2026-09-21).
78
+ * This session is the only process on its own root, so the single-writer invariant holds naturally; no primary/attached,
79
+ * no failover; version skew (an old primary serving an old action surface) is structurally impossible.
80
+ */
81
+ class SessionBackend {
82
+ rt;
83
+ refreshWidget;
84
+ notifyUser;
85
+ sessionId;
86
+ closed = false;
87
+ constructor(rt, refreshWidget, notifyUser, sessionId) {
88
+ this.rt = rt;
89
+ this.refreshWidget = refreshWidget;
90
+ this.notifyUser = notifyUser;
91
+ this.sessionId = sessionId;
92
+ }
93
+ /** Engine attention hook: toast to this session (under a per-session root the owner is always itself; the filter is kept defensively). */
94
+ handleAttention(notice) {
95
+ const tail = notice.transport === 'local-display'
96
+ ? ' (local-display: call watcher inspect; no auto wake)'
97
+ : notice.transport === 'relay-failed'
98
+ ? ` (relay wake FAILED: ${notice.relayError ?? 'unknown error'} — call watcher inspect; no auto wake)`
99
+ : '';
100
+ const text = `pi-watcher attention [${notice.reasonCode}] watch ${notice.watchId}: ${notice.summary}` + tail;
101
+ if (!notice.ownerSession || notice.ownerSession === this.sessionId) {
102
+ try {
103
+ this.notifyUser(text);
104
+ }
105
+ catch { /* display failure does not block */ }
106
+ }
107
+ }
108
+ widgetLines() {
109
+ return widgetSnapshotOf(this.rt, this.sessionId);
110
+ }
111
+ async healRelay() {
112
+ return this.rt.ensureRelayReady();
113
+ }
114
+ async toolCall(params, actor) {
115
+ return JSON.stringify(await toServiceResultAsync(params.requestId, () => runToolAction(this.rt, params, actor)));
116
+ }
117
+ async panel(actor) {
118
+ return this.rt.service.panel(actor);
119
+ }
120
+ async localAck(req, actor) {
121
+ return JSON.stringify(await toServiceResultAsync(req.requestId, () => this.rt.service.ackEpisode(req.requestId, req.episodeId, req.action, req.note, req.until, actor)));
122
+ }
123
+ requestWidgetRefresh() {
124
+ this.refreshWidget();
125
+ }
126
+ close() {
127
+ if (this.closed)
128
+ return;
129
+ this.closed = true;
130
+ this.rt.close();
131
+ }
132
+ }
133
+ export default function watcherExtension(pi, options = {}) {
134
+ let backend = null;
135
+ // Latest Pi model registry seen on any context: Jev resolves credentials from it lazily.
136
+ let latestRegistry;
137
+ const noteRegistry = (ctx) => {
138
+ if (ctx?.modelRegistry && typeof ctx.modelRegistry.classify === 'function')
139
+ latestRegistry = ctx.modelRegistry;
140
+ };
141
+ let backendError = null;
142
+ let sessionClosed = false;
143
+ let widgetCtx = null;
144
+ const resolveOptions = (ctx) => {
145
+ // per-session root (decision 2026-09-21): each session owns sessions/<sessionId>/ exclusively,
146
+ // and is the only process on it; no cross-process attach, version skew is structurally impossible.
147
+ // options.rootDir now means the parent root (default <cwd>/.pi-watcher).
148
+ const parent = options.rootDir ?? `${ctx.cwd}/.pi-watcher`;
149
+ const rootDir = pathMod.join(parent, 'sessions', sessionDirOf(sessionIdOf(ctx)));
150
+ const sourceRoots = options.sourceRoots ?? new Map([
151
+ ['executor-local', `${ctx.cwd}/.pi-watcher-sources`]
152
+ ]);
153
+ return { ...options, rootDir, sourceRoots };
154
+ };
155
+ // Status bar widget (design 11 §33 / 08 §35): ordinary progress goes to the widget, not into the LLM context.
156
+ // Rendered immediately after each sweep (each session's own runtime).
157
+ const refreshWidget = () => {
158
+ const ctx = widgetCtx;
159
+ if (!backend || !ctx?.ui?.setWidget)
160
+ return;
161
+ try {
162
+ ctx.ui.setWidget('watcher', backend.widgetLines());
163
+ }
164
+ catch {
165
+ /* display failure does not affect the engine */
166
+ }
167
+ };
168
+ /**
169
+ * relay auto-enable: on when pi-relay is installed (default home exists), otherwise stay local-display.
170
+ * Without relay the watcher can only display, not wake, so the agent is forced into babysitting polling.
171
+ * PI_WATCHER_RELAY=0 disables it explicitly; options.relay takes precedence.
172
+ */
173
+ const resolveRelay = () => {
174
+ if (options.relay !== undefined)
175
+ return options.relay;
176
+ if (process.env?.PI_WATCHER_RELAY === '0')
177
+ return false;
178
+ const relayHome = process.env?.PI_RELAY_HOME ?? pathMod.join(os.homedir(), '.pi', 'relay');
179
+ return fs.existsSync(relayHome) ? { relayHome } : false;
180
+ };
181
+ const actorOf = (ctx) => ({
182
+ actorId: `pi-session:${sessionIdOf(ctx)}`,
183
+ owner: {
184
+ sessionId: sessionIdOf(ctx),
185
+ originAnchor: ctx.sessionManager?.getSessionFile?.() ?? null,
186
+ bindingEpoch: 1,
187
+ profileId: 'default-local'
188
+ },
189
+ profileRevision: 1,
190
+ permitted: new Set(['watcher.register', 'watcher.list', 'watcher.inspect', 'watcher.check', 'watcher.control'])
191
+ });
192
+ /**
193
+ * Lazily ensure the backend (single-flight serialization): start this session's own runtime (per-session root).
194
+ * No lock contention, no attach, no failover; this session is the only process on its own root.
195
+ * ROOT_LOCK_HELD only appears when another process holds the same sessionId (e.g. the same session opened twice),
196
+ * in which case we report it honestly and keep retrying lazily.
197
+ */
198
+ const runEnsure = async (ctx) => {
199
+ noteRegistry(ctx);
200
+ if (backend || sessionClosed)
201
+ return;
202
+ const opts = resolveOptions(ctx);
203
+ try {
204
+ const primarySid = sessionIdOf(ctx);
205
+ let attentionSink = null;
206
+ const rt = await startRuntime({
207
+ rootDir: opts.rootDir,
208
+ sourceRoots: opts.sourceRoots ?? new Map(),
209
+ allowedSourceIds: opts.allowedSourceIds,
210
+ pollTickMs: opts.pollTickMs,
211
+ relay: resolveRelay(),
212
+ piRegistry: () => latestRegistry,
213
+ onAttention: n => { try {
214
+ attentionSink?.(n);
215
+ }
216
+ catch { /* display failure does not block the engine */ } },
217
+ onJudgment: j => {
218
+ if (j.ownerSession && primarySid && j.ownerSession !== primarySid)
219
+ return;
220
+ try {
221
+ const top = j.candidates.length
222
+ ? j.candidates.map(c => `${c.reason} p=${c.probability.toFixed(2)}${c.note ? ` (${c.note})` : ''}`).join('; ')
223
+ : 'no action needed';
224
+ ctx.ui?.notify?.(`pi-watcher: jev reviewed (${j.mode}) "${j.objective}" — ${top}`, 'info');
225
+ }
226
+ catch { /* display failure does not block the engine */ }
227
+ }
228
+ });
229
+ const be = new SessionBackend(rt, refreshWidget, text => { ctx.ui?.notify?.(text, 'warning'); }, primarySid);
230
+ attentionSink = n => be.handleAttention(n);
231
+ if (sessionClosed) {
232
+ be.close();
233
+ return;
234
+ }
235
+ backend = be;
236
+ backendError = null;
237
+ rt.startLoop(ctx.signal, refreshWidget);
238
+ refreshWidget();
239
+ }
240
+ catch (e) {
241
+ if (!sessionClosed)
242
+ backendError = e;
243
+ }
244
+ };
245
+ /** Serialize ensure: a chained promise, no shared mutable in-flight variable. */
246
+ let ensureChain = Promise.resolve();
247
+ const ensureBackend = (ctx) => {
248
+ widgetCtx ??= ctx;
249
+ const next = ensureChain.then(() => runEnsure(ctx));
250
+ ensureChain = next;
251
+ return next;
252
+ };
253
+ pi.on('session_start', async (_event, ctx) => {
254
+ sessionClosed = false;
255
+ await ensureBackend(ctx);
256
+ if (!backend && backendError) {
257
+ const msg = isRootLockHeld(backendError)
258
+ ? `pi-watcher: this session's watcher root is locked by another process with the same session id (duplicate session process? ${errorText(backendError)}); every watcher call retries`
259
+ : `pi-watcher: embedded startup failed (${errorText(backendError)}); will retry on next watcher call`;
260
+ ctx.ui?.notify?.(msg, 'warning');
261
+ }
262
+ // Last link of the autonomous loop: relay not negotiated but owner already ran relay-setup (invite present) -> request relay binding for this session
263
+ void autoBindRelay(ctx).catch(() => { });
264
+ });
265
+ /** relay auto-bind: owner pre-authorized (relay-setup produced an invite) but the session is unbound -> request this session's relay binding via pi.events. */
266
+ const autoBindPending = new Map();
267
+ let autoBindResultOff;
268
+ const ensureAutoBindListener = () => {
269
+ if (autoBindResultOff || !pi.events)
270
+ return;
271
+ const off = pi.events.on('pi-relay:bind-result', (data) => {
272
+ const r = data;
273
+ if (!r || typeof r.requestId !== 'string')
274
+ return;
275
+ const resolve = autoBindPending.get(r.requestId);
276
+ if (resolve) {
277
+ autoBindPending.delete(r.requestId);
278
+ resolve(r);
279
+ }
280
+ });
281
+ autoBindResultOff = typeof off === 'function' ? off : undefined;
282
+ };
283
+ /** Send one bind-request and wait for the reply (timeout -> null). kind selects the v2 local / v1 invite payload. */
284
+ const emitBindRequest = (bus, requestId, kind, payload) => new Promise(resolve => {
285
+ autoBindPending.set(requestId, resolve);
286
+ const timer = setTimeout(() => {
287
+ if (autoBindPending.delete(requestId))
288
+ resolve(null);
289
+ }, 3000);
290
+ timer.unref?.();
291
+ try {
292
+ const req = kind === 'local'
293
+ ? { kind: 'local', requestId, source: 'pi-watcher', sourceId: payload.sourceId, channelId: payload.channelId, realm: payload.realm, projectRoot: payload.projectRoot }
294
+ : { kind: 'invite', requestId, source: 'pi-watcher', invitePath: payload.invitePath, projectRoot: payload.projectRoot };
295
+ bus.emit('pi-relay:bind-request', req);
296
+ }
297
+ catch {
298
+ clearTimeout(timer);
299
+ if (autoBindPending.delete(requestId))
300
+ resolve(null);
301
+ }
302
+ });
303
+ const autoBindRelay = async (ctx) => {
304
+ const bus = pi.events;
305
+ if (!bus)
306
+ return; // host has no event bus (old pi)
307
+ if (options.relay === false)
308
+ return; // relay explicitly disabled
309
+ if (process.env?.PI_WATCHER_AUTO_BIND === '0')
310
+ return; // auto-bind explicitly disabled
311
+ if (!backend)
312
+ return; // runtime unavailable: retry on the next session event
313
+ const opts = resolveOptions(ctx);
314
+ const relayConf = resolveRelay();
315
+ const realm = typeof relayConf === 'object' && relayConf.realm ? relayConf.realm : 'local';
316
+ // Exactly the same derivation as runtime.ts uses when opening the source store (shared helper), otherwise we would bind to a nonexistent source
317
+ const relaySourceId = typeof relayConf === 'object' && relayConf.sourceId ? relayConf.sourceId : deriveSourceId(opts.rootDir);
318
+ ensureAutoBindListener();
319
+ const sleep = (ms) => new Promise(r => { const t = setTimeout(r, ms); t.unref?.(); });
320
+ const delays = [0, 1500, 4000];
321
+ const sid = sessionIdOf(ctx);
322
+ // -- Phase 1: v2 local standing binding (invite-free, idempotent, held by each session separately) --
323
+ // Not gated on negotiation state: when a shared runtime is already on relay, a new session still needs its own standing
324
+ // binding (otherwise this session has no wake channel after the host session exits; standing has no TTL/overlap limit, so the cost is zero)
325
+ // -- Phase 1: v2 local standing binding (invite-free; capability probe = send v2 directly) --
326
+ for (let attempt = 0; attempt < delays.length; attempt++) {
327
+ if (sessionClosed)
328
+ return;
329
+ if (delays[attempt] > 0)
330
+ await sleep(delays[attempt]);
331
+ if (sessionClosed || !backend)
332
+ return;
333
+ const result = await emitBindRequest(bus, `auto-bind-${sid}-l${attempt}`, 'local', {
334
+ realm, sourceId: relaySourceId, channelId: 'W', projectRoot: opts.rootDir
335
+ });
336
+ if (!result)
337
+ continue; // no reply (relay not loaded / not attached) -> back off and retry
338
+ if (result.ok) {
339
+ ctx.ui?.notify?.(`pi-watcher: relay standing binding active for this session${result.bindingId ? ` (${result.bindingId})` : ''}${result.armed === false ? ' [not armed]' : ' [sessionScoped wake]'} — watcher attention will now wake this session`, 'info');
340
+ // resume self-heal: diagnose/reconfigure right after a successful bind (ensures the frozen routeSet contains the new binding's
341
+ // active membership; pid-GC already cleared dead rows at enroll)
342
+ void backend?.healRelay().catch(() => { });
343
+ return;
344
+ }
345
+ const code = result.error?.code ?? '';
346
+ // capability/authorization unsupported -> fall back to invite (v1 path)
347
+ if (/local_trust_disabled|unsupported_feature|source_not_found|invalid_payload/i.test(code))
348
+ break;
349
+ if (/not.?attached|no.?host|attach/i.test(`${code} ${result.error?.message ?? ''}`))
350
+ continue; // relay not ready yet -> retry
351
+ ctx.ui?.notify?.(`pi-watcher: relay standing auto-bind failed (${code || 'unknown'}: ${result.error?.message ?? ''}) — falling back to invite path`, 'warning');
352
+ break;
353
+ }
354
+ // -- Phase 2: v1 invite fallback (only when relay is not yet negotiated and owner pre-authorized: relay-setup has an unconsumed invite) --
355
+ try {
356
+ const listRaw = JSON.parse(await backend.toolCall({ action: 'list' }, actorOf(ctx)));
357
+ if (listRaw?.value?.relay === 'relay')
358
+ return; // already negotiated: invite path not needed
359
+ }
360
+ catch {
361
+ return;
362
+ }
363
+ let invitePath;
364
+ try {
365
+ const setupCandidates = [
366
+ pathMod.join(opts.rootDir, 'relay', 'relay-setup.json'),
367
+ pathMod.join(opts.rootDir, 'relay-setup.json'),
368
+ // per-session root (decision 2026-09-21): owner pre-authorization is a project-level artifact ->
369
+ // the parent root (two levels above <parent>/sessions/<sid>)
370
+ pathMod.join(pathMod.dirname(pathMod.dirname(opts.rootDir)), 'relay-setup.json')
371
+ ];
372
+ const setupPath = setupCandidates.find(p => fs.existsSync(p));
373
+ if (setupPath) {
374
+ const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8'));
375
+ const candidate = setup.inviteFile ?? setup.invitePath;
376
+ if (candidate && fs.existsSync(candidate)) {
377
+ try {
378
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
379
+ // Expired stale invite (e.g. an old store invite left over from yesterday): do not send an invalid bind, to avoid a store_unavailable error
380
+ if (typeof parsed.expiresAt !== 'number' || parsed.expiresAt > Date.now()) {
381
+ invitePath = candidate;
382
+ }
383
+ }
384
+ catch {
385
+ invitePath = candidate;
386
+ }
387
+ }
388
+ }
389
+ }
390
+ catch { /* no setup -> no fallback */ }
391
+ if (!invitePath || !fs.existsSync(invitePath))
392
+ return;
393
+ for (let attempt = 0; attempt < delays.length; attempt++) {
394
+ if (sessionClosed)
395
+ return;
396
+ if (delays[attempt] > 0)
397
+ await sleep(delays[attempt]);
398
+ if (sessionClosed || !backend)
399
+ return;
400
+ const result = await emitBindRequest(bus, `auto-bind-${sid}-i${attempt}`, 'invite', {
401
+ realm, sourceId: relaySourceId, channelId: 'W', projectRoot: opts.rootDir, invitePath
402
+ });
403
+ if (!result)
404
+ continue;
405
+ if (result.ok) {
406
+ ctx.ui?.notify?.(`pi-watcher: relay wake bound to this session${result.bindingId ? ` (${result.bindingId})` : ''}${result.armed === false ? ' [not armed]' : ' [armed]'} — watcher attention will now wake this session`, 'info');
407
+ return;
408
+ }
409
+ // A single-use invite already consumed by another session -> treat as "bound elsewhere", no retry and no error
410
+ const code = result.error?.code ?? '';
411
+ if (/invite|consumed|used|already/i.test(`${code} ${result.error?.message ?? ''}`)) {
412
+ ctx.ui?.notify?.(`pi-watcher: relay invite already used by another session — wakes go there; relay_bindings list shows bindings`, 'info');
413
+ return;
414
+ }
415
+ if (/not.?attached|no.?host|attach/i.test(`${code} ${result.error?.message ?? ''}`))
416
+ continue; // relay not ready yet -> retry
417
+ ctx.ui?.notify?.(`pi-watcher: relay auto-bind failed (${code || 'unknown'}: ${result.error?.message ?? ''}) — use relay_bindings bind manually`, 'warning');
418
+ return;
419
+ }
420
+ };
421
+ pi.on('session_shutdown', async () => {
422
+ await ensureChain.catch(() => { });
423
+ sessionClosed = true;
424
+ const be = backend;
425
+ backend = null;
426
+ backendError = null;
427
+ widgetCtx = null;
428
+ be?.close();
429
+ });
430
+ // -- Autonomous registration: long-running exec sessions automatically get a watch (no reliance on the model remembering to call watcher register) --
431
+ // Trigger signal: this session's exec_command / write_stdin result contains a session_id that is still running.
432
+ // Attribution is exact: the tool_result event fires in the extension instance of the session that made the call.
433
+ const autoExecEnabled = options.autoExecWatch
434
+ ?? (typeof process !== 'undefined' ? process.env?.PI_WATCHER_AUTO_EXEC === '1' : false);
435
+ // Exit-marker injection: a watched exec session without a marker has an undecidable outcome (the marker is not a pi standard; pi-unified-exec
436
+ // does not write the exit code to the log). tool_call patches in `echo __EXEC_EXIT__:$?` in place: passive helper, creates no watch, zero noise.
437
+ const markerInjectionEnabled = options.execMarkerInjection
438
+ ?? (typeof process !== 'undefined' ? process.env?.PI_WATCHER_EXEC_MARKER !== '0' : true);
439
+ pi.on('tool_call', (event) => {
440
+ if (!markerInjectionEnabled || sessionClosed)
441
+ return;
442
+ if (event.toolName !== 'exec_command')
443
+ return;
444
+ const input = event.input;
445
+ if (!input || typeof input.cmd !== 'string')
446
+ return;
447
+ if (input.cmd.includes('__EXEC_EXIT__'))
448
+ return; // model already included it -> do not duplicate
449
+ const trimmed = input.cmd.replace(/\s+$/, '');
450
+ input.cmd = `${trimmed}\necho __EXEC_EXIT__:$?`;
451
+ });
452
+ pi.on('tool_result', (event, ctx) => {
453
+ if (!autoExecEnabled || sessionClosed)
454
+ return;
455
+ if (event.toolName !== 'exec_command' && event.toolName !== 'write_stdin')
456
+ return;
457
+ // async side-path: does not block tool result delivery, fails silently (the next event / manual register is the fallback)
458
+ void autoWatchExec(event, ctx).catch(() => { });
459
+ });
460
+ const autoWatchExec = async (event, ctx) => {
461
+ const text = contentText(event.content);
462
+ if (/\[exited\]/.test(text))
463
+ return; // already finished: nothing to observe
464
+ const sid = /session_id:\s*(\d+)/.exec(text)?.[1];
465
+ if (!sid)
466
+ return;
467
+ // universal monitor mode: pin the log_path from the exec result directly (the exact artifact the agent already holds),
468
+ // using a hit on the injected marker pattern as the declarative terminal state (the marker is a pi-watcher convention, not a fact about external libraries)
469
+ const logPath = /log_path:\s*(\S+\.log)/.exec(text)?.[1];
470
+ if (!logPath)
471
+ return; // no path to pin: do not act on our own
472
+ await ensureBackend(ctx);
473
+ const be = backend;
474
+ if (!be)
475
+ return;
476
+ const actor = actorOf(ctx);
477
+ // Dedup: if this owner already has an active watch on the same log path, do not create another (list projection runId = path + pattern digest)
478
+ const listRaw = JSON.parse(await be.toolCall({ action: 'list' }, actor));
479
+ const watches = listRaw?.value?.watches ?? [];
480
+ const transport = listRaw?.value?.relay === 'relay' ? 'relay' : 'local-display';
481
+ const candidate = {
482
+ mode: 'embedded',
483
+ target: {
484
+ kind: 'run', sourceId: 'agent-file', taskId: 'file',
485
+ runId: `auto-exec-${sid}`, attemptId: 'attempt-1',
486
+ file: {
487
+ path: logPath,
488
+ okPattern: '__EXEC_EXIT__:0',
489
+ failPattern: '__EXEC_EXIT__:-?[1-9]'
490
+ }
491
+ },
492
+ mission: {
493
+ objective: `auto: shell session ${sid} (exec log)`,
494
+ scope: `tail exec log ${logPath}; read-only`,
495
+ checkpointId: 'auto-exec',
496
+ requiredArtifacts: [], requiresChecks: false, businessAcceptance: 'not_required'
497
+ },
498
+ policy: {
499
+ transport,
500
+ semanticMode: 'off', // autonomous watch never sends egress to Jev (three-consent model)
501
+ notificationOwner: 'watcher',
502
+ requestKinds: [],
503
+ maxRequestsPerEpisode: 1,
504
+ episodeCooldownMs: 60000,
505
+ attentionTtlMs: DEFAULT_ATTENTION_TTL_MS
506
+ },
507
+ limits: {
508
+ pollMinMs: 1000, pollMaxMs: 5000,
509
+ maxSilenceMs: 600000,
510
+ maxProbesPerEpisode: 2, maxJudgeRequestsPerDay: 10,
511
+ expiresAt: new Date(Date.now() + 12 * 3600_000).toISOString()
512
+ }
513
+ };
514
+ // Idempotent: requestId is stable; skip if an active watch with the same runId exists
515
+ const digestRunId = candidate.target.runId;
516
+ if (watches.some(w => w.runId === digestRunId && (w.lifecycle === 'active' || w.lifecycle === 'paused')))
517
+ return;
518
+ const regRaw = JSON.parse(await be.toolCall({
519
+ action: 'register', requestId: `auto-exec-${sid}`, candidate
520
+ }, actor));
521
+ if (regRaw?.ok && regRaw.value?.watchId) {
522
+ ctx.ui?.notify?.(`pi-watcher: auto-watching shell session ${sid} log (${regRaw.value.watchId}, ${transport}) — progress via watcher inspect/check`, 'info');
523
+ }
524
+ };
525
+ /** /watcher jev: which Jev source is active, and whether egress consent is granted. */
526
+ const jevStatusText = async (ctx) => {
527
+ noteRegistry(ctx);
528
+ const consent = jevConsentState();
529
+ let source;
530
+ if (process.env?.JEV_API_KEY) {
531
+ source = 'JEV_API_KEY (direct TypeSafe API, jev-1.13.0)';
532
+ }
533
+ else {
534
+ const status = latestRegistry ? await new PiRegistryJudge(() => latestRegistry).ready() : { ready: false, reason: 'this Pi version exposes no model registry to extensions' };
535
+ source = status.ready ? `Pi model registry: ${status.model}` : `none: ${status.reason ?? 'unavailable'}`;
536
+ }
537
+ return [
538
+ 'pi-watcher semantic review (Jev, optional)',
539
+ ` model source: ${source}`,
540
+ ` egress consent: ${consent.granted ? 'granted' : 'not granted'} (${consent.source === 'env' ? 'JEV_CONSENT env' : consent.source === 'stored' ? consentFilePath() : 'default'})`,
541
+ ' enable: /login a Jev provider (or set TYPESAFE_API_KEY / JEV_API_KEY), then /watcher jev consent on;',
542
+ ' watches opt in per watch with semanticMode=shadow|active.'
543
+ ].join('\n');
544
+ };
545
+ pi.registerCommand('watcher', {
546
+ description: 'pi-watcher panel: watches, health, open issues, and why no attention was sent. /watcher ack <episodeId> <received|investigating|defer|resolved|dismiss> [until ISO] — local owner response. /watcher jev [status] | /watcher jev consent <on|off> — optional Jev semantic review',
547
+ handler: async (args, ctx) => {
548
+ await ensureBackend(ctx);
549
+ const be = backend;
550
+ if (!be) {
551
+ ctx.ui?.notify?.(`pi-watcher unavailable: ${errorText(backendError) || 'not started'}`, 'warning');
552
+ return;
553
+ }
554
+ const argv = (args ?? '').trim().split(/\s+/).filter(Boolean);
555
+ if (argv[0] === 'jev') {
556
+ // User-only: egress consent is never settable from the model-facing tool (I24, design 10.2).
557
+ if (argv[1] === 'consent' && (argv[2] === 'on' || argv[2] === 'off')) {
558
+ setStoredJevConsent(argv[2] === 'on');
559
+ const after = jevConsentState();
560
+ ctx.ui?.notify?.(`pi-watcher: Jev egress consent ${argv[2] === 'on' ? 'granted' : 'revoked'} (stored in ${consentFilePath()})` +
561
+ (after.source === 'env' ? `; note: JEV_CONSENT in the environment overrides it (effective: ${after.granted ? 'on' : 'off'})` : ''), 'info');
562
+ return;
563
+ }
564
+ if (argv[1] && argv[1] !== 'status') {
565
+ ctx.ui?.notify?.('Usage: /watcher jev [status] | /watcher jev consent <on|off>', 'warning');
566
+ return;
567
+ }
568
+ ctx.ui?.notify?.(await jevStatusText(ctx), 'info');
569
+ return;
570
+ }
571
+ if (argv[0] === 'ack') {
572
+ // Local owner panel ACK (non-model path; does not claim relay delivery, design 11.7 + I05/I21)
573
+ const [, episodeId, action, until, ...rest] = argv;
574
+ const allowed = ['received', 'investigating', 'defer', 'resolved', 'dismiss'];
575
+ if (!episodeId || !action || !allowed.includes(action)) {
576
+ ctx.ui?.notify?.('Usage: /watcher ack <episodeId> <received|investigating|defer|resolved|dismiss> [until ISO] [reason...]', 'warning');
577
+ return;
578
+ }
579
+ const result = await be.localAck({
580
+ requestId: `ack-${episodeId}-${Date.now()}`,
581
+ episodeId,
582
+ action: action,
583
+ note: rest.join(' ') || `local owner ${action}`,
584
+ until: until && !Number.isNaN(Date.parse(until)) ? until : undefined
585
+ }, actorOf(ctx));
586
+ const parsed = JSON.parse(result);
587
+ ctx.ui?.notify?.(result, parsed.ok === false ? 'error' : 'info');
588
+ return;
589
+ }
590
+ const panel = await be.panel(actorOf(ctx));
591
+ const text = JSON.stringify(panel, null, 2);
592
+ ctx.ui?.notify?.(text.length > 4000 ? `${text.slice(0, 4000)}…` : text, 'info');
593
+ }
594
+ });
595
+ pi.registerTool({
596
+ name: 'watcher',
597
+ label: 'Watcher',
598
+ description: 'Fact-driven monitoring for long-running tasks — the replacement for polling loops, sleep waits, and scheduled checks. ' +
599
+ 'Actions: watch-file/watch-check/register/list/inspect/check/pause/close/ack/update. watch-file tails an agent-declared file ' +
600
+ '(e.g. the log_path of a running exec session, a build log, a report) with declared okPattern/failPattern as terminal evidence; ' +
601
+ 'watch-check runs an agent-declared bounded command (CI/cloud/remote state) on a fixed interval with a declared verdict mapping. ' +
602
+ 'Watches observe task facts (run state, deadlines, silence, ' +
603
+ 'obligations, dependencies) and optionally invoke Jev (a fast, lightweight discriminator model) for semantic progress monitoring. ' +
604
+ 'list returns active watches by default with a terminalCount summary of finished (closed/expired) history; pass includeClosed=true to list full history. ' +
605
+ 'Wakes this session via pi-relay only when a fact requires a decision. ' +
606
+ 'Each session runs its own watcher runtime under .pi-watcher/sessions/<id>/ — no cross-session coupling, plugin upgrades never skew. ' +
607
+ 'Never executes business actions, never arms, binds, or force-wakes. ack requires a relay delivery credential ' +
608
+ '(returns NO_DELIVERY while the relay managed path is not negotiated).',
609
+ promptSnippet: 'watcher: fact-driven monitoring for long-running tasks — replaces polling loops, sleep waits, and scheduled checks; wakes this session via pi-relay when facts change',
610
+ promptGuidelines: [
611
+ 'Prefer watcher over any polling pattern: whenever you would run a watch/sleep loop, retry with delays, schedule a future check ("check back in 10 minutes"), or repeatedly poll a running unified-exec session with write_stdin, register a watch instead — it observes the task, tracks deadlines and silence, and wakes this session via pi-relay only when a fact requires a decision. To check progress on a watched task at any time, call watcher(action: "inspect") or watcher(action: "check") — it returns the live status, latest output tail, and health without blocking. Never busy-poll the underlying process with write_stdin, sleep loops, or ad-hoc terminal reads.',
612
+ 'You decide what to watch — point the watcher at durable evidence. To watch a long-running shell command, first run it with exec_command (it yields session_id + log_path once it outlives the attach window), then call watcher action=watch-file with path=<that log_path>, okPattern="__EXEC_EXIT__:0", failPattern="__EXEC_EXIT__:-?[1-9]" (the exit marker is auto-injected into your command). Growth is tracked as progress, the declared patterns fire terminal facts, silence/deadline surface stuck tasks, and terminal facts auto-close after the attention TTL. By default semanticMode is "off" (pure fact tracking); pass semanticMode="shadow" to have Jev review progress/loops/blockers in the background without waking, or "active" to have Jev wake this session via relay when an impasse or decision-required blocker occurs. Check progress anytime with watcher inspect/check (live status, output tail, health) — never write_stdin-poll a watched session. Use the full action=register ONLY for custom policy (group targets, specific deadlines/silence budgets beyond the simple overrides).',
613
+ 'For multi-step pipelines (long sequences of short commands), do NOT shepherd each step with attached waits — run the whole sequence as ONE long-running command or script (append `; echo __EXEC_EXIT__:$?`) and watch its log file instead.',
614
+ 'When the user asks to monitor, watch, or keep an eye on a background task (builds, tests, training runs, long jobs), call watcher with action=watch-file on its log/output file, or action=watch-check when there is no local file — never spawn while/sleep loops or detached pollers.',
615
+ 'Use watcher action=watch-file for any state that materializes as a growing file: exec session logs (path from the exec result), build/test output redirected to a file, generated reports and artifacts. Declare the verdict honestly: okPattern (success) and failPattern (failure, checked first) on the file tail are the ONLY content-terminal facts — without patterns you get progress/silence facts only, and a missing or rotated file degrades health honestly (never a fabricated verdict).',
616
+ 'Use Jev lightweight semantic progress monitoring (semanticMode="shadow"|"active") when task status cannot be judged by exit code or pattern alone — e.g. long builds, model training, migrations, multi-step pipelines, or flaky test suites. Jev is a fast, lightweight System 1 model (~300ms latency, zero autoregressive LLM overhead) that semantically inspects log windows for forward progress, repeating loops without new info, and unresolved blockers. Use "shadow" for quiet background review (status visible in inspect/widget) or "active" to wake this session via relay when an impasse or blocker occurs.',
617
+ 'Use watcher action=watch-check to watch any remote or file-less state you cannot tail locally — CI build conclusion (gh run view), cloud resource readiness (kubectl/gcloud), service health (curl). Declare the verdict honestly: okPattern/failCodes/failPattern map outcomes to terminal facts; unmapped nonzero exits are pending (not failure); exit 126/127/timeout mean the CHECK is broken (degraded, never a task verdict). The watcher runs the command on a fixed interval — do NOT run it yourself in a loop. For blocking waits (kubectl wait) prefer exec_command + a log file + action=watch-file.',
618
+ 'Call watcher with action=inspect to query or report the status of any registered watch — it holds the authoritative fact base (observations, episodes, health); do not guess the state and do not probe with ad-hoc bash when a watch exists.',
619
+ 'When a pi-relay managed delivery wakes this session: call watcher inspect for that watch first, report or decide using the fresh facts, then call relay_respond (received/investigating/defer/resolved/dismiss) with the deliveryRef to close the loop.',
620
+ 'When the user cancels, abandons, or changes a monitored task, call watcher with action=pause or action=close so stale watches stop generating attention.',
621
+ 'Use watches for obligations and dependencies too: kind=obligation watches fire on facts (dependencies terminal/succeeded, readyWhen) instead of timers, and defer responses resurface the same episode at the snooze deadline without re-waking.',
622
+ ],
623
+ parameters: Type.Object({
624
+ action: Type.Union([
625
+ Type.Literal('watch-file'),
626
+ Type.Literal('watch-check'),
627
+ Type.Literal('register'),
628
+ Type.Literal('list'),
629
+ Type.Literal('inspect'),
630
+ Type.Literal('check'),
631
+ Type.Literal('ack'),
632
+ Type.Literal('pause'),
633
+ Type.Literal('close'),
634
+ Type.Literal('update')
635
+ ]),
636
+ path: Type.Optional(Type.String({ description: 'For action=watch-file: absolute path of the file to tail (e.g. the log_path from an exec_command result, a build log, a report). Relative paths resolve against the session cwd' })),
637
+ okPattern: Type.Optional(Type.String({ description: 'RegExp meaning terminal success. watch-file: matched against the file tail (for an exec log: __EXEC_EXIT__:0). watch-check: matched against command stdout (for CLIs that always exit 0; without it, exit 0 = succeeded)' })),
638
+ failPattern: Type.Optional(Type.String({ description: 'RegExp meaning terminal failure, checked before okPattern. watch-file: file tail (for an exec log: __EXEC_EXIT__:-?[1-9]). watch-check: command stdout, even when exit 0' })),
639
+ cmd: Type.Optional(Type.String({ description: 'For action=watch-check: the bounded check command to run periodically (max 2000 chars; NO secrets — it is persisted as evidence). Exit code/stdout carries the verdict per okPattern/failPattern/failCodes; non-mapped nonzero exits mean pending, not failure' })),
640
+ intervalMs: Type.Optional(Type.Integer({ description: 'For action=watch-check: fixed run interval (default 30000, clamped 1000-600000)' })),
641
+ timeoutMs: Type.Optional(Type.Integer({ description: 'For action=watch-check: per-run timeout with kill (default 10000, clamped 1000-30000)' })),
642
+ failCodes: Type.Optional(Type.Array(Type.Integer(), { description: 'For action=watch-check: exit codes meaning explicit failure (default: only patterns decide failure)' })),
643
+ objective: Type.Optional(Type.String({ description: 'For action=watch-file/watch-check: one-line purpose of the watched target' })),
644
+ deadlineAt: Type.Optional(Type.String({ description: 'For action=watch-file/watch-check: optional business deadline (ISO)' })),
645
+ maxSilenceMs: Type.Optional(Type.Integer({ description: 'For action=watch-file/watch-check: optional silence budget (default 600000 for files, 1800000 for checks)' })),
646
+ includeClosed: Type.Optional(Type.Boolean({
647
+ description: 'For action=list: include terminal (closed/expired) watches in the response. ' +
648
+ 'Default returns only active/paused watches plus a terminalCount summary — finished watches are history, not decision inputs.'
649
+ })),
650
+ semanticMode: Type.Optional(Type.Union([Type.Literal('off'), Type.Literal('shadow'), Type.Literal('active')], {
651
+ description: 'For action=watch-file/watch-check: semantic progress monitoring via Jev (a fast, lightweight discriminator model, not an LLM). ' +
652
+ 'off (default): pure fact tracking. ' +
653
+ 'shadow: lightweight model semantically monitors output logs in background for progress, repeating loops, and blockers without waking. ' +
654
+ 'active: lightweight model monitors semantic progress and wakes this session via pi-relay when an impasse, repeating loop, or host decision is needed.'
655
+ })),
656
+ requestId: Type.Optional(Type.String({ description: 'Idempotency key (required for register/pause/close)' })),
657
+ watchId: Type.Optional(Type.String({ description: 'Watch id starting with w- (required for inspect/check/pause/close)' })),
658
+ expectedControlRevision: Type.Optional(Type.Integer({ description: 'Current controlRevision of the watch (required for check/pause/close)' })),
659
+ reason: Type.Optional(Type.String({ description: 'Human-readable reason (required for pause/close)' })),
660
+ candidate: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: 'RegisterCandidate spec (required for register): target, mission, policy, limits' }))
661
+ }),
662
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
663
+ await ensureBackend(ctx);
664
+ let be = backend;
665
+ if (!be) {
666
+ const lockHeld = isRootLockHeld(backendError);
667
+ const base = `pi-watcher runtime unavailable: ${errorText(backendError) || 'not started'}`;
668
+ const message = lockHeld
669
+ ? `${base} — another process holds this session's watcher root (duplicate session process?); acquisition is retried on every watcher call`
670
+ : base;
671
+ return {
672
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: lockHeld ? 'ROOT_LOCK_HELD' : 'STORE_UNAVAILABLE', message } }) }]
673
+ };
674
+ }
675
+ const actor = actorOf(ctx);
676
+ // cwd injection: watch-file relative path resolution / watch-check command working directory (decision record 2026-09-21)
677
+ const injectable = params;
678
+ if ((injectable.action === 'watch-file' || injectable.action === 'watch-check') && injectable.cwd === undefined) {
679
+ injectable.cwd = ctx.cwd;
680
+ }
681
+ const runTool = (b) => b.toolCall(params, actor);
682
+ // per-session root: no cross-process failover path; tool failures are thrown up honestly
683
+ const text = await runTool(be);
684
+ return { content: [{ type: 'text', text }] };
685
+ }
686
+ });
687
+ }