@gotgenes/pi-permission-system 26.1.0 → 26.2.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [26.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.1.0...pi-permission-system-v26.2.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** publish a serving heartbeat while draining the inbox ([2055e35](https://github.com/gotgenes/pi-packages/commit/2055e353e147af6738ddc23c2c755f56f8032f8b)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **pi-permission-system:** fail fast when an out-of-process parent is not serving ([e190af9](https://github.com/gotgenes/pi-packages/commit/e190af9f06c49375432f19bb228e068ade01c912)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
19
+
20
+
21
+ ### Documentation
22
+
23
+ * **pi-permission-system:** document out-of-process forwarding liveness ([b5a50d5](https://github.com/gotgenes/pi-packages/commit/b5a50d535c97ca0ec388c5f482afb395e15289fa)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
24
+
8
25
  ## [26.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.0.0...pi-permission-system-v26.1.0) (2026-08-17)
9
26
 
10
27
 
@@ -97,20 +97,20 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
97
97
 
98
98
  ## Runtime Knobs
99
99
 
100
- | Key | Default | Description |
101
- | --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
- | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
103
- | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
104
- | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
105
- | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
106
- | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose in-process parent is not draining its inbox gives up in ~2 s regardless. |
107
- | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
108
- | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
109
- | `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
110
- | `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
111
- | `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
112
- | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
113
- | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
100
+ | Key | Default | Description |
101
+ | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
103
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
104
+ | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
105
+ | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
106
+ | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
107
+ | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
108
+ | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
109
+ | `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
110
+ | `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
111
+ | `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
112
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
113
+ | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
114
114
 
115
115
  Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
116
116
  No debug output is printed to the terminal.
@@ -35,7 +35,20 @@ For in-process child sessions, detection and forwarding use the event-driven reg
35
35
  ### When nobody answers
36
36
 
37
37
  A forwarded request is only useful if some session is draining the inbox it was written into.
38
- The polling session publishes the session id it polls, and an in-process child checks that its target is published before committing to a long wait.
38
+ The polling session publishes the session id it polls, and a child checks that its target is published before committing to a long wait.
39
+
40
+ The announcement goes out on two channels, because a child cannot always reach the same one.
41
+ A child running inside its parent's process reads a process-global registry.
42
+ A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
43
+
44
+ For an out-of-process target, four things count as "not draining":
45
+
46
+ | What the child finds | What it means |
47
+ | ------------------------------------------------- | ---------------------------------------------------------------------------- |
48
+ | No record | The parent exited, never served, or runs a version that does not publish one |
49
+ | A record naming a process that is gone | The parent was killed rather than shut down |
50
+ | A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
51
+ | A record for a different session id | The child is forwarding somewhere nobody is listening |
39
52
 
40
53
  If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
41
54
 
@@ -47,6 +60,7 @@ permission requests.
47
60
 
48
61
  The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
49
62
  A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
63
+ That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
50
64
 
51
65
  Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
52
66
  None of them is reported as a user denial, because no user was ever asked.
@@ -68,8 +82,13 @@ The serving node records its decider on the response — a rule of its own (with
68
82
  That is the difference between a human approving a subagent's request and the parent's policy approving it on their behalf — two outcomes that were previously indistinguishable in the log.
69
83
  An older parent that sends no decider yields `"decision": null`: the hop is still recorded, and the answer is still honored.
70
84
 
71
- This liveness signal is process-local, so it applies to in-process children only.
72
- A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) cannot observe its parent's polling and still waits the full timeout.
85
+ ### Upgrading
86
+
87
+ Upgrade the parent before relying on the out-of-process signal — in practice, restart the interactive session after upgrading the package.
88
+
89
+ A parent session still running a version that predates the heartbeat publishes none, and a child on a version that expects one reads that absence as "not draining" and gives up in about two seconds.
90
+ That only happens in the window where an upgrade lands while a parent session is already running, and it resolves as soon as that session restarts.
91
+ Nothing needs to be edited, and in-process children are unaffected: parent and child there are the same running copy by construction.
73
92
 
74
93
  ---
75
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "26.1.0",
3
+ "version": "26.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,7 @@ import {
19
19
  sleep,
20
20
  writeJsonFileAtomic,
21
21
  } from "#src/authority/forwarding-io";
22
+ import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
22
23
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
23
24
  import {
24
25
  type ForwardedAccessFacts,
@@ -33,7 +34,6 @@ import {
33
34
  resolvePermissionForwardingTarget,
34
35
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
35
36
  } from "#src/authority/permission-forwarding";
36
- import type { ServingLookup } from "#src/authority/serving-registry";
37
37
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
38
38
  import { createPermissionRequestId } from "#src/permission-request-id";
39
39
  import { buildUiPrompt } from "#src/permission-ui-prompt";
@@ -97,8 +97,8 @@ export interface ParentAuthorizerDeps {
97
97
  forwardingDir: string;
98
98
  /** In-process subagent session registry for forwarding target resolution. */
99
99
  registry?: SubagentSessionRegistry;
100
- /** Whether the resolved target is draining its inbox (in-process targets only). */
101
- serving: ServingLookup;
100
+ /** Whether the resolved target is draining its inbox, on whichever channel can say. */
101
+ serving: TargetServingLookup;
102
102
  /** How long to wait for the target's answer, read live so config edits apply. */
103
103
  getTimeoutMs: () => number;
104
104
  logger: DebugReviewLogger;
@@ -181,7 +181,7 @@ function forwardableRequestId(requesterRequestId: string): string {
181
181
  export class ParentAuthorizer implements TerminalAuthorizer {
182
182
  private readonly forwardingDir: string;
183
183
  private readonly registry: SubagentSessionRegistry | undefined;
184
- private readonly serving: ServingLookup;
184
+ private readonly serving: TargetServingLookup;
185
185
  private readonly getTimeoutMs: () => number;
186
186
  private readonly logger: DebugReviewLogger;
187
187
 
@@ -387,11 +387,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
387
387
  unservedSince !== null &&
388
388
  Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
389
389
  ) {
390
+ const observation = this.serving.describe(target);
390
391
  this.logger.review("forwarded_permission.no_serving_session", {
391
392
  requestId,
392
393
  requesterSessionId: request.requesterSessionId,
393
394
  targetSessionId,
394
- servingSessionIds: this.serving.servingIds(),
395
+ // Which channel answered, and what it saw: the difference between a
396
+ // parent that exited, one that was killed, and one polling under a
397
+ // different session id is the whole diagnosis of a stalled forward.
398
+ servingChannel: observation.channel,
399
+ servingState: observation.state,
400
+ servingSessionIds: observation.servingIds,
395
401
  });
396
402
  this.discardRequest(location, requestPath);
397
403
  return abandon(
@@ -421,21 +427,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
421
427
  /**
422
428
  * Track how long the target has looked unserved, or `null` while it looks fine.
423
429
  *
424
- * Only an in-process target (`source: "registry"`) can be judged: a target in
425
- * another process shares no serving registry with this one, so its absence
426
- * from the registry says nothing (#719, follow-up in #721).
430
+ * Which channel can answer for this target is the judge's decision, not this
431
+ * one's: a target it cannot judge answers `null`, which resets the window
432
+ * exactly as "serving" does, so an unjudgeable target waits out the timeout.
427
433
  */
428
434
  private checkServingLiveness(
429
435
  target: PermissionForwardingTarget,
430
436
  unservedSince: number | null,
431
437
  ): number | null {
432
- if (target.source !== "registry") {
433
- return null;
434
- }
435
- if (this.serving.isServing(target.sessionId)) {
436
- return null;
437
- }
438
- return unservedSince ?? Date.now();
438
+ return this.serving.isServing(target) === false
439
+ ? (unservedSince ?? Date.now())
440
+ : null;
439
441
  }
440
442
 
441
443
  /**
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
2
3
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
4
  import type {
4
5
  PromptPreferences,
5
6
  requestPermissionDecision,
6
7
  } from "#src/authority/permission-prompt-component";
7
- import type { ServingLookup } from "#src/authority/serving-registry";
8
8
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
9
9
  import type { PermissionEventBus } from "#src/permission-events";
10
10
  import type { AuthorizerLog, PermissionQuery } from "#src/service";
@@ -108,8 +108,8 @@ export interface AuthorizerSelectionDeps {
108
108
  forwardingDir: string;
109
109
  /** In-process subagent session registry for forwarding target resolution. */
110
110
  registry?: SubagentSessionRegistry;
111
- /** Which sessions are draining a forwarded-permission inbox. */
112
- servingRegistry: ServingLookup;
111
+ /** Whether a forwarding target is draining its inbox, on whichever channel can say. */
112
+ serving: TargetServingLookup;
113
113
  /** The forwarding timeout, read live so a config edit applies to the next ask. */
114
114
  getForwardingTimeoutMs: () => number;
115
115
  logger: DebugReviewLogger;
@@ -145,7 +145,7 @@ export function selectAuthorizer(
145
145
  terminal: new ParentAuthorizer(ctx, {
146
146
  forwardingDir: deps.forwardingDir,
147
147
  registry: deps.registry,
148
- serving: deps.servingRegistry,
148
+ serving: deps.serving,
149
149
  getTimeoutMs: deps.getForwardingTimeoutMs,
150
150
  logger: deps.logger,
151
151
  }),
@@ -0,0 +1,450 @@
1
+ /**
2
+ * forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
3
+ *
4
+ * The in-process answer already exists: a serving session marks itself in the
5
+ * process-global `ServingSessionRegistry`, and an in-process child abandons a
6
+ * target that has looked unmarked for the grace window instead of waiting out
7
+ * the full forwarding timeout (#719).
8
+ *
9
+ * A child spawned as a separate `pi` process shares no `globalThis` with its
10
+ * parent, so that mark is invisible to it and it keeps waiting the full ten
11
+ * minutes — every `ask` forwarded to a session that has already exited costs
12
+ * the whole timeout and ends in a denial nobody made (#735 scenario 1).
13
+ *
14
+ * The filesystem is the only channel those two processes share, so the serving
15
+ * session publishes a heartbeat there: one record per serving session,
16
+ * refreshed while it polls and withdrawn when it stops.
17
+ */
18
+
19
+ import { readdirSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import {
22
+ ensureDirectoryExists,
23
+ isErrnoCode,
24
+ logPermissionForwardingError,
25
+ safeDeleteFile,
26
+ writeJsonFileAtomic,
27
+ } from "#src/authority/forwarding-io";
28
+ import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
29
+ import {
30
+ encodeSessionIdForPath,
31
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
32
+ } from "#src/authority/permission-forwarding";
33
+ import type {
34
+ ServingAnnouncer,
35
+ ServingLookup,
36
+ } from "#src/authority/serving-registry";
37
+ import type { DebugReviewLogger } from "#src/session-logger";
38
+
39
+ /**
40
+ * How often a serving session rewrites its heartbeat — four poll ticks.
41
+ *
42
+ * Longer than the poll interval so `ForwardingManager` can announce on every
43
+ * tick without four filesystem writes a second, and short enough that a record
44
+ * deleted underneath its owner reappears well inside the grace window a
45
+ * forwarding child waits out before abandoning.
46
+ */
47
+ export const SERVING_HEARTBEAT_REFRESH_MS =
48
+ 4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
49
+
50
+ /**
51
+ * How long a heartbeat may go unrefreshed before its writer is presumed gone —
52
+ * five refreshes.
53
+ *
54
+ * Generous because a delayed Node timer is not a dead session, and because the
55
+ * case this threshold exists for (a process that is alive but no longer
56
+ * polling) is the rare one: an exited session withdraws its record and a killed
57
+ * one is caught by the recorded pid, neither of which waits for staleness.
58
+ */
59
+ export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
60
+
61
+ /** What a serving session publishes while it drains its forwarded-permission inbox. */
62
+ export interface ServingHeartbeat {
63
+ sessionId: string;
64
+ /** The serving process, so a killed session is detectable without waiting out staleness. */
65
+ pid: number;
66
+ updatedAt: number;
67
+ }
68
+
69
+ /**
70
+ * How a session's heartbeat reads right now.
71
+ *
72
+ * Only `"alive"` means someone is draining the inbox. The other three are the
73
+ * ways a target can be unserved, kept apart because they are the diagnosis a
74
+ * stalled forward needs: `"absent"` is a session that exited (or never served,
75
+ * or runs a version that does not publish), `"dead_pid"` one that was killed,
76
+ * and `"stale"` one whose process survives but stopped polling.
77
+ */
78
+ export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
79
+
80
+ /**
81
+ * Read side of the heartbeat channel, consumed by a forwarding child.
82
+ *
83
+ * Separate from the announce seam because the two have no caller in common: a
84
+ * serving session only publishes, and a forwarding child only reads (ISP).
85
+ */
86
+ export interface HeartbeatReader {
87
+ read(sessionId: string): HeartbeatState;
88
+ /** Every session whose record reads as alive, for the abandonment diagnostic. */
89
+ servingIds(): readonly string[];
90
+ }
91
+
92
+ /**
93
+ * Query-side seam: is the session a forwarding target names being drained?
94
+ *
95
+ * Keyed on the target rather than a session id because the answer depends on
96
+ * how the target was resolved. An in-process child and its parent share a
97
+ * `globalThis`, so the registry answers for them; an out-of-process pair shares
98
+ * only the filesystem; and a session that owns the inbox it is forwarding to is
99
+ * not a case either channel describes.
100
+ *
101
+ * Consolidating that into one collaborator is what keeps `ParentAuthorizer`
102
+ * from holding two lookups and re-deciding which one applies — the decision has
103
+ * one home, and a third channel would not reach the poll loop.
104
+ */
105
+ export interface TargetServingLookup {
106
+ /** `true` serving, `false` not serving, `null` when the target carries no signal. */
107
+ isServing(target: PermissionForwardingTarget): boolean | null;
108
+ /** What the judge observed, for the review entry a child writes when it gives up. */
109
+ describe(target: PermissionForwardingTarget): ServingObservation;
110
+ }
111
+
112
+ /** What answered a liveness question, and what it saw. */
113
+ export interface ServingObservation {
114
+ channel: "registry" | "heartbeat" | "none";
115
+ /** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
116
+ state: HeartbeatState | null;
117
+ servingIds: readonly string[];
118
+ }
119
+
120
+ /** Constructor config for {@link ForwardingLivenessJudge}. */
121
+ export interface ForwardingLivenessJudgeDeps {
122
+ /** Answers for a target the requester shares a process with. */
123
+ registry: ServingLookup;
124
+ /** Answers for a target in another process. */
125
+ heartbeats: HeartbeatReader;
126
+ }
127
+
128
+ /**
129
+ * Routes a liveness question to the channel that can answer it.
130
+ *
131
+ * The routing key is `PermissionForwardingTarget.source`, which the resolver
132
+ * already produces — so "in-process" is decided once, where the target is
133
+ * found, rather than re-derived here (#719).
134
+ */
135
+ export class ForwardingLivenessJudge implements TargetServingLookup {
136
+ constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
137
+
138
+ isServing(target: PermissionForwardingTarget): boolean | null {
139
+ switch (target.source) {
140
+ case "registry":
141
+ return this.deps.registry.isServing(target.sessionId);
142
+ case "env":
143
+ return this.deps.heartbeats.read(target.sessionId) === "alive";
144
+ case "self":
145
+ return null;
146
+ }
147
+ }
148
+
149
+ describe(target: PermissionForwardingTarget): ServingObservation {
150
+ switch (target.source) {
151
+ case "registry":
152
+ return {
153
+ channel: "registry",
154
+ state: null,
155
+ servingIds: this.deps.registry.servingIds(),
156
+ };
157
+ case "env":
158
+ return {
159
+ channel: "heartbeat",
160
+ state: this.deps.heartbeats.read(target.sessionId),
161
+ servingIds: this.deps.heartbeats.servingIds(),
162
+ };
163
+ case "self":
164
+ return { channel: "none", state: null, servingIds: [] };
165
+ }
166
+ }
167
+ }
168
+
169
+ const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
170
+
171
+ /**
172
+ * Where serving heartbeats live: beside the `sessions/` tree, never inside it.
173
+ *
174
+ * A heartbeat under `sessions/<id>/` would make that session root permanently
175
+ * non-empty, entangling liveness with the request/response cleanup whose
176
+ * removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
177
+ * that logic stays untouched and "who is serving" is a single directory read.
178
+ */
179
+ export function servingHeartbeatDir(forwardingDir: string): string {
180
+ return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
181
+ }
182
+
183
+ /** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
184
+ export function servingHeartbeatPath(
185
+ forwardingDir: string,
186
+ sessionId: string,
187
+ ): string {
188
+ return join(
189
+ servingHeartbeatDir(forwardingDir),
190
+ `${encodeSessionIdForPath(sessionId)}.json`,
191
+ );
192
+ }
193
+
194
+ /** Constructor config for {@link ServingHeartbeatStore}. */
195
+ export interface ServingHeartbeatStoreDeps {
196
+ forwardingDir: string;
197
+ logger: DebugReviewLogger;
198
+ /** Injected so the refresh throttle and staleness are testable without sleeping. */
199
+ now?: () => number;
200
+ /** The process to record. Injected so a test can publish a pid it controls. */
201
+ pid?: number;
202
+ /** Injected so a test can decide which pids are running. */
203
+ isProcessAlive?: (pid: number) => boolean;
204
+ }
205
+
206
+ /**
207
+ * Publishes this session's serving heartbeat to the filesystem.
208
+ *
209
+ * Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
210
+ * so `ForwardingManager` announces to both channels through one collaborator
211
+ * and neither knows the other exists.
212
+ *
213
+ * `markServing` is idempotent by that seam's contract and internally throttled,
214
+ * so the caller may announce on every poll tick. Nothing here throws: it runs
215
+ * from a timer, and a filesystem failure must degrade to the pre-existing
216
+ * timeout rather than break the poll loop.
217
+ */
218
+ export class ServingHeartbeatStore
219
+ implements ServingAnnouncer, HeartbeatReader
220
+ {
221
+ private readonly forwardingDir: string;
222
+ private readonly logger: DebugReviewLogger;
223
+ private readonly now: () => number;
224
+ private readonly pid: number;
225
+ private readonly isProcessAlive: (pid: number) => boolean;
226
+ private published: { sessionId: string; at: number } | null = null;
227
+ private hasSweptDeadRecords = false;
228
+
229
+ constructor(deps: ServingHeartbeatStoreDeps) {
230
+ this.forwardingDir = deps.forwardingDir;
231
+ this.logger = deps.logger;
232
+ this.now = deps.now ?? Date.now;
233
+ this.pid = deps.pid ?? process.pid;
234
+ this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
235
+ }
236
+
237
+ /** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
238
+ markServing(sessionId: string): void {
239
+ const at = this.now();
240
+ if (this.isThrottled(sessionId, at)) {
241
+ return;
242
+ }
243
+
244
+ const directory = servingHeartbeatDir(this.forwardingDir);
245
+ if (
246
+ !ensureDirectoryExists(
247
+ this.logger,
248
+ directory,
249
+ "permission forwarding serving heartbeat",
250
+ )
251
+ ) {
252
+ return;
253
+ }
254
+ this.sweepDeadRecordsOnce();
255
+
256
+ const heartbeat: ServingHeartbeat = {
257
+ sessionId,
258
+ pid: this.pid,
259
+ updatedAt: at,
260
+ };
261
+ try {
262
+ writeJsonFileAtomic(
263
+ this.logger,
264
+ servingHeartbeatPath(this.forwardingDir, sessionId),
265
+ heartbeat,
266
+ );
267
+ } catch (error) {
268
+ logPermissionForwardingError(
269
+ this.logger,
270
+ `Failed to publish the serving heartbeat for session '${sessionId}'`,
271
+ error,
272
+ );
273
+ return;
274
+ }
275
+ this.published = { sessionId, at };
276
+ }
277
+
278
+ /** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
279
+ clearServing(sessionId: string): void {
280
+ if (this.published?.sessionId === sessionId) {
281
+ this.published = null;
282
+ }
283
+ safeDeleteFile(
284
+ this.logger,
285
+ servingHeartbeatPath(this.forwardingDir, sessionId),
286
+ "permission forwarding serving heartbeat",
287
+ );
288
+ }
289
+
290
+ /** How `sessionId`'s heartbeat reads right now. */
291
+ read(sessionId: string): HeartbeatState {
292
+ const record = this.readRecord(
293
+ servingHeartbeatPath(this.forwardingDir, sessionId),
294
+ );
295
+ return record === null ? "absent" : this.classify(record);
296
+ }
297
+
298
+ /** Every session whose record reads as alive. */
299
+ servingIds(): readonly string[] {
300
+ const ids: string[] = [];
301
+ for (const { record } of this.listRecords()) {
302
+ if (record !== null && this.classify(record) === "alive") {
303
+ ids.push(record.sessionId);
304
+ }
305
+ }
306
+ return ids;
307
+ }
308
+
309
+ // ── Private methods ────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Delete the records of processes that are provably gone, once per session.
313
+ *
314
+ * Without this the directory grows one record per session that was killed
315
+ * rather than shut down, forever. Bounded to a single directory read at the
316
+ * first announcement, and safe under pid reuse: a wrongly swept owner
317
+ * republishes within the refresh window, which is shorter than the grace a
318
+ * forwarding child waits out.
319
+ *
320
+ * Only a dead pid is proof. A record that is merely stale belongs to a
321
+ * process that still exists, and the reader already reports it as stale
322
+ * without anyone having to remove it.
323
+ */
324
+ private sweepDeadRecordsOnce(): void {
325
+ if (this.hasSweptDeadRecords) {
326
+ return;
327
+ }
328
+ this.hasSweptDeadRecords = true;
329
+ for (const { path, record } of this.listRecords()) {
330
+ if (record !== null && this.isProcessAlive(record.pid)) {
331
+ continue;
332
+ }
333
+ safeDeleteFile(
334
+ this.logger,
335
+ path,
336
+ "abandoned permission forwarding serving heartbeat",
337
+ );
338
+ }
339
+ }
340
+
341
+ /** Every published record, paired with its path; unusable ones read as `null`. */
342
+ private listRecords(): {
343
+ path: string;
344
+ record: ServingHeartbeat | null;
345
+ }[] {
346
+ const directory = servingHeartbeatDir(this.forwardingDir);
347
+ let names: string[];
348
+ try {
349
+ names = readdirSync(directory);
350
+ } catch {
351
+ return [];
352
+ }
353
+ return names
354
+ .filter((name) => name.endsWith(".json"))
355
+ .map((name) => {
356
+ const path = join(directory, name);
357
+ return { path, record: this.readRecord(path) };
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Read a record, or `null` when it is missing or unusable.
363
+ *
364
+ * Silent by design: a forwarding child calls this on every poll tick, so a
365
+ * warning per unreadable read would flood the review log at four lines a
366
+ * second. The unusability is already reported once, as the `absent` state on
367
+ * the abandonment entry.
368
+ */
369
+ private readRecord(path: string): ServingHeartbeat | null {
370
+ try {
371
+ return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /** Which of the four states a well-formed record is in. */
378
+ private classify(record: ServingHeartbeat): HeartbeatState {
379
+ if (!this.isProcessAlive(record.pid)) {
380
+ return "dead_pid";
381
+ }
382
+ return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
383
+ ? "stale"
384
+ : "alive";
385
+ }
386
+
387
+ /**
388
+ * Whether the record on disk is recent enough to leave alone.
389
+ *
390
+ * Time alone, with no existence probe: an existence check would cost a
391
+ * syscall on every poll tick to save at most one refresh window, and a record
392
+ * removed underneath its owner reappears inside the grace window anyway.
393
+ */
394
+ private isThrottled(sessionId: string, at: number): boolean {
395
+ return (
396
+ this.published !== null &&
397
+ this.published.sessionId === sessionId &&
398
+ at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
399
+ );
400
+ }
401
+ }
402
+
403
+ // ── Module-private helpers ────────────────────────────────────────────────
404
+
405
+ /**
406
+ * Narrow a parsed record, or `undefined`.
407
+ *
408
+ * `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
409
+ * the caller's own process group and `kill(-n)` a foreign one, so a malformed
410
+ * record must be rejected before it can reach the liveness probe.
411
+ */
412
+ function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
413
+ if (typeof value !== "object" || value === null) {
414
+ return null;
415
+ }
416
+ const candidate = value as Partial<ServingHeartbeat>;
417
+ if (
418
+ typeof candidate.sessionId !== "string" ||
419
+ candidate.sessionId.length === 0 ||
420
+ typeof candidate.pid !== "number" ||
421
+ !Number.isInteger(candidate.pid) ||
422
+ candidate.pid <= 0 ||
423
+ typeof candidate.updatedAt !== "number" ||
424
+ !Number.isFinite(candidate.updatedAt)
425
+ ) {
426
+ return null;
427
+ }
428
+ return {
429
+ sessionId: candidate.sessionId,
430
+ pid: candidate.pid,
431
+ updatedAt: candidate.updatedAt,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Whether `pid` names a running process.
437
+ *
438
+ * Signal `0` performs the permission and existence checks without delivering
439
+ * anything. `EPERM` means the process exists under another user — reported as
440
+ * alive, the direction that falls back to the timeout rather than abandoning a
441
+ * request someone may still answer.
442
+ */
443
+ function isRunningProcess(pid: number): boolean {
444
+ try {
445
+ process.kill(pid, 0);
446
+ return true;
447
+ } catch (error) {
448
+ return isErrnoCode(error, "EPERM");
449
+ }
450
+ }
@@ -65,6 +65,12 @@ export class ForwardingManager {
65
65
  return;
66
66
  }
67
67
  this.timer = setInterval(() => {
68
+ // Ahead of the processing guard: a session whose human is deliberating at
69
+ // a forwarded dialog holds `processInbox` open for as long as they take,
70
+ // and it is serving throughout. Refreshing behind the guard would let its
71
+ // announcement decay exactly when it is most demonstrably alive, and
72
+ // every other forwarding child would give up on it.
73
+ this.refreshServing();
68
74
  if (!this.context || this.processing) {
69
75
  return;
70
76
  }
@@ -107,6 +113,21 @@ export class ForwardingManager {
107
113
  });
108
114
  }
109
115
 
116
+ /**
117
+ * Re-announce the served session, keeping a decayable channel current.
118
+ *
119
+ * Separate from {@link announceServing} because that one detects a change to
120
+ * write its log line, and this one deliberately writes none — four review
121
+ * entries a second would drown the log the announcement exists to make
122
+ * readable.
123
+ */
124
+ private refreshServing(): void {
125
+ if (this.servingSessionId === null) {
126
+ return;
127
+ }
128
+ this.deps.serving.markServing(this.servingSessionId);
129
+ }
130
+
110
131
  /** Withdraw the published session, if any. */
111
132
  private withdrawServing(): void {
112
133
  const sessionId = this.servingSessionId;
@@ -201,7 +201,15 @@ export function normalizePermissionForwardingSessionId(
201
201
  return trimmed;
202
202
  }
203
203
 
204
- function encodeSessionIdForPath(sessionId: string): string {
204
+ /**
205
+ * Make a session id safe to name a path segment.
206
+ *
207
+ * Exported because the forwarding tree has two layouts keyed by session id —
208
+ * `sessions/<id>/` and the serving-heartbeat records beside it — and a second
209
+ * encoding would be a silent way for the two to disagree about which file
210
+ * belongs to which session.
211
+ */
212
+ export function encodeSessionIdForPath(sessionId: string): string {
205
213
  return encodeURIComponent(sessionId);
206
214
  }
207
215
 
@@ -35,10 +35,43 @@ export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
35
35
  * neither reads the store nor gains a query it has no business making (ISP).
36
36
  */
37
37
  export interface ServingAnnouncer {
38
+ /**
39
+ * Record that `sessionId` is polling its inbox.
40
+ *
41
+ * Idempotent, and called on every poll tick rather than once per session: an
42
+ * announcement that can decay (the filesystem heartbeat) has to be kept
43
+ * current, and one that cannot (this registry) costs a set insertion to say
44
+ * so again.
45
+ */
38
46
  markServing(sessionId: string): void;
39
47
  clearServing(sessionId: string): void;
40
48
  }
41
49
 
50
+ /**
51
+ * Fan an announcement out to every channel a serving session publishes on.
52
+ *
53
+ * A session announces to the process-global registry (for its in-process
54
+ * children) and to the filesystem (for children in other processes). Composing
55
+ * them keeps `ForwardingManager` holding one collaborator, so adding or
56
+ * removing a channel never reaches the poll loop.
57
+ */
58
+ export function composeServingAnnouncers(
59
+ ...announcers: readonly ServingAnnouncer[]
60
+ ): ServingAnnouncer {
61
+ return {
62
+ markServing(sessionId: string): void {
63
+ for (const announcer of announcers) {
64
+ announcer.markServing(sessionId);
65
+ }
66
+ },
67
+ clearServing(sessionId: string): void {
68
+ for (const announcer of announcers) {
69
+ announcer.clearServing(sessionId);
70
+ }
71
+ },
72
+ };
73
+ }
74
+
42
75
  /**
43
76
  * Query-side seam: a forwarding child asks whether its target is draining.
44
77
  *
package/src/index.ts CHANGED
@@ -8,11 +8,18 @@ import {
8
8
  ForwardedRequestServer,
9
9
  type ServingPolicy,
10
10
  } from "./authority/forwarded-request-server";
11
+ import {
12
+ ForwardingLivenessJudge,
13
+ ServingHeartbeatStore,
14
+ } from "./authority/forwarding-liveness";
11
15
  import { ForwardingManager } from "./authority/forwarding-manager";
12
16
  import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
13
17
  import { requestPermissionDecision } from "./authority/permission-prompt-component";
14
18
  import { PermissionPrompter } from "./authority/permission-prompter";
15
- import { getServingSessionRegistry } from "./authority/serving-registry";
19
+ import {
20
+ composeServingAnnouncers,
21
+ getServingSessionRegistry,
22
+ } from "./authority/serving-registry";
16
23
  import { SubagentDetection } from "./authority/subagent-detection";
17
24
  import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
18
25
  import { getSubagentSessionRegistry } from "./authority/subagent-registry";
@@ -111,6 +118,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
111
118
 
112
119
  const prompter = new PermissionPrompter({ logger });
113
120
 
121
+ // The filesystem half of the serving announcement. `servingRegistry` reaches
122
+ // an in-process child through `globalThis`; a child in its own process shares
123
+ // nothing but this directory, so the served session publishes a heartbeat
124
+ // there too (#721).
125
+ const servingHeartbeats = new ServingHeartbeatStore({
126
+ forwardingDir: paths.forwardingDir,
127
+ logger,
128
+ });
129
+ // The read side of both channels, routed by how the target was resolved.
130
+ const servingLiveness = new ForwardingLivenessJudge({
131
+ registry: servingRegistry,
132
+ heartbeats: servingHeartbeats,
133
+ });
134
+
114
135
  const authorizerSelection = new AuthorizerSelection({
115
136
  detection: subagentDetection,
116
137
  events: pi.events,
@@ -121,7 +142,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
121
142
  requestPermissionDecision,
122
143
  forwardingDir: paths.forwardingDir,
123
144
  registry: subagentRegistry,
124
- servingRegistry,
145
+ serving: servingLiveness,
125
146
  getForwardingTimeoutMs: () =>
126
147
  configStore.current().forwardingTimeoutMs ??
127
148
  PERMISSION_FORWARDING_TIMEOUT_MS,
@@ -177,7 +198,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
177
198
  new ForwardingManager({
178
199
  detection: subagentDetection,
179
200
  forwarder: requestServer,
180
- serving: servingRegistry,
201
+ serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
181
202
  logger,
182
203
  }),
183
204
  permissionManager,