feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,464 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
getActiveAgentName,
getActiveAgentNameFromSystemPrompt,
} from "#src/active-agent";
import {
type ForwarderContext,
getCwd,
getSessionId,
} from "#src/authority/forwarder-context";
import {
cleanupPermissionForwardingLocationIfEmpty,
ensurePermissionForwardingLocation,
logPermissionForwardingError,
logPermissionForwardingWarning,
readForwardedPermissionResponse,
safeDeleteFile,
sleep,
writeJsonFileAtomic,
} from "#src/authority/forwarding-io";
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import {
type ForwardedAccessFacts,
type ForwardedPermissionRequest,
type ForwardedPermissionResponse,
type ForwardedPromptDisplay,
type ForwardedSessionApproval,
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
PERMISSION_FORWARDING_SERVING_GRACE_MS,
type PermissionForwardingLocation,
type PermissionForwardingTarget,
resolvePermissionForwardingTarget,
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
} from "#src/authority/permission-forwarding";
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import { createPermissionRequestId } from "#src/permission-request-id";
import { buildUiPrompt } from "#src/permission-ui-prompt";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import type { DebugReviewLogger } from "#src/session-logger";
import { toRecord } from "#src/value-guards";
import type { TerminalAuthorizer } from "./authorizer";
import type { PromptPermissionDetails } from "./permission-prompter";
// ── Module-private helpers ────────────────────────────────────────────────
function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
if (typeof getSystemPrompt !== "function") {
return undefined;
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
const systemPrompt = getSystemPrompt.call(ctx);
return typeof systemPrompt === "string" ? systemPrompt : undefined;
} catch (error) {
// No deps available in this helper — warning silently dropped.
logPermissionForwardingWarning(
null,
"Failed to read context system prompt for forwarded permission metadata",
error,
);
return undefined;
}
}
// ── ParentAuthorizer ────────────────────────────────────────────────────
/**
* The facts a forwarded request relays unchanged from the child's ask: the
* prompt payload, the optional display projection, and the optional
* session-approval suggestion.
*
* Bundled into one object so the two-hop private chain
* (`waitForForwardedApproval` → `buildForwardedRequest`) threads a single
* relayed value instead of three positional optionals.
*/
interface ForwardedRequestFacts {
/**
* The requester's own permission request id, adopted as the forwarded
* request's id so one id runs from the child's gate to the serving node's
* decision instead of a third being minted here.
*/
requestId: string;
/** The child's complete prompt payload, relayed for the serving node to render. */
payload: PromptPayload;
display?: ForwardedPromptDisplay;
sessionApproval?: ForwardedSessionApproval;
/** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
accessIntent?: ForwardedAccessFacts;
}
/** Constructor config for {@link ParentAuthorizer}. */
export interface ParentAuthorizerDeps {
forwardingDir: string;
/** In-process subagent session registry for forwarding target resolution. */
registry?: SubagentSessionRegistry;
/** Whether the resolved target is draining its inbox, on whichever channel can say. */
serving: TargetServingLookup;
/** How long to wait for the target's answer, read live so config edits apply. */
getTimeoutMs: () => number;
logger: DebugReviewLogger;
}
/**
* Deny because no authority ever ruled — the request was never delivered,
* never answered, or answered unreadably.
*
* `confirmationUnavailable` is what keeps this out of the "User denied …"
* message (#719): a user who was never asked denied nothing. `denialReason`
* names which path gave up, and the gate renders it to the model.
*
* The provenance record reuses that same string rather than restating it, so
* what the model is told and what the log attributes cannot drift (#726).
*/
function abandon(denialReason: string): PermissionPromptDecision {
return {
approved: false,
state: "denied",
confirmationUnavailable: true,
denialReason,
decidedBy: { kind: "unavailable", reason: denialReason },
};
}
/**
* Adopt the responder's answer, recording the hop it came through.
*
* The requester's own terminal entry has to answer two questions, and they are
* different: *which session* answered, and *what within it* decided. Nesting
* keeps both rather than flattening the responder's source into this node's
* record, where it would read as a local decision (#726).
*
* A responder that sent no usable source yields `decision: null` — the hop is
* still a fact, and an older parent is not an error.
*/
function relayDecision(
response: ForwardedPermissionResponse,
): PermissionPromptDecision {
return {
...response,
decidedBy: {
kind: "forwarded",
responderSessionId: response.responderSessionId,
decision: response.decidedBy ?? null,
},
};
}
/** Ids this node is willing to use as a request/response filename. */
const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
/**
* The id to write on the forwarded request: the requester's own, or a fresh
* mint when that id could not safely name a file.
*
* At a relay hop the adopted id came from a request file on disk, which the
* tolerant reader validates only as a string — so this is the boundary that
* keeps an inbound id from choosing an outbound path.
*/
function forwardableRequestId(requesterRequestId: string): string {
return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
? requesterRequestId
: createPermissionRequestId();
}
/**
* Authorizer for a subagent session: escalate the ask up the tree to the
* parent's authority.
*
* Owns the escalation-up role of the forwarded-permission behavior: builds
* and persists a request file, then polls for the parent session's
* response. `ctx` is bound once at construction — `selectAuthorizer` only
* constructs a `ParentAuthorizer` for a context it has already confirmed has
* no UI and is a subagent, so `authorize` never re-derives that dispatch
* (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
* arms, both dead once every caller routes through `selectAuthorizer`).
*/
export class ParentAuthorizer implements TerminalAuthorizer {
private readonly forwardingDir: string;
private readonly registry: SubagentSessionRegistry | undefined;
private readonly serving: TargetServingLookup;
private readonly getTimeoutMs: () => number;
private readonly logger: DebugReviewLogger;
constructor(
private readonly ctx: ForwarderContext,
deps: ParentAuthorizerDeps,
) {
this.forwardingDir = deps.forwardingDir;
this.registry = deps.registry;
this.serving = deps.serving;
this.getTimeoutMs = deps.getTimeoutMs;
this.logger = deps.logger;
}
authorize(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
const uiPrompt = buildUiPrompt(details);
return this.waitForForwardedApproval(this.ctx, {
requestId: details.requestId,
payload: details.payload,
display: {
source: uiPrompt.source,
surface: uiPrompt.surface,
value: uiPrompt.value,
},
sessionApproval: details.sessionApproval,
accessIntent: details.accessIntent,
});
}
// ── Private methods ────────────────────────────────────────────────────
private async waitForForwardedApproval(
ctx: ForwarderContext,
facts: ForwardedRequestFacts,
): Promise<PermissionPromptDecision> {
const requesterSessionId = getSessionId(ctx);
const target = resolvePermissionForwardingTarget({
hasUI: ctx.hasUI,
// Invariant: selectAuthorizer only selects ParentAuthorizer for a
// no-UI subagent context, so this is always true — no detection dep
// needed to re-derive it here.
isSubagent: true,
currentSessionId: requesterSessionId,
env: process.env,
sessionId: requesterSessionId,
registry: this.registry,
});
if (!target) {
logPermissionForwardingError(
this.logger,
`Permission forwarding target session could not be resolved. ` +
`Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
`If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
`ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
`(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
);
return abandon(
"Could not resolve a parent session to forward this permission request to",
);
}
const location = ensurePermissionForwardingLocation(
this.logger,
this.forwardingDir,
target.sessionId,
);
if (!location) {
logPermissionForwardingError(
this.logger,
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${target.sessionId}'`,
);
return abandon(
`Permission forwarding directories could not be prepared for session '${target.sessionId}'`,
);
}
const request = this.buildForwardedRequest(
ctx,
facts,
requesterSessionId,
target.sessionId,
);
const requestPath = join(location.requestsDir, `${request.id}.json`);
const responsePath = join(location.responsesDir, `${request.id}.json`);
this.logger.review("forwarded_permission.request_created", {
requestId: request.id,
requesterAgentName: request.requesterAgentName,
requesterSessionId: request.requesterSessionId,
targetSessionId: target.sessionId,
requestPath,
responsePath,
});
try {
writeJsonFileAtomic(this.logger, requestPath, request);
} catch (error) {
logPermissionForwardingError(
this.logger,
`Failed to write forwarded permission request '${requestPath}'`,
error,
);
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
return abandon("The forwarded permission request could not be written");
}
return this.pollForForwardedResponse(
location,
request,
requestPath,
responsePath,
target,
);
}
private buildForwardedRequest(
ctx: ForwarderContext,
facts: ForwardedRequestFacts,
requesterSessionId: string,
targetSessionId: string,
): ForwardedPermissionRequest {
const requestId = forwardableRequestId(facts.requestId);
const requesterAgentName =
getActiveAgentName(ctx) ??
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
"unknown";
// Complete the child-fixed facts into a full ForwardedAccessIntent: the
// gate fixed the access facts; the edge stamps the requester identity it
// alone knows (cwd + principal). The parent resolves against this intent
// and never re-derives the match set (ADR 0008).
const accessIntent = facts.accessIntent
? {
...facts.accessIntent,
requesterCwd: getCwd(ctx),
principal: {
sessionId: requesterSessionId,
agentName: requesterAgentName,
},
}
: undefined;
return {
id: requestId,
createdAt: Date.now(),
requesterSessionId,
targetSessionId,
requesterAgentName,
payload: facts.payload,
...(facts.display
? {
source: facts.display.source,
surface: facts.display.surface,
value: facts.display.value,
}
: {}),
...(facts.sessionApproval
? { sessionApproval: facts.sessionApproval }
: {}),
...(accessIntent ? { accessIntent } : {}),
};
}
private async pollForForwardedResponse(
location: PermissionForwardingLocation,
request: ForwardedPermissionRequest,
requestPath: string,
responsePath: string,
target: PermissionForwardingTarget,
): Promise<PermissionPromptDecision> {
const { id: requestId, requesterAgentName, targetSessionId } = request;
const timeoutMs = this.getTimeoutMs();
const deadline = Date.now() + timeoutMs;
let unservedSince: number | null = null;
while (Date.now() < deadline) {
if (existsSync(responsePath)) {
const response = readForwardedPermissionResponse(
this.logger,
responsePath,
);
const relayed = response ? relayDecision(response) : null;
this.logger.review("forwarded_permission.response_received", {
requestId,
approved: response?.approved ?? null,
state: response?.state ?? null,
denialReason: response?.denialReason ?? null,
responderSessionId: response?.responderSessionId ?? null,
targetSessionId,
responsePath,
decidedBy: relayed?.decidedBy,
});
this.discardRequest(location, requestPath, responsePath);
return (
relayed ??
abandon("The parent session's permission response could not be read")
);
}
unservedSince = this.checkServingLiveness(target, unservedSince);
if (
unservedSince !== null &&
Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
) {
const observation = this.serving.describe(target);
this.logger.review("forwarded_permission.no_serving_session", {
requestId,
requesterSessionId: request.requesterSessionId,
targetSessionId,
// Which channel answered, and what it saw: the difference between a
// parent that exited, one that was killed, and one polling under a
// different session id is the whole diagnosis of a stalled forward.
servingChannel: observation.channel,
servingState: observation.state,
servingSessionIds: observation.servingIds,
});
this.discardRequest(location, requestPath);
return abandon(
`Session '${target.sessionId}' is not serving forwarded permission requests`,
);
}
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
}
logPermissionForwardingWarning(
this.logger,
`Timed out waiting for forwarded permission response '${responsePath}'`,
);
this.logger.review("forwarded_permission.response_timed_out", {
requestId,
requesterAgentName,
targetSessionId,
responsePath,
});
this.discardRequest(location, requestPath);
return abandon(
`Session '${target.sessionId}' did not answer within ${timeoutMs / 1000}s`,
);
}
/**
* Track how long the target has looked unserved, or `null` while it looks fine.
*
* Which channel can answer for this target is the judge's decision, not this
* one's: a target it cannot judge answers `null`, which resets the window
* exactly as "serving" does, so an unjudgeable target waits out the timeout.
*/
private checkServingLiveness(
target: PermissionForwardingTarget,
unservedSince: number | null,
): number | null {
return this.serving.isServing(target) === false
? (unservedSince ?? Date.now())
: null;
}
/**
* Drop this exchange's files and, if nothing else is pending, its directories.
*
* Deleting the request is what makes an abandonment final: a request left
* behind would be answered by the parent long after the child gave up.
*/
private discardRequest(
location: PermissionForwardingLocation,
requestPath: string,
responsePath?: string,
): void {
if (responsePath) {
safeDeleteFile(
this.logger,
responsePath,
"forwarded permission response",
);
}
safeDeleteFile(this.logger, requestPath, "forwarded permission request");
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
}
}
@@ -0,0 +1,90 @@
import type { DecisionSource } from "#src/authority/decision-source";
import type { AuthorizerLog, PermissionQuery } from "#src/service";
import type {
AuthorizerVerdict,
NamedAuthorizer,
TerminalAuthorizer,
} from "./authorizer";
import {
createDeniedPermissionDecision,
type PermissionPromptDecision,
} from "./permission-dialog";
/**
* Compose the live-authority chain (ADR 0007): try each non-terminal `link`
* in order, and on `defer` fall through to the next link, ending at the
* context-selected `terminal` that always decides.
*
* The signature is the type-level terminal-cannot-defer invariant: `links` are
* deferring {@link NamedAuthorizer}s while `terminal` is a
* {@link TerminalAuthorizer} (returns a full decision), so a deferring link
* cannot occupy the terminal slot.
*
* Each link is handed the session-scoped `query` and the review-log `log` at
* `authorize` time (ADR 0007 §3) so it queries the deterministic engine at gate
* parity and records its decision trail; the terminal receives neither. With
* zero links the composed chain **is** the terminal instance (identity), so
* behavior is byte-identical to the pre-chain spine — the empty-links case that
* ships until a link registers.
*/
export function composeAuthorizerChain(
links: readonly NamedAuthorizer[],
terminal: TerminalAuthorizer,
query: PermissionQuery,
log: AuthorizerLog,
): TerminalAuthorizer {
if (links.length === 0) {
return terminal;
}
return {
async authorize(details) {
for (const link of links) {
const verdict = await link.authorize(details, query, log);
const decision = decideFromVerdict(link.name, verdict);
if (decision) {
return decision;
}
// `defer` \u2014 try the next link.
}
return terminal.authorize(details);
},
};
}
/**
* Map a link's decisive verdict to a decision; `defer` yields `null`.
*
* The deciding link is named on the decision, not merely counted among the
* consulted set the selection already records: a link ahead of it that
* deferred decided nothing and must not be credited (#726).
*/
function decideFromVerdict(
name: string,
verdict: AuthorizerVerdict,
): PermissionPromptDecision | null {
switch (verdict.kind) {
case "allow":
// A link grant is non-persistent (state `approved`, never
// `approved_for_session`), per ADR 0007's off-by-default envelope.
return {
approved: true,
state: "approved",
decidedBy: decidedByLink(name, "allow", null),
};
case "deny":
return {
...createDeniedPermissionDecision(verdict.reason),
decidedBy: decidedByLink(name, "deny", verdict.reason ?? null),
};
case "defer":
return null;
}
}
function decidedByLink(
name: string,
verdict: "allow" | "deny",
reason: string | null,
): DecisionSource {
return { kind: "authorizer", name, verdict, reason };
}
@@ -0,0 +1,69 @@
/**
* Registry for named live-authority chain links (ADR 0007 §4).
*
* A downstream extension offers a named `Authorizer` link via
* `PermissionsService.registerAuthorizer`; this registry stores the link's
* `authorize` callback so composition can bind names to capabilities. One link
* per name; duplicate registration throws.
*
* Registration alone grants no authority — a link decides nothing until the
* operator names it in the `authorizerChain` config (the opt-in activation
* model). `AuthorizerSelection` owns that config-order resolution; this registry
* is storage only.
*/
import type { Authorizer } from "./authorizer";
/**
* Read-only lookup used by chain composition (ISP — exposes only the read side,
* not the registration surface).
*/
export interface AuthorizerLookup {
get(name: string): Authorizer["authorize"] | undefined;
}
/**
* Registration side of the registry (ISP — exposes only the write surface,
* mirroring the read-only {@link AuthorizerLookup}).
*/
export interface AuthorizerRegistrar {
register(name: string, authorize: Authorizer["authorize"]): () => void;
}
/**
* Persistent registry mapping link names to their `authorize` callbacks.
*
* Owned by the extension factory (`index.ts`) so it survives across session
* activations. Exposed to sibling extensions via
* `PermissionsService.registerAuthorizer` and consulted by
* `AuthorizerSelection` during chain resolution.
*/
export class AuthorizerRegistry
implements AuthorizerLookup, AuthorizerRegistrar
{
private readonly links = new Map<string, Authorizer["authorize"]>();
/**
* Register a link under `name`.
*
* Throws if a link is already registered for that name — keeps resolution
* deterministic (a pi-permission-system package priority). Returns a disposer
* that removes the link; the disposer is identity-guarded so a stale call
* cannot evict a later registration.
*/
register(name: string, authorize: Authorizer["authorize"]): () => void {
if (this.links.has(name)) {
throw new Error(`An authorizer is already registered for '${name}'.`);
}
this.links.set(name, authorize);
return () => {
if (this.links.get(name) === authorize) {
this.links.delete(name);
}
};
}
get(name: string): Authorizer["authorize"] | undefined {
return this.links.get(name);
}
}
@@ -0,0 +1,184 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { PermissionQuery } from "#src/service";
import {
type AuthorizerSelectionDeps,
type NamedAuthorizer,
type SelectedAuthority,
selectAuthorizer,
} from "./authorizer";
import { composeAuthorizerChain } from "./authorizer-chain";
import type { AuthorizerLookup } from "./authorizer-registry";
import { encloseInDelegationEnvelope } from "./delegation-envelope";
import type {
PermissionPrompterApi,
PromptPermissionDetails,
} from "./permission-prompter";
/**
* The lifecycle slice of the selection owner that PermissionSession drives.
*
* PermissionSession calls activate/deactivate to keep the selection's stored
* context in sync with its own — the same pattern the former
* PromptingGatewayLifecycle used.
*/
export interface AuthorizerSelectionLifecycle {
activate(ctx: ExtensionContext): void;
deactivate(): void;
}
/**
* The ask-escalation seam `GateRunner` depends on: escalate a single ask to
* the session's selected `Authorizer` and return its decision.
*
* Replaces the two-method `GatePrompter` role (#556). There is no
* "can anyone answer" pre-check: absent authority is the `DenyingAuthorizer`,
* which answers by denying with a `confirmationUnavailable` marker.
*/
export interface AskEscalator {
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
}
/**
* Context-owning selection root for the Authorizer spine.
*
* The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
* `selectAuthorizer` once per activation, and implements `AskEscalator` by
* delegating to the selected `Authorizer` via `PermissionPrompter`.
*
* `selectAuthorizer` encodes the liveness decision in *which* `Authorizer` it
* returns (`LocalUserAuthorizer` / `ParentAuthorizer` when authority is
* reachable, `DenyingAuthorizer` otherwise), so no separate confirmability
* predicate survives (#556 dissolved `canConfirm()`).
*/
export class AuthorizerSelection
implements AskEscalator, AuthorizerSelectionLifecycle
{
private authority: SelectedAuthority | null = null;
constructor(
private readonly deps: AuthorizerSelectionDeps & {
prompter: PermissionPrompterApi;
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
getPermissionQuery: () => PermissionQuery;
/** Read-only lookup of registered links by name. */
authorizerRegistry: AuthorizerLookup;
/** The operator's configured link names, read live per ask. */
getAuthorizerChain: () => string[];
},
) {}
/**
* Select the live authority for `ctx` and store it. The non-terminal
* chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
* link register in a `permissions:ready` handler that may fire after
* activation, so link resolution is deferred to the session's first ask.
*/
activate(ctx: ExtensionContext): void {
this.authority = selectAuthorizer(ctx, this.deps);
}
/**
* The chain links for this ask.
*
* A node that adjudicates locally resolves its configured names; a relaying
* node resolves none. Its terminal hands the ask to a serving node, which
* resolves the request against its own recorded authority and escalates it
* through *its* chain over the same child-fixed facts (#635) — so running
* links here would adjudicate one ask twice, and a relaying node cannot host
* a link in the first place (#699). The delegation is recorded rather than
* reported as a fail-safe skip: an absent link is the design here, not the
* misconfiguration `authorizer_chain_unregistered_link` exists to surface.
*/
private linksFor(
authority: SelectedAuthority,
requestId: string,
): NamedAuthorizer[] {
const configured = this.deps.getAuthorizerChain();
if (configured.length === 0) {
return [];
}
if (!authority.adjudicatesLocally) {
this.deps.logger.review("authorizer_chain_delegated", {
requestId,
links: configured,
});
return [];
}
return this.resolveConfiguredLinks(configured, requestId);
}
/**
* Resolve the operator's `authorizerChain` names to registered links, in
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a
* warning (invariant 2 — more prompting, never less); each resolved link is
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
* surface cannot exceed the operator's policy.
*
* The resolved names are recorded against the ask before any link runs — a
* link that defers decides nothing and would otherwise leave no evidence it
* was consulted at all, which is what makes "the judge never ran" and "the
* judge ran and deferred" indistinguishable in the review log.
*/
private resolveConfiguredLinks(
configured: readonly string[],
requestId: string,
): NamedAuthorizer[] {
const links: NamedAuthorizer[] = [];
const resolved: string[] = [];
for (const name of configured) {
const authorize = this.deps.authorizerRegistry.get(name);
if (authorize === undefined) {
this.deps.logger.review("authorizer_chain_unregistered_link", {
requestId,
name,
});
continue;
}
resolved.push(name);
links.push({ name, authorize: encloseInDelegationEnvelope(authorize) });
}
if (resolved.length > 0) {
this.deps.logger.review("authorizer_chain_resolved", {
requestId,
links: resolved,
});
}
return links;
}
/** Clear the stored selection. */
deactivate(): void {
this.authority = null;
}
/**
* Escalate an ask through the composed chain and return its decision.
*
* Resolves this ask's links freshly (so a link registered any time before
* this first ask is honored) and composes them ahead of the selected
* terminal. With zero links — no chain configured, or a relaying node that
* delegates adjudication to the serving node — the composed value **is** the
* terminal instance, so behavior is identical to a bare terminal escalation.
*
* Rejects if no terminal has been selected — i.e. before the session was
* activated. Implements {@link AskEscalator}.
*/
escalate(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
const authority = this.authority;
if (authority === null) {
return Promise.reject(
new Error("escalate called before the session was activated"),
);
}
const chain = composeAuthorizerChain(
this.linksFor(authority, details.requestId),
authority.terminal,
this.deps.getPermissionQuery(),
this.deps.logger,
);
return this.deps.prompter.prompt(chain, details);
}
}
@@ -0,0 +1,156 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type {
PromptPreferences,
requestPermissionDecision,
} from "#src/authority/permission-prompt-component";
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import type { PermissionEventBus } from "#src/permission-events";
import type { AuthorizerLog, PermissionQuery } from "#src/service";
import type { DebugReviewLogger } from "#src/session-logger";
import { ParentAuthorizer } from "./approval-escalator";
import { DenyingAuthorizer } from "./denying-authorizer";
import { LocalUserAuthorizer } from "./local-user-authorizer";
import type { PromptPermissionDetails } from "./permission-prompter";
import type { SubagentDetector } from "./subagent-detection";
/**
* A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
* pass the ask on to the next link (`defer`). A `deny` carries an optional
* teaching `reason` the invoking model sees, so it can self-correct.
*/
export type AuthorizerVerdict =
| { kind: "allow" }
| { kind: "deny"; reason?: string }
| { kind: "defer" };
/**
* A non-terminal link in the live-authority chain: reviews an `ask` and may
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
* queries the deterministic engine at gate parity rather than reaching for the
* cross-extension service via `Symbol.for()`. It also injects an
* {@link AuthorizerLog} so a link can record its decision trail to the shared
* permission review log (same §3 injection pattern).
*/
export interface Authorizer {
authorize(
details: PromptPermissionDetails,
query: PermissionQuery,
log: AuthorizerLog,
): Promise<AuthorizerVerdict>;
}
/**
* A resolved chain link together with the operator-configured name it came
* from.
*
* `AuthorizerRegistry` already keys links by name, and `AuthorizerSelection`
* has the name in scope when it resolves the operator's `authorizerChain`; the
* name is carried through composition so a decision record can say *which*
* link decided rather than only which links were consulted.
*/
export interface NamedAuthorizer extends Authorizer {
readonly name: string;
}
/**
* The terminal link: on `ask`, rules on a single request and is told the
* decision. Structurally cannot defer — it always returns a full
* {@link PermissionPromptDecision}, which is the type-level enforcement of
* ADR 0007's terminal-cannot-defer invariant.
*
* One method, one responsibility. `DenyingAuthorizer` ignores `details`;
* `LocalUserAuthorizer` renders `payload` for the human and derives the UI
* event from the request facts; `ParentAuthorizer` ships `payload` over the
* wire so the serving node renders it under its own budget.
*/
export interface TerminalAuthorizer {
authorize(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
}
/**
* The node's live-authority selection: who decides this node's asks, and
* whether this node adjudicates them with its own chain.
*
* The chain role is the selection's product, not a discriminator a consumer
* re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
* subagent that has its own UI decides locally, and re-deriving the role from
* `detection.isSubagent(ctx)` alone would get that case wrong.
*/
export interface SelectedAuthority {
/** The terminal that decides this node's asks, or relays them upward. */
readonly terminal: TerminalAuthorizer;
/**
* False when the terminal relays the ask to a serving node
* (`ParentAuthorizer`): that node resolves the request against its own
* recorded authority and escalates it through *its* chain over the same
* child-fixed facts (#635), so resolving links here would adjudicate one ask
* twice.
*/
readonly adjudicatesLocally: boolean;
}
/** Construction inputs for {@link selectAuthorizer}. */
export interface AuthorizerSelectionDeps {
/** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
detection: SubagentDetector;
/** Event bus used by `LocalUserAuthorizer` for the `permissions:ui_prompt` broadcast. */
events: PermissionEventBus;
/** Read live at prompt time; threaded into `LocalUserAuthorizer`. */
getPromptPreferences: () => PromptPreferences;
/** Injected for testability; production callers pass the real function. */
requestPermissionDecision: typeof requestPermissionDecision;
/** Forwarding directory `ParentAuthorizer` reads/writes request and response files under. */
forwardingDir: string;
/** In-process subagent session registry for forwarding target resolution. */
registry?: SubagentSessionRegistry;
/** Whether a forwarding target is draining its inbox, on whichever channel can say. */
serving: TargetServingLookup;
/** The forwarding timeout, read live so a config edit applies to the next ask. */
getForwardingTimeoutMs: () => number;
logger: DebugReviewLogger;
}
/**
* Select the live authority for the current context: the single owner of the
* three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
* dispatch implies.
*
* Evaluated once per session activation (`AuthorizerSelection.activate`),
* replacing the re-derivation of the same predicates across
* `PromptingGateway`, `PermissionPrompter`, and `ApprovalEscalator`.
*/
export function selectAuthorizer(
ctx: ExtensionContext,
deps: AuthorizerSelectionDeps,
): SelectedAuthority {
if (ctx.hasUI) {
return {
terminal: new LocalUserAuthorizer({
ui: ctx.ui,
mode: ctx.mode,
events: deps.events,
getPromptPreferences: deps.getPromptPreferences,
requestPermissionDecision: deps.requestPermissionDecision,
}),
adjudicatesLocally: true,
};
}
if (deps.detection.isSubagent(ctx)) {
return {
terminal: new ParentAuthorizer(ctx, {
forwardingDir: deps.forwardingDir,
registry: deps.registry,
serving: deps.serving,
getTimeoutMs: deps.getForwardingTimeoutMs,
logger: deps.logger,
}),
adjudicatesLocally: false,
};
}
return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
}
@@ -0,0 +1,35 @@
/**
* Bracketed-paste normalization for the inline permission dialog's reason field.
*
* A terminal in bracketed-paste mode wraps pasted text in these markers, and
* the TUI hands the wrapped chunk to the focused component in a single call.
*/
const PASTE_START = "\u001b[200~";
const PASTE_END = "\u001b[201~";
const NEWLINE_RUN = /[\r\n]+/g;
/**
* Collapse newline runs inside a bracketed-paste chunk to single spaces.
*
* The framework line editor deletes newlines outright, which joins the words
* on either side of a line break; a reason pasted from a multi-line source
* should stay readable in the single-line field. The markers are preserved so
* the editor still recognizes the chunk as a paste, and anything that is not
* a complete paste chunk is returned unchanged.
*/
export function collapsePastedNewlines(data: string): string {
const start = data.indexOf(PASTE_START);
if (start === -1) {
return data;
}
const contentStart = start + PASTE_START.length;
const contentEnd = data.indexOf(PASTE_END, contentStart);
if (contentEnd === -1) {
return data;
}
const content = data
.slice(contentStart, contentEnd)
.replace(NEWLINE_RUN, " ");
return data.slice(0, contentStart) + content + data.slice(contentEnd);
}
@@ -0,0 +1,235 @@
/**
* What decided a permission request, recorded at the site that decided it.
*
* The decision paths are already distinct in the code — a session hit, a yolo
* grant, an infrastructure read, a config rule, a chain link, a human at a
* dialog, an unreachable authority — and each one knows what it is at the
* moment it decides. This is that fact, carried to the record instead of being
* discarded and re-guessed from an event name downstream.
*
* Every variant is **self-contained**: it repeats the detail that made it
* decisive rather than leaning on a sibling log column. That duplicates
* `surface` and the pattern on a local review line, and it is the only shape
* that survives the forwarding hop, where the response file has no such
* columns to lean on.
*/
/** Which human-facing surface the operator answered on. */
export type UserDecisionSurface = "dialog" | "select";
export type DecisionSource =
/** A human ruled, at the inline dialog or the `select`/`input` fallback. */
| { kind: "user"; via: UserDecisionSurface }
/** A registered `authorizerChain` link ruled; `name` is the configured name. */
| {
kind: "authorizer";
name: string;
verdict: "allow" | "deny";
reason: string | null;
}
/** Recorded authority: a rule in the composed ruleset matched. */
| {
kind: "rule";
surface: string;
pattern: string | null;
origin: string | null;
}
/** A session-scoped grant the operator made earlier in this session. */
| { kind: "session_approval"; surface: string; pattern: string | null }
/**
* `yoloMode`. `pattern` preserves the ask's matched rule — including a
* synthetic sentinel such as `<opaque-bash-wrapper>` — which is what makes a
* yolo grant over a synthesized ask legible.
*/
| { kind: "yolo"; pattern: string | null }
/** A Pi infrastructure read, allowed by containment rather than by a rule. */
| { kind: "infrastructure_read" }
/**
* No authority ever ruled: none was reachable, or the forwarding path gave
* up before reaching one. `reason` names which path gave up.
*/
| { kind: "unavailable"; reason: string }
/** A gate threw, and the boundary blocked rather than allowed. */
| { kind: "gate_error"; reason: string }
/**
* Another session decided. Recursive by design: the requesting side records
* both that the decider was elsewhere and what, within that session, decided
* — which is the distinction an audit of a forwarded ask needs.
*
* `decision` is `null` when the responder sent none (an older parent).
*/
| {
kind: "forwarded";
responderSessionId: string | null;
decision: DecisionSource | null;
};
/**
* How deep a `forwarded` chain may nest before {@link asDecisionSource} gives
* up.
*
* Forwarding is depth-1 by invariant (child → root) and a relay hop makes it
* two, so this is headroom rather than a working limit. It exists because the
* value is read off disk: a recursive reader over a file another process wrote
* is a stack-overflow surface, and the fail-closed answer is to stop.
*/
export const MAX_DECISION_SOURCE_DEPTH = 4;
/**
* Narrow an unknown value to a {@link DecisionSource}, or `undefined`.
*
* Lives beside its type so a new variant updates the guard next door, following
* `asPromptPayload` and `isPermissionDecisionState`. All-or-nothing: a
* malformed field — at any nesting level — yields `undefined` rather than a
* half-parsed record, because a provenance record that names a decider who did
* not decide is worse than one that names none.
*/
export function asDecisionSource(value: unknown): DecisionSource | undefined {
return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
}
function narrowSource(
value: unknown,
depthBudget: number,
): DecisionSource | undefined {
const candidate = asObject(value);
if (!candidate) return undefined;
switch (candidate.kind) {
case "user":
return narrowUser(candidate);
case "authorizer":
return narrowAuthorizer(candidate);
case "rule":
return narrowRule(candidate);
case "session_approval":
return narrowSessionApproval(candidate);
case "yolo":
return isNullableString(candidate.pattern)
? { kind: "yolo", pattern: candidate.pattern }
: undefined;
case "infrastructure_read":
return { kind: "infrastructure_read" };
case "unavailable":
return typeof candidate.reason === "string"
? { kind: "unavailable", reason: candidate.reason }
: undefined;
case "gate_error":
return typeof candidate.reason === "string"
? { kind: "gate_error", reason: candidate.reason }
: undefined;
case "forwarded":
return narrowForwarded(candidate, depthBudget);
default:
return undefined;
}
}
function narrowUser(
candidate: Record<string, unknown>,
): DecisionSource | undefined {
const via = USER_DECISION_SURFACES.find((entry) => entry === candidate.via);
return via ? { kind: "user", via } : undefined;
}
function narrowAuthorizer(
candidate: Record<string, unknown>,
): DecisionSource | undefined {
const verdict = AUTHORIZER_VERDICTS.find(
(entry) => entry === candidate.verdict,
);
if (
!verdict ||
typeof candidate.name !== "string" ||
!isNullableString(candidate.reason)
) {
return undefined;
}
return {
kind: "authorizer",
name: candidate.name,
verdict,
reason: candidate.reason,
};
}
function narrowRule(
candidate: Record<string, unknown>,
): DecisionSource | undefined {
if (
typeof candidate.surface !== "string" ||
!isNullableString(candidate.pattern) ||
!isNullableString(candidate.origin)
) {
return undefined;
}
return {
kind: "rule",
surface: candidate.surface,
pattern: candidate.pattern,
origin: candidate.origin,
};
}
function narrowSessionApproval(
candidate: Record<string, unknown>,
): DecisionSource | undefined {
if (
typeof candidate.surface !== "string" ||
!isNullableString(candidate.pattern)
) {
return undefined;
}
return {
kind: "session_approval",
surface: candidate.surface,
pattern: candidate.pattern,
};
}
/**
* The inner decision is narrowed against a decremented budget, so a chain
* deeper than {@link MAX_DECISION_SOURCE_DEPTH} is rejected whole rather than
* truncated — a truncated chain would silently attribute the decision to the
* last frame that fit.
*/
function narrowForwarded(
candidate: Record<string, unknown>,
depthBudget: number,
): DecisionSource | undefined {
if (depthBudget <= 0 || !isNullableString(candidate.responderSessionId)) {
return undefined;
}
if (candidate.decision === null) {
return {
kind: "forwarded",
responderSessionId: candidate.responderSessionId,
decision: null,
};
}
const decision = narrowSource(candidate.decision, depthBudget - 1);
return decision
? {
kind: "forwarded",
responderSessionId: candidate.responderSessionId,
decision,
}
: undefined;
}
const USER_DECISION_SURFACES = [
"dialog",
"select",
] as const satisfies readonly UserDecisionSurface[];
const AUTHORIZER_VERDICTS = ["allow", "deny"] as const;
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
@@ -0,0 +1,65 @@
/**
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
*
* The chain owner caps every registered link's verdict so a buggy or over-eager
* external judge can never exceed the operator's policy: a link's `allow` on an
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
* verdict — it never turns a `defer`/`deny` into an `allow`.
*
* The excluded set is the whole `path` surface plus `external_directory`, with
* one bundle-maintained exception: the built-in `read` tool may accept a link's
* `allow` for an external-directory ask. Mutating tools, bash, extension tools,
* and unknown tools remain capped to the terminal human authority. A finer
* secret-shaped-`path` exclusion remains deferred; `path` stays fully excluded.
*/
import type { Authorizer } from "./authorizer";
import type { PromptPermissionDetails } from "./permission-prompter";
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
"external_directory",
"path",
]);
/** Read-only external-directory access explicitly delegable in my-pi. */
const DELEGABLE_EXTERNAL_DIRECTORY_TOOLS: ReadonlySet<string> = new Set(["read"]);
/**
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
* through unchanged. `details`, the injected `query`, and the review-log `log`
* are forwarded as-is.
*/
export function encloseInDelegationEnvelope(
authorize: Authorizer["authorize"],
): Authorizer["authorize"] {
return async (details, query, log) => {
const verdict = await authorize(details, query, log);
if (verdict.kind === "allow" && isExcludedSurface(details)) {
return { kind: "defer" };
}
return verdict;
};
}
/**
* Whether an allow verdict exceeds the delegation envelope. The gate-computed
* surface is authoritative. `path` remains fully excluded; external-directory
* access is excluded unless it comes from the built-in `read` tool. Unknown
* surfaces fail safe to the terminal authority.
*/
function isExcludedSurface(details: PromptPermissionDetails): boolean {
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
if (surface === undefined) {
return true;
}
if (surface === "external_directory") {
return !(
details.toolName !== undefined &&
DELEGABLE_EXTERNAL_DIRECTORY_TOOLS.has(details.toolName)
);
}
return DELEGATION_EXCLUDED_SURFACES.has(surface);
}
@@ -0,0 +1,24 @@
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { TerminalAuthorizer } from "./authorizer";
/** Why this authorizer denies; the provenance record's `reason` (#726). */
const NO_AUTHORITY_REASON = "No live authority was reachable for this session";
/**
* Least-privilege Authorizer: no authority is reachable for this session
* (no UI, not a subagent), so every ask is denied.
*
* The denial carries the `confirmationUnavailable` marker so the ask path can
* distinguish "nobody could answer" from an interactive user denial when it
* derives the review-entry and decision-event resolution.
*/
export class DenyingAuthorizer implements TerminalAuthorizer {
authorize(): Promise<PermissionPromptDecision> {
return Promise.resolve({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: { kind: "unavailable", reason: NO_AUTHORITY_REASON },
});
}
}
@@ -0,0 +1,453 @@
import { join } from "node:path";
import type { DecisionSource } from "#src/authority/decision-source";
import {
type ForwarderContext,
getSessionId,
} from "#src/authority/forwarder-context";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import {
type ForwardedAccessFacts,
type ForwardedAccessIntent,
type ForwardedPermissionRequest,
type ForwardedPermissionResponse,
isForwardedPermissionRequestForSession,
type PermissionForwardingLocation,
} from "#src/authority/permission-forwarding";
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
import { SessionApproval } from "#src/session-approval";
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
import type { DebugReviewLogger } from "#src/session-logger";
import type { PermissionCheckResult } from "#src/types";
import type { AskEscalator } from "./authorizer-selection";
import {
cleanupPermissionForwardingLocationIfEmpty,
ensureDirectoryExists,
formatUnknownErrorMessage,
getExistingPermissionForwardingLocation,
listRequestFiles,
logPermissionForwardingError,
logPermissionForwardingWarning,
readForwardedPermissionRequest,
safeDeleteFile,
writeJsonFileAtomic,
} from "./forwarding-io";
import type { PromptPermissionDetails } from "./permission-prompter";
/**
* Narrow seam describing what `ForwardingManager` needs from the server: a
* single method that drains this session's forwarded-permission inbox.
*
* Depending on the interface (not the concrete `ForwardedRequestServer`)
* keeps the manager's unit tests free of casts — they inject a plain
* `{ processInbox: vi.fn() }` mock.
*/
export interface InboxProcessor {
processInbox(ctx: ForwarderContext): Promise<void>;
}
/**
* Recorded-authority view the serving node resolves a forwarded request
* against: answer one {@link ForwardedAccessIntent} query on the serving
* session's composed ruleset, agent-scoped to the requester
* (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
*
* Narrow by design (ISP): the server needs one decision, not the whole
* resolver. The composition root satisfies it with
* `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
* `resolve` entry point `LocalPermissionsService` composes.
*/
export interface ServingPolicy {
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
}
/** Constructor config for `ForwardedRequestServer`. */
export interface ForwardedRequestServerDeps {
forwardingDir: string;
logger: DebugReviewLogger;
/** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
policy: ServingPolicy;
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
escalator: AskEscalator;
/**
* The serving session's `SessionRules`. Records a whole-session grant when a
* human approves a forwarded request for the entire serving session.
*/
recorder: SessionApprovalRecorder;
/** In-process subagent registry, read only by the one-hop canary. */
registry?: SubagentSessionRegistry;
}
// ── Module-private helpers ────────────────────────────────────────────────
/**
* Map a forwarded request onto the escalated ask's details, carrying the
* forwarded provenance (requester agent/session + the child's original display
* projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292),
* plus the child-fixed access facts so the serving node's `Authorizer` chain
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
*
* The display `surface` and the fact `surface` are distinct and both belong
* here: the former is the child's tool name (what the UI shows), the latter the
* gate surface the rule fired on (what the bounded-delegation checkpoint
* excludes on).
*/
function buildForwardedAskDetails(
request: ForwardedPermissionRequest,
): PromptPermissionDetails {
const payload = buildForwardedAskPayload(request);
return {
requestId: request.id,
source: request.source ?? "tool_call",
agentName: request.requesterAgentName || null,
payload,
surface: request.surface ?? null,
value: request.value ?? null,
forwarding: {
requesterAgentName: request.requesterAgentName || null,
requesterSessionId: request.requesterSessionId || null,
},
// Carries the child's suggestion so LocalUserAuthorizer can offer the
// whole-session grant scope; absent for a legacy/version-skew request.
...(request.sessionApproval
? { sessionApproval: request.sessionApproval }
: {}),
// Absent for a version-skew request that carried no intent — which the
// delegation envelope reads as "surface undetermined" and fail-safes to
// excluded, so absence must stay absence rather than become `undefined`.
...(request.accessIntent
? { accessIntent: toAccessFacts(request.accessIntent) }
: {}),
};
}
/**
* Project the wire intent down to the child-fixed access facts an `Authorizer`
* may see.
*
* Field-by-field rather than a spread, because this is a disclosure boundary:
* `requesterCwd` and `principal` are requester identity for the serving node's
* own resolution (ADR 0008 §3) and stay off the ask details. A link that needs
* requester identity reads `details.forwarding`. `ForwardedAccessIntent`
* extends `ForwardedAccessFacts`, so a spread would type-check while widening
* disclosure at runtime; the explicit return type makes any future field on
* `ForwardedAccessFacts` a compile error here until it is deliberately
* projected or deliberately withheld.
*/
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
return {
surface: intent.surface,
matchValues: intent.matchValues,
boundaryValue: intent.boundaryValue,
};
}
// ── ForwardedRequestServer ────────────────────────────────────────────────
/**
* Owner of the serving-down role of the forwarded-permission behavior:
* draining this session's forwarded-permission inbox and answering each
* request the same way the session resolves a local action — resolving its
* `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
* escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
* 0008).
*/
export class ForwardedRequestServer implements InboxProcessor {
private readonly forwardingDir: string;
private readonly logger: DebugReviewLogger;
private readonly policy: ServingPolicy;
private readonly escalator: AskEscalator;
private readonly recorder: SessionApprovalRecorder;
private readonly registry: SubagentSessionRegistry | undefined;
constructor(deps: ForwardedRequestServerDeps) {
this.forwardingDir = deps.forwardingDir;
this.logger = deps.logger;
this.policy = deps.policy;
this.escalator = deps.escalator;
this.recorder = deps.recorder;
this.registry = deps.registry;
}
/** Drain and respond to this session's forwarded-permission inbox. */
async processInbox(ctx: ForwarderContext): Promise<void> {
const currentSessionId = getSessionId(ctx);
const location = getExistingPermissionForwardingLocation(
this.forwardingDir,
currentSessionId,
);
if (!location) {
return;
}
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
if (requestFiles.length === 0) {
return;
}
// Defensively recreate responses/ before writing any response — a
// concurrent cleanup pass may have removed it between the requestsDir
// existence check above and the write inside processSingleForwardedRequest
// (the ENOENT write loop reported in issue #398).
if (
!ensureDirectoryExists(
this.logger,
location.responsesDir,
"permission forwarding responses",
)
) {
return;
}
for (const fileName of requestFiles) {
const requestPath = join(location.requestsDir, fileName);
const request = readForwardedPermissionRequest(this.logger, requestPath);
if (!request) {
safeDeleteFile(
this.logger,
requestPath,
`${location.label} forwarded permission request`,
);
continue;
}
await this.processSingleForwardedRequest(
request,
location,
requestPath,
currentSessionId,
);
}
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
}
// ── Private methods ────────────────────────────────────────────────────
private async processSingleForwardedRequest(
request: ForwardedPermissionRequest,
location: PermissionForwardingLocation,
requestPath: string,
currentSessionId: string,
): Promise<void> {
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
logPermissionForwardingWarning(
this.logger,
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
);
safeDeleteFile(
this.logger,
requestPath,
`${location.label} forwarded permission request`,
);
return;
}
this.warnOnMultiHop(request, currentSessionId);
const forwardedPermissionLogDetails = {
requestId: request.id,
source: location.label,
requesterAgentName: request.requesterAgentName,
requesterSessionId: request.requesterSessionId,
targetSessionId: request.targetSessionId,
requestPath,
};
const decision = await this.resolveDecision(
request,
forwardedPermissionLogDetails,
);
this.recordForwardedDecision(
request,
location,
requestPath,
currentSessionId,
this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
);
}
/**
* Apply the human's grant-scope choice on a forwarded approval.
*
* A whole-session grant (`approved_for_serving_session`) records the child's
* suggested pattern into this serving node's `SessionRules` — the single
* source of truth for the scope — and is then translated to a plain
* `approved` so the child records nothing (its next identical action
* re-forwards and resolves as recorded authority). Every other decision
* passes through unchanged (`approved_for_session` → the child records).
*
* The translation rewrites the grant's *scope*, never its decider: the human
* who chose the wider scope is still the one who decided (#726).
*/
private applyGrantScope(
request: ForwardedPermissionRequest,
decision: PermissionPromptDecision,
logDetails: Record<string, unknown>,
): PermissionPromptDecision {
if (decision.state !== "approved_for_serving_session") {
return decision;
}
if (request.sessionApproval) {
this.recorder.recordSessionApproval(
SessionApproval.multiple(
request.sessionApproval.surface,
request.sessionApproval.patterns,
),
);
this.logger.review("forwarded_permission.session_recorded", {
...logDetails,
surface: request.sessionApproval.surface,
patterns: request.sessionApproval.patterns,
});
}
return {
approved: true,
state: "approved",
decidedBy: decision.decidedBy,
};
}
/**
* Persist the served decision: write the response file the child polls for,
* log the outcome, and delete the drained request. The symmetric "respond"
* half to {@link resolveDecision}'s "decide" half.
*/
private recordForwardedDecision(
request: ForwardedPermissionRequest,
location: PermissionForwardingLocation,
requestPath: string,
currentSessionId: string,
decision: PermissionPromptDecision,
): void {
const responsePath = join(location.responsesDir, `${request.id}.json`);
this.logger.review(
decision.approved
? "forwarded_permission.approved"
: "forwarded_permission.denied",
{
requestId: request.id,
source: location.label,
requesterAgentName: request.requesterAgentName,
requesterSessionId: request.requesterSessionId,
targetSessionId: request.targetSessionId,
responsePath,
resolution: decision.state,
denialReason: decision.denialReason ?? null,
decidedBy: decision.decidedBy,
},
);
try {
writeJsonFileAtomic(this.logger, responsePath, {
approved: decision.approved,
state: decision.state,
denialReason: decision.denialReason,
responderSessionId: currentSessionId,
respondedAt: Date.now(),
// Carried onto the wire so the requester can name what decided inside
// this session, not merely that this session answered (#726).
decidedBy: decision.decidedBy,
} satisfies ForwardedPermissionResponse);
} catch (error) {
logPermissionForwardingError(
this.logger,
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
error,
);
return;
}
safeDeleteFile(
this.logger,
requestPath,
`${location.label} forwarded permission request`,
);
}
/**
* Resolve the request the same way the session resolves a local action:
* recorded authority first (a request carrying an `accessIntent` — the
* child-fixed facts, ADR 0008 §2 — resolves against the serving node's
* composed ruleset — `allow`, including yolo-rewritten, auto-approves;
* `deny` auto-denies), then escalate `ask` (or a request missing
* `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
* `Authorizer`.
*/
private async resolveDecision(
request: ForwardedPermissionRequest,
logDetails: Record<string, unknown>,
): Promise<PermissionPromptDecision> {
const check = request.accessIntent
? this.policy.resolve(request.accessIntent)
: null;
if (check && check.state !== "ask") {
// The rule is carried in full rather than left to the event name: the
// response file has no surface, pattern, or origin column for the
// requester's record to lean on.
const decidedBy: DecisionSource = {
kind: "rule",
surface: request.accessIntent?.surface ?? check.toolName,
pattern: check.matchedPattern ?? null,
origin: check.origin,
};
const approved = check.state === "allow";
this.logger.review(
approved
? "forwarded_permission.auto_approved"
: "forwarded_permission.auto_denied",
{ ...logDetails, decidedBy },
);
return approved
? { approved: true, state: "approved", decidedBy }
: { approved: false, state: "denied", decidedBy };
}
this.logger.review("forwarded_permission.prompted", logDetails);
try {
return await this.escalator.escalate(buildForwardedAskDetails(request));
} catch (error) {
const reason = formatUnknownErrorMessage(error);
logPermissionForwardingError(
this.logger,
`Failed to escalate forwarded permission request '${request.id}'`,
error,
);
// Nobody denied this; the escalation broke and the node failed closed.
return {
approved: false,
state: "denied",
decidedBy: { kind: "gate_error", reason },
};
}
}
/**
* One-hop canary: forwarding is depth-1 (child → root). If the requester is
* itself a registered subagent whose parent is not this serving session, the
* request came through more than one hop (or was misrouted) — resolution is
* still well-defined, so keep serving, but warn loudly so a future
* recursion-guard break is visible rather than silent. Unregistered
* (external file-based) requesters have no recorded parent and are silent.
*/
private warnOnMultiHop(
request: ForwardedPermissionRequest,
currentSessionId: string,
): void {
const requesterInfo = this.registry?.get(request.requesterSessionId);
if (
requesterInfo?.parentSessionId &&
requesterInfo.parentSessionId !== currentSessionId
) {
logPermissionForwardingWarning(
this.logger,
`Forwarded permission request '${request.id}' violates the one-hop ` +
`invariant: requester '${request.requesterSessionId}' is a registered ` +
`subagent whose parent '${requesterInfo.parentSessionId}' is not this ` +
`serving session '${currentSessionId}' (multi-hop or misrouted).`,
);
}
}
}
@@ -0,0 +1,39 @@
import type { SessionEntryView } from "#src/active-agent";
import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
/**
* Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
* dialog UI surface, and the three session-manager readers `getSessionId`
* and the `active-agent` helpers use.
*
* A full `ExtensionContext` satisfies this structurally, so production
* callers pass `ctx` unchanged.
*/
export interface ForwarderContext {
hasUI: boolean;
ui: PermissionDecisionUi;
/** The session's working directory, stamped onto a forwarded request as the requester cwd. */
cwd: string;
sessionManager: {
getSessionId(): string;
getSessionDir(): string;
getEntries(): readonly SessionEntryView[];
};
}
/** Reads the current session cwd off `ctx`. */
export function getCwd(ctx: ForwarderContext): string {
return ctx.cwd;
}
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
export function getSessionId(ctx: ForwarderContext): string {
try {
const sessionId = ctx.sessionManager.getSessionId();
if (typeof sessionId === "string" && sessionId.trim()) {
return sessionId.trim();
}
} catch {}
return "unknown";
}
@@ -0,0 +1,507 @@
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmdirSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { asDecisionSource } from "#src/authority/decision-source";
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
import {
createPermissionForwardingLocation,
type ForwardedAccessIntent,
type ForwardedPermissionRequest,
type ForwardedPermissionResponse,
type ForwardedSessionApproval,
type PermissionForwardingLocation,
} from "#src/authority/permission-forwarding";
import {
OWNER_ONLY_DIRECTORY_MODE,
OWNER_ONLY_FILE_MODE,
} from "#src/log-file-permissions";
import type { PermissionUiPromptSource } from "#src/permission-events";
import { asPromptPayload } from "#src/presentation/prompt-payload";
import type { DebugReviewLogger } from "#src/session-logger";
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
const UI_PROMPT_SOURCES = [
"tool_call",
"skill_input",
"skill_read",
] as const satisfies readonly PermissionUiPromptSource[];
/** Narrow an unknown value to a valid prompt source, or `undefined`. */
function asUiPromptSource(
value: unknown,
): PermissionUiPromptSource | undefined {
return UI_PROMPT_SOURCES.find((source) => source === value);
}
/** Narrow an unknown value to a nullable display string, or `undefined`. */
function asNullableDisplayString(value: unknown): string | null | undefined {
if (value === null || typeof value === "string") {
return value;
}
return undefined;
}
/**
* Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
*
* Tolerant read: the child's session-approval suggestion is optional (absent
* on an older child) and only accepted when well-formed — a non-empty surface
* and an all-string patterns array.
*/
function asForwardedSessionApproval(
value: unknown,
): ForwardedSessionApproval | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = value as Partial<ForwardedSessionApproval>;
if (
typeof candidate.surface !== "string" ||
candidate.surface.length === 0 ||
!Array.isArray(candidate.patterns) ||
!candidate.patterns.every((pattern) => typeof pattern === "string")
) {
return undefined;
}
return { surface: candidate.surface, patterns: [...candidate.patterns] };
}
/**
* Narrow an unknown value to a `ForwardedAccessIntent`, or `undefined`.
*
* Tolerant read: the child-fixed access intent is optional (absent on an older
* child) and only accepted when fully well-formed — a string `surface`, an
* all-string `matchValues` array, a `string | null` `boundaryValue`, a string
* `requesterCwd`, and a `principal` with string `sessionId`/`agentName`. Any
* malformed shape → `undefined`, so the serving node floors to `ask` (Step 3)
* rather than resolving against corrupt facts.
*/
function asForwardedAccessIntent(
value: unknown,
): ForwardedAccessIntent | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = value as {
surface?: unknown;
matchValues?: unknown;
boundaryValue?: unknown;
requesterCwd?: unknown;
principal?: unknown;
};
if (
typeof candidate.surface !== "string" ||
!Array.isArray(candidate.matchValues) ||
!candidate.matchValues.every((entry) => typeof entry === "string") ||
!(
candidate.boundaryValue === null ||
typeof candidate.boundaryValue === "string"
) ||
typeof candidate.requesterCwd !== "string" ||
typeof candidate.principal !== "object" ||
candidate.principal === null
) {
return undefined;
}
const principal = candidate.principal as {
sessionId?: unknown;
agentName?: unknown;
};
if (
typeof principal.sessionId !== "string" ||
typeof principal.agentName !== "string"
) {
return undefined;
}
return {
surface: candidate.surface,
matchValues: [...candidate.matchValues],
boundaryValue: candidate.boundaryValue,
requesterCwd: candidate.requesterCwd,
principal: {
sessionId: principal.sessionId,
agentName: principal.agentName,
},
};
}
export function formatUnknownErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return String(error);
}
export function isErrnoCode(error: unknown, code: string): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: string }).code === code,
);
}
/**
* Log a warning to both the review and debug logs.
* Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
*/
export function logPermissionForwardingWarning(
logger: DebugReviewLogger | null,
message: string,
error?: unknown,
): void {
const details =
typeof error === "undefined"
? { message }
: { message, error: formatUnknownErrorMessage(error) };
logger?.review("permission_forwarding.warning", details);
logger?.debug("permission_forwarding.warning", details);
}
/**
* Log an error to both the review and debug logs.
* Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
*/
export function logPermissionForwardingError(
logger: DebugReviewLogger | null,
message: string,
error?: unknown,
): void {
const details =
typeof error === "undefined"
? { message }
: { message, error: formatUnknownErrorMessage(error) };
logger?.review("permission_forwarding.error", details);
logger?.debug("permission_forwarding.error", details);
}
export function ensureDirectoryExists(
logger: DebugReviewLogger | null,
path: string,
description: string,
): boolean {
try {
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
return true;
} catch (error) {
logPermissionForwardingError(
logger,
`Failed to create ${description} directory '${path}'`,
error,
);
return false;
}
}
export function getPermissionForwardingLocationForSession(
forwardingDir: string,
sessionId: string,
): PermissionForwardingLocation {
return createPermissionForwardingLocation(forwardingDir, sessionId);
}
export function ensurePermissionForwardingLocation(
logger: DebugReviewLogger | null,
forwardingDir: string,
sessionId: string,
): PermissionForwardingLocation | null {
let location: PermissionForwardingLocation;
try {
location = getPermissionForwardingLocationForSession(
forwardingDir,
sessionId,
);
} catch (error) {
logPermissionForwardingError(
logger,
"Failed to resolve permission forwarding location",
error,
);
return null;
}
const sessionRootReady = ensureDirectoryExists(
logger,
location.sessionRootDir,
"permission forwarding session root",
);
const requestsReady = ensureDirectoryExists(
logger,
location.requestsDir,
"permission forwarding requests",
);
const responsesReady = ensureDirectoryExists(
logger,
location.responsesDir,
"permission forwarding responses",
);
return sessionRootReady && requestsReady && responsesReady ? location : null;
}
export function getExistingPermissionForwardingLocation(
forwardingDir: string,
sessionId: string,
): PermissionForwardingLocation | null {
let location: PermissionForwardingLocation;
try {
location = getPermissionForwardingLocationForSession(
forwardingDir,
sessionId,
);
} catch {
return null;
}
return existsSync(location.requestsDir) ? location : null;
}
/**
* Attempt to remove a directory if it is empty.
*
* Returns `true` when the directory is absent after the call (successfully
* removed, or never existed). Returns `false` when the directory still exists
* (non-empty, or a filesystem error prevented removal).
*/
export function tryRemoveDirectoryIfEmpty(
logger: DebugReviewLogger | null,
path: string,
description: string,
): boolean {
if (!existsSync(path)) {
return true;
}
let entries: string[];
try {
entries = readdirSync(path);
} catch (error) {
logPermissionForwardingWarning(
logger,
`Failed to inspect ${description} directory '${path}'`,
error,
);
return false;
}
if (entries.length > 0) {
return false;
}
try {
rmdirSync(path);
return true;
} catch (error) {
if (isErrnoCode(error, "ENOENT")) {
return true;
}
if (isErrnoCode(error, "ENOTEMPTY")) {
return false;
}
logPermissionForwardingWarning(
logger,
`Failed to remove empty ${description} directory '${path}'`,
error,
);
return false;
}
}
export function cleanupPermissionForwardingLocationIfEmpty(
logger: DebugReviewLogger | null,
location: PermissionForwardingLocation,
): void {
// Only remove responses/ when requests/ is already gone — removing responses/
// while a request is still pending causes the ENOENT write loop (issue #398).
const requestsGone = tryRemoveDirectoryIfEmpty(
logger,
location.requestsDir,
`${location.label} permission forwarding requests`,
);
if (requestsGone) {
tryRemoveDirectoryIfEmpty(
logger,
location.responsesDir,
`${location.label} permission forwarding responses`,
);
}
tryRemoveDirectoryIfEmpty(
logger,
location.sessionRootDir,
`${location.label} permission forwarding session root`,
);
}
export function safeDeleteFile(
logger: DebugReviewLogger | null,
filePath: string,
description: string,
): void {
try {
unlinkSync(filePath);
} catch (error) {
if (isErrnoCode(error, "ENOENT")) {
return;
}
logPermissionForwardingWarning(
logger,
`Failed to delete ${description} file '${filePath}'`,
error,
);
}
}
export function writeJsonFileAtomic(
logger: DebugReviewLogger | null,
filePath: string,
value: unknown,
): void {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
// `rename` preserves the temp file's mode, so setting it here is enough —
// a response overwriting an existing file also comes through a fresh temp.
writeFileSync(tempPath, JSON.stringify(value), {
encoding: "utf-8",
mode: OWNER_ONLY_FILE_MODE,
});
renameSync(tempPath, filePath);
} catch (error) {
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
throw error;
}
}
export function readForwardedPermissionRequest(
logger: DebugReviewLogger | null,
filePath: string,
): ForwardedPermissionRequest | null {
try {
const raw = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
!parsed ||
typeof parsed.id !== "string" ||
typeof parsed.createdAt !== "number" ||
typeof parsed.requesterSessionId !== "string" ||
typeof parsed.targetSessionId !== "string" ||
typeof parsed.requesterAgentName !== "string"
) {
logPermissionForwardingWarning(
logger,
`Ignoring invalid forwarded permission request format in '${filePath}'`,
);
return null;
}
return {
id: parsed.id,
createdAt: parsed.createdAt,
requesterSessionId: parsed.requesterSessionId,
targetSessionId: parsed.targetSessionId,
requesterAgentName: parsed.requesterAgentName,
// Tolerant read: the payload and display fields are optional and may be
// absent (older child) or malformed; reconstruct only the well-formed
// ones. An older child's `message` is deliberately not salvaged — a
// skewed ask renders from the fields it does carry (ADR 0011 §9).
payload: asPromptPayload(parsed.payload),
source: asUiPromptSource(parsed.source),
surface: asNullableDisplayString(parsed.surface),
value: asNullableDisplayString(parsed.value),
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
accessIntent: asForwardedAccessIntent(parsed.accessIntent),
};
} catch (error) {
logPermissionForwardingWarning(
logger,
`Failed to read forwarded permission request '${filePath}'`,
error,
);
return null;
}
}
export function readForwardedPermissionResponse(
logger: DebugReviewLogger | null,
filePath: string,
): ForwardedPermissionResponse | null {
try {
const raw = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
!parsed ||
typeof parsed.approved !== "boolean" ||
!isPermissionDecisionState(parsed.state) ||
typeof parsed.responderSessionId !== "string"
) {
logPermissionForwardingWarning(
logger,
`Ignoring invalid forwarded permission response format in '${filePath}'`,
);
return null;
}
return {
approved: parsed.approved,
state: parsed.state,
denialReason:
typeof parsed.denialReason === "string"
? parsed.denialReason
: undefined,
responderSessionId: parsed.responderSessionId,
respondedAt:
typeof parsed.respondedAt === "number"
? parsed.respondedAt
: Date.now(),
// Tolerant like the request's `accessIntent`: an unusable provenance
// record is dropped, but the decision itself still has to reach the
// requester, so it never rejects the response.
decidedBy: asDecisionSource(parsed.decidedBy),
};
} catch (error) {
logPermissionForwardingWarning(
logger,
`Failed to read forwarded permission response '${filePath}'`,
error,
);
return null;
}
}
export function listRequestFiles(
logger: DebugReviewLogger | null,
requestsDir: string,
): string[] {
try {
return readdirSync(requestsDir)
.filter((name) => name.endsWith(".json"))
.sort();
} catch (error) {
logPermissionForwardingWarning(
logger,
`Failed to read permission forwarding requests from '${requestsDir}'`,
error,
);
return [];
}
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
@@ -0,0 +1,450 @@
/**
* forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
*
* The in-process answer already exists: a serving session marks itself in the
* process-global `ServingSessionRegistry`, and an in-process child abandons a
* target that has looked unmarked for the grace window instead of waiting out
* the full forwarding timeout (#719).
*
* A child spawned as a separate `pi` process shares no `globalThis` with its
* parent, so that mark is invisible to it and it keeps waiting the full ten
* minutes — every `ask` forwarded to a session that has already exited costs
* the whole timeout and ends in a denial nobody made (#735 scenario 1).
*
* The filesystem is the only channel those two processes share, so the serving
* session publishes a heartbeat there: one record per serving session,
* refreshed while it polls and withdrawn when it stops.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
ensureDirectoryExists,
isErrnoCode,
logPermissionForwardingError,
safeDeleteFile,
writeJsonFileAtomic,
} from "#src/authority/forwarding-io";
import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
import {
encodeSessionIdForPath,
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
} from "#src/authority/permission-forwarding";
import type {
ServingAnnouncer,
ServingLookup,
} from "#src/authority/serving-registry";
import type { DebugReviewLogger } from "#src/session-logger";
/**
* How often a serving session rewrites its heartbeat — four poll ticks.
*
* Longer than the poll interval so `ForwardingManager` can announce on every
* tick without four filesystem writes a second, and short enough that a record
* deleted underneath its owner reappears well inside the grace window a
* forwarding child waits out before abandoning.
*/
export const SERVING_HEARTBEAT_REFRESH_MS =
4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
/**
* How long a heartbeat may go unrefreshed before its writer is presumed gone —
* five refreshes.
*
* Generous because a delayed Node timer is not a dead session, and because the
* case this threshold exists for (a process that is alive but no longer
* polling) is the rare one: an exited session withdraws its record and a killed
* one is caught by the recorded pid, neither of which waits for staleness.
*/
export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
/** What a serving session publishes while it drains its forwarded-permission inbox. */
export interface ServingHeartbeat {
sessionId: string;
/** The serving process, so a killed session is detectable without waiting out staleness. */
pid: number;
updatedAt: number;
}
/**
* How a session's heartbeat reads right now.
*
* Only `"alive"` means someone is draining the inbox. The other three are the
* ways a target can be unserved, kept apart because they are the diagnosis a
* stalled forward needs: `"absent"` is a session that exited (or never served,
* or runs a version that does not publish), `"dead_pid"` one that was killed,
* and `"stale"` one whose process survives but stopped polling.
*/
export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
/**
* Read side of the heartbeat channel, consumed by a forwarding child.
*
* Separate from the announce seam because the two have no caller in common: a
* serving session only publishes, and a forwarding child only reads (ISP).
*/
export interface HeartbeatReader {
read(sessionId: string): HeartbeatState;
/** Every session whose record reads as alive, for the abandonment diagnostic. */
servingIds(): readonly string[];
}
/**
* Query-side seam: is the session a forwarding target names being drained?
*
* Keyed on the target rather than a session id because the answer depends on
* how the target was resolved. An in-process child and its parent share a
* `globalThis`, so the registry answers for them; an out-of-process pair shares
* only the filesystem; and a session that owns the inbox it is forwarding to is
* not a case either channel describes.
*
* Consolidating that into one collaborator is what keeps `ParentAuthorizer`
* from holding two lookups and re-deciding which one applies — the decision has
* one home, and a third channel would not reach the poll loop.
*/
export interface TargetServingLookup {
/** `true` serving, `false` not serving, `null` when the target carries no signal. */
isServing(target: PermissionForwardingTarget): boolean | null;
/** What the judge observed, for the review entry a child writes when it gives up. */
describe(target: PermissionForwardingTarget): ServingObservation;
}
/** What answered a liveness question, and what it saw. */
export interface ServingObservation {
channel: "registry" | "heartbeat" | "none";
/** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
state: HeartbeatState | null;
servingIds: readonly string[];
}
/** Constructor config for {@link ForwardingLivenessJudge}. */
export interface ForwardingLivenessJudgeDeps {
/** Answers for a target the requester shares a process with. */
registry: ServingLookup;
/** Answers for a target in another process. */
heartbeats: HeartbeatReader;
}
/**
* Routes a liveness question to the channel that can answer it.
*
* The routing key is `PermissionForwardingTarget.source`, which the resolver
* already produces — so "in-process" is decided once, where the target is
* found, rather than re-derived here (#719).
*/
export class ForwardingLivenessJudge implements TargetServingLookup {
constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
isServing(target: PermissionForwardingTarget): boolean | null {
switch (target.source) {
case "registry":
return this.deps.registry.isServing(target.sessionId);
case "env":
return this.deps.heartbeats.read(target.sessionId) === "alive";
case "self":
return null;
}
}
describe(target: PermissionForwardingTarget): ServingObservation {
switch (target.source) {
case "registry":
return {
channel: "registry",
state: null,
servingIds: this.deps.registry.servingIds(),
};
case "env":
return {
channel: "heartbeat",
state: this.deps.heartbeats.read(target.sessionId),
servingIds: this.deps.heartbeats.servingIds(),
};
case "self":
return { channel: "none", state: null, servingIds: [] };
}
}
}
const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
/**
* Where serving heartbeats live: beside the `sessions/` tree, never inside it.
*
* A heartbeat under `sessions/<id>/` would make that session root permanently
* non-empty, entangling liveness with the request/response cleanup whose
* removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
* that logic stays untouched and "who is serving" is a single directory read.
*/
export function servingHeartbeatDir(forwardingDir: string): string {
return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
}
/** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
export function servingHeartbeatPath(
forwardingDir: string,
sessionId: string,
): string {
return join(
servingHeartbeatDir(forwardingDir),
`${encodeSessionIdForPath(sessionId)}.json`,
);
}
/** Constructor config for {@link ServingHeartbeatStore}. */
export interface ServingHeartbeatStoreDeps {
forwardingDir: string;
logger: DebugReviewLogger;
/** Injected so the refresh throttle and staleness are testable without sleeping. */
now?: () => number;
/** The process to record. Injected so a test can publish a pid it controls. */
pid?: number;
/** Injected so a test can decide which pids are running. */
isProcessAlive?: (pid: number) => boolean;
}
/**
* Publishes this session's serving heartbeat to the filesystem.
*
* Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
* so `ForwardingManager` announces to both channels through one collaborator
* and neither knows the other exists.
*
* `markServing` is idempotent by that seam's contract and internally throttled,
* so the caller may announce on every poll tick. Nothing here throws: it runs
* from a timer, and a filesystem failure must degrade to the pre-existing
* timeout rather than break the poll loop.
*/
export class ServingHeartbeatStore
implements ServingAnnouncer, HeartbeatReader
{
private readonly forwardingDir: string;
private readonly logger: DebugReviewLogger;
private readonly now: () => number;
private readonly pid: number;
private readonly isProcessAlive: (pid: number) => boolean;
private published: { sessionId: string; at: number } | null = null;
private hasSweptDeadRecords = false;
constructor(deps: ServingHeartbeatStoreDeps) {
this.forwardingDir = deps.forwardingDir;
this.logger = deps.logger;
this.now = deps.now ?? Date.now;
this.pid = deps.pid ?? process.pid;
this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
}
/** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
markServing(sessionId: string): void {
const at = this.now();
if (this.isThrottled(sessionId, at)) {
return;
}
const directory = servingHeartbeatDir(this.forwardingDir);
if (
!ensureDirectoryExists(
this.logger,
directory,
"permission forwarding serving heartbeat",
)
) {
return;
}
this.sweepDeadRecordsOnce();
const heartbeat: ServingHeartbeat = {
sessionId,
pid: this.pid,
updatedAt: at,
};
try {
writeJsonFileAtomic(
this.logger,
servingHeartbeatPath(this.forwardingDir, sessionId),
heartbeat,
);
} catch (error) {
logPermissionForwardingError(
this.logger,
`Failed to publish the serving heartbeat for session '${sessionId}'`,
error,
);
return;
}
this.published = { sessionId, at };
}
/** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
clearServing(sessionId: string): void {
if (this.published?.sessionId === sessionId) {
this.published = null;
}
safeDeleteFile(
this.logger,
servingHeartbeatPath(this.forwardingDir, sessionId),
"permission forwarding serving heartbeat",
);
}
/** How `sessionId`'s heartbeat reads right now. */
read(sessionId: string): HeartbeatState {
const record = this.readRecord(
servingHeartbeatPath(this.forwardingDir, sessionId),
);
return record === null ? "absent" : this.classify(record);
}
/** Every session whose record reads as alive. */
servingIds(): readonly string[] {
const ids: string[] = [];
for (const { record } of this.listRecords()) {
if (record !== null && this.classify(record) === "alive") {
ids.push(record.sessionId);
}
}
return ids;
}
// ── Private methods ────────────────────────────────────────────────
/**
* Delete the records of processes that are provably gone, once per session.
*
* Without this the directory grows one record per session that was killed
* rather than shut down, forever. Bounded to a single directory read at the
* first announcement, and safe under pid reuse: a wrongly swept owner
* republishes within the refresh window, which is shorter than the grace a
* forwarding child waits out.
*
* Only a dead pid is proof. A record that is merely stale belongs to a
* process that still exists, and the reader already reports it as stale
* without anyone having to remove it.
*/
private sweepDeadRecordsOnce(): void {
if (this.hasSweptDeadRecords) {
return;
}
this.hasSweptDeadRecords = true;
for (const { path, record } of this.listRecords()) {
if (record !== null && this.isProcessAlive(record.pid)) {
continue;
}
safeDeleteFile(
this.logger,
path,
"abandoned permission forwarding serving heartbeat",
);
}
}
/** Every published record, paired with its path; unusable ones read as `null`. */
private listRecords(): {
path: string;
record: ServingHeartbeat | null;
}[] {
const directory = servingHeartbeatDir(this.forwardingDir);
let names: string[];
try {
names = readdirSync(directory);
} catch {
return [];
}
return names
.filter((name) => name.endsWith(".json"))
.map((name) => {
const path = join(directory, name);
return { path, record: this.readRecord(path) };
});
}
/**
* Read a record, or `null` when it is missing or unusable.
*
* Silent by design: a forwarding child calls this on every poll tick, so a
* warning per unreadable read would flood the review log at four lines a
* second. The unusability is already reported once, as the `absent` state on
* the abandonment entry.
*/
private readRecord(path: string): ServingHeartbeat | null {
try {
return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
} catch {
return null;
}
}
/** Which of the four states a well-formed record is in. */
private classify(record: ServingHeartbeat): HeartbeatState {
if (!this.isProcessAlive(record.pid)) {
return "dead_pid";
}
return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
? "stale"
: "alive";
}
/**
* Whether the record on disk is recent enough to leave alone.
*
* Time alone, with no existence probe: an existence check would cost a
* syscall on every poll tick to save at most one refresh window, and a record
* removed underneath its owner reappears inside the grace window anyway.
*/
private isThrottled(sessionId: string, at: number): boolean {
return (
this.published !== null &&
this.published.sessionId === sessionId &&
at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
);
}
}
// ── Module-private helpers ────────────────────────────────────────────────
/**
* Narrow a parsed record, or `undefined`.
*
* `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
* the caller's own process group and `kill(-n)` a foreign one, so a malformed
* record must be rejected before it can reach the liveness probe.
*/
function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
if (typeof value !== "object" || value === null) {
return null;
}
const candidate = value as Partial<ServingHeartbeat>;
if (
typeof candidate.sessionId !== "string" ||
candidate.sessionId.length === 0 ||
typeof candidate.pid !== "number" ||
!Number.isInteger(candidate.pid) ||
candidate.pid <= 0 ||
typeof candidate.updatedAt !== "number" ||
!Number.isFinite(candidate.updatedAt)
) {
return null;
}
return {
sessionId: candidate.sessionId,
pid: candidate.pid,
updatedAt: candidate.updatedAt,
};
}
/**
* Whether `pid` names a running process.
*
* Signal `0` performs the permission and existence checks without delivering
* anything. `EPERM` means the process exists under another user — reported as
* alive, the direction that falls back to the timeout rather than abandoning a
* request someone may still answer.
*/
function isRunningProcess(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return isErrnoCode(error, "EPERM");
}
}
@@ -0,0 +1,143 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { DebugReviewLogger } from "#src/session-logger";
import type { InboxProcessor } from "./forwarded-request-server";
import { getSessionId } from "./forwarder-context";
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
import type { ServingAnnouncer } from "./serving-registry";
import type { SubagentDetector } from "./subagent-detection";
/**
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
* `ForwardingManager` satisfies it; tests can provide a plain object mock.
*/
export interface ForwardingController {
start(ctx: ExtensionContext): void;
stop(): void;
}
/** Constructor config for {@link ForwardingManager}. */
export interface ForwardingManagerDeps {
/** Single owner of subagent detection; gates whether this session may serve. */
detection: SubagentDetector;
/** Drains this session's forwarded-permission inbox on each tick. */
forwarder: InboxProcessor;
/** Publishes that this session is draining its inbox, for forwarding children. */
serving: ServingAnnouncer;
logger: DebugReviewLogger;
}
/**
* Encapsulates the forwarded-permission polling lifecycle.
*
* Owns the timer, current context, and processing-lock state that previously
* lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
* session event that may activate forwarding; call `stop()` on session
* shutdown.
*
* While polling, it publishes the session id it polls to the `ServingAnnouncer`
* so a forwarding child can tell that someone is draining the inbox it wrote
* into — and the review log records that id, so a child forwarding to a
* *different* id is visible as a one-line diff against its
* `forwarded_permission.request_created` entry (#719).
*/
export class ForwardingManager {
private timer: NodeJS.Timeout | null = null;
private context: ExtensionContext | null = null;
private processing = false;
private servingSessionId: string | null = null;
constructor(private readonly deps: ForwardingManagerDeps) {}
/**
* Start polling if `ctx` has UI and is not a subagent execution context.
* No-op (timer stays running) if already polling — updates the stored
* context so the next tick uses the latest session.
* Stops any existing poll when the context does not qualify for forwarding.
*/
start(ctx: ExtensionContext): void {
if (!ctx.hasUI || this.deps.detection.isSubagent(ctx)) {
this.stop();
return;
}
this.context = ctx;
this.announceServing(getSessionId(ctx));
if (this.timer) {
return;
}
this.timer = setInterval(() => {
// Ahead of the processing guard: a session whose human is deliberating at
// a forwarded dialog holds `processInbox` open for as long as they take,
// and it is serving throughout. Refreshing behind the guard would let its
// announcement decay exactly when it is most demonstrably alive, and
// every other forwarding child would give up on it.
this.refreshServing();
if (!this.context || this.processing) {
return;
}
this.processing = true;
void this.deps.forwarder.processInbox(this.context).finally(() => {
this.processing = false;
});
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
}
/** Stop polling and clear all internal state. */
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.withdrawServing();
this.context = null;
this.processing = false;
}
// ── Private methods ────────────────────────────────────────────────
/**
* Publish `sessionId` as the served session, replacing any previous one.
*
* A no-op when the id is unchanged, since `start` runs on every
* `before_agent_start`, `input`, and `tool_call` — the announcement must not
* cost a log line per turn.
*/
private announceServing(sessionId: string): void {
if (this.servingSessionId === sessionId) {
return;
}
this.withdrawServing();
this.servingSessionId = sessionId;
this.deps.serving.markServing(sessionId);
this.deps.logger.review("forwarded_permission.serving_started", {
sessionId,
});
}
/**
* Re-announce the served session, keeping a decayable channel current.
*
* Separate from {@link announceServing} because that one detects a change to
* write its log line, and this one deliberately writes none — four review
* entries a second would drown the log the announcement exists to make
* readable.
*/
private refreshServing(): void {
if (this.servingSessionId === null) {
return;
}
this.deps.serving.markServing(this.servingSessionId);
}
/** Withdraw the published session, if any. */
private withdrawServing(): void {
const sessionId = this.servingSessionId;
if (sessionId === null) {
return;
}
this.servingSessionId = null;
this.deps.serving.clearServing(sessionId);
this.deps.logger.review("forwarded_permission.serving_stopped", {
sessionId,
});
}
}
@@ -0,0 +1,88 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type {
PermissionPromptDecision,
RequestPermissionOptions,
} from "#src/authority/permission-dialog";
import type {
PermissionPromptUi,
PromptPreferences,
requestPermissionDecision,
} from "#src/authority/permission-prompt-component";
import { buildForwardedScopeLabels } from "#src/pattern-suggest";
import {
emitUiPromptEvent,
type PermissionEventBus,
} from "#src/permission-events";
import { buildUiPrompt } from "#src/permission-ui-prompt";
import type { TerminalAuthorizer } from "./authorizer";
import type { PromptPermissionDetails } from "./permission-prompter";
/** Dependencies required by {@link LocalUserAuthorizer}. */
export interface LocalUserAuthorizerDeps {
/** The active session's UI surface (select/input plus the inline `custom` dialog). */
ui: PermissionPromptUi;
/** The session run mode; the dispatcher renders the inline dialog only in `"tui"`. */
mode: ExtensionContext["mode"];
/** Event bus used for the `permissions:ui_prompt` broadcast. */
events: PermissionEventBus;
/** Read live at prompt time so a settings-modal toggle takes effect on the next prompt. */
getPromptPreferences: () => PromptPreferences;
/** Injected for testability; production callers pass the real function. */
requestPermissionDecision: typeof requestPermissionDecision;
}
/**
* Authorizer for a session with an active UI: prompt the human here.
*
* Emits the `permissions:ui_prompt` broadcast (moved here from
* `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
* observers know a decision is imminent. This is the single emit site: a
* forwarded ask carries its provenance on `details.forwarding`, which this
* class renders (populated `forwarding` context + "(Subagent)" title) so the
* broadcast stays non-degraded (#292) without a second emission path.
*/
export class LocalUserAuthorizer implements TerminalAuthorizer {
constructor(private readonly deps: LocalUserAuthorizerDeps) {}
authorize(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
const uiPrompt = buildUiPrompt(details);
emitUiPromptEvent(this.deps.events, uiPrompt);
return this.deps.requestPermissionDecision(
{
mode: this.deps.mode,
ui: this.deps.ui,
...this.deps.getPromptPreferences(),
},
details.forwarding
? "Permission Required (Subagent)"
: "Permission Required",
details.payload,
buildRequestOptions(details),
);
}
}
/**
* A forwarded ask carrying a session-approval suggestion offers the scope
* choice (subagent vs whole session); any other ask keeps its single
* "for this session" option (custom label when the gate supplied one).
*/
function buildRequestOptions(
details: PromptPermissionDetails,
): RequestPermissionOptions | undefined {
const pattern = details.sessionApproval?.patterns[0];
if (details.forwarding && details.sessionApproval && pattern) {
return {
sessionScope: buildForwardedScopeLabels(
details.forwarding.requesterAgentName,
details.sessionApproval.surface,
pattern,
),
};
}
return details.sessionLabel
? { sessionLabel: details.sessionLabel }
: undefined;
}
@@ -0,0 +1,174 @@
import type { DecisionSource } from "#src/authority/decision-source";
export type PermissionDecisionState =
| "approved"
| "approved_for_session"
| "approved_for_serving_session"
| "denied"
| "denied_with_reason";
export type PermissionPromptDecision = {
approved: boolean;
state: PermissionDecisionState;
denialReason?: string;
/**
* True when the decision was made automatically by yolo mode rather than
* by an interactive user prompt. Used by handlers to emit "auto_approved"
* rather than "user_approved" in the permissions:decision broadcast.
*/
autoApproved?: true;
/**
* True when no human ever ruled on this ask: either no live authority was
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
* unresolvable, request undeliverable, target not serving, or no answer
* within the timeout). Consumed by deriveResolution (the decision-event
* resolution), the gate (block reason), and PermissionPrompter (review-entry
* resolution) to emit "confirmation_unavailable" rather than a plain user
* denial — a user who was never asked denied nothing (#719).
*/
confirmationUnavailable?: true;
/**
* What decided this request, stamped by the site that decided it.
*
* Required: every decision names its decider, and the type is what
* guarantees it rather than a convention each producer has to remember — the
* same discipline `PromptPermissionDetails.payload` carries (#726).
*/
decidedBy: DecisionSource;
};
/**
* A decision before its decider is known.
*
* The inner producers — the dialog's decision model, the `select`/`input`
* fallback, the verdict mapper — state the outcome; which decider to attribute
* it to is settled one layer up, at the site that chose the producer. The same
* shape `GateBypass.decision` uses for the request id: a producer emits only
* what it knows.
*/
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
export interface PermissionDecisionUi {
select(title: string, options: string[]): Promise<string | undefined>;
input(title: string, placeholder?: string): Promise<string | undefined>;
}
const APPROVE_OPTION = "Yes";
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
const DENY_OPTION = "No";
const DENY_WITH_REASON_OPTION = "No, provide reason";
export function normalizePermissionDenialReason(
value: unknown,
): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function createDeniedPermissionDecision(
denialReason?: string,
): UnattributedDecision {
const normalizedReason = normalizePermissionDenialReason(denialReason);
return normalizedReason
? {
approved: false,
state: "denied_with_reason",
denialReason: normalizedReason,
}
: {
approved: false,
state: "denied",
};
}
export function isPermissionDecisionState(
value: unknown,
): value is PermissionDecisionState {
return (
value === "approved" ||
value === "approved_for_session" ||
value === "approved_for_serving_session" ||
value === "denied" ||
value === "denied_with_reason"
);
}
export interface RequestPermissionOptions {
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
sessionLabel?: string;
/**
* Forwarded asks only: when set, choosing the "for this session" option opens
* a second select asking whether the grant applies to the requesting subagent
* only (the least-privilege default) or the whole serving session.
*/
sessionScope?: {
subagentLabel: string;
servingSessionLabel: string;
};
}
export async function requestPermissionDecisionFromUi(
ui: PermissionDecisionUi,
title: string,
message: string,
options?: RequestPermissionOptions,
): Promise<UnattributedDecision> {
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
const decisionOptions = [
APPROVE_OPTION,
sessionOption,
DENY_OPTION,
DENY_WITH_REASON_OPTION,
] as const;
const selected = await ui.select(`${title}\n${message}`, [
...decisionOptions,
]);
if (selected === APPROVE_OPTION) {
return {
approved: true,
state: "approved",
};
}
if (selected === sessionOption) {
if (options?.sessionScope) {
const scope = await ui.select(`${title}\nApply this session grant to:`, [
options.sessionScope.subagentLabel,
options.sessionScope.servingSessionLabel,
]);
return {
approved: true,
// A cancelled scope select (undefined) falls back to the
// least-privilege subagent scope.
state:
scope === options.sessionScope.servingSessionLabel
? "approved_for_serving_session"
: "approved_for_session",
};
}
return {
approved: true,
state: "approved_for_session",
};
}
if (selected === DENY_WITH_REASON_OPTION) {
const denialReason = normalizePermissionDenialReason(
await ui.input(
`${title}\nShare why this request was denied (optional).`,
"Reason shown back to the agent",
),
);
return createDeniedPermissionDecision(denialReason);
}
return createDeniedPermissionDecision();
}
@@ -0,0 +1,317 @@
import { join } from "node:path";
import type { DecisionSource } from "#src/authority/decision-source";
import type { PermissionUiPromptSource } from "#src/permission-events";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import type { PermissionDecisionState } from "./permission-dialog";
import type { SubagentSessionRegistry } from "./subagent-registry";
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
/**
* How long an in-process forwarding target may go unserved before the child
* gives up on it — eight poll ticks.
*
* A window rather than a single check because `ForwardingManager` withdraws and
* re-announces across a session switch, and a request that arrives inside that
* gap is about to be picked up. Not configurable: the operator-facing knob is
* the overall timeout, and this only decides how fast a hopeless wait ends.
*/
export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
export const SUBAGENT_ENV_HINT_KEYS = [
// pi-agent-router (original)
"PI_IS_SUBAGENT",
"PI_SUBAGENT_SESSION_ID",
"PI_AGENT_ROUTER_SUBAGENT",
// nicobailon/pi-subagents
"PI_SUBAGENT_CHILD",
"PI_SUBAGENT_RUN_ID",
"PI_SUBAGENT_CHILD_AGENT",
"PI_SUBAGENT_DEPTH",
// HazAT/pi-interactive-subagents
"PI_SUBAGENT_NAME",
"PI_SUBAGENT_ID",
"PI_SUBAGENT_SESSION",
"PI_SUBAGENT_ACTIVITY_FILE",
] as const;
/** Ordered list of env var names to check for the parent session ID. First match wins. */
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
// pi-agent-router (original)
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
// Shared convention for CLI-based subagent extensions
// (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
"PI_SUBAGENT_PARENT_SESSION",
] as const;
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
/**
* Display fields relayed from a forwarding child to the parent UI so the parent
* can emit a non-degraded `permissions:ui_prompt` event.
*
* Carried separately from the prompt payload because the parent reconstructs
* the original event from the escalated ask's details (`buildUiPrompt`), not
* from the payload's own facts.
*/
export interface ForwardedPromptDisplay {
source: PermissionUiPromptSource;
surface: string | null;
value: string | null;
}
/**
* The child's session-approval suggestion, relayed to the serving node so a
* human who grants "the whole session" records the same pattern the child
* would have recorded locally.
*
* A plain data shape (not the `SessionApproval` value object) so it serializes
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
* from it via `SessionApproval.multiple`.
*/
export interface ForwardedSessionApproval {
surface: string;
patterns: readonly string[];
}
/**
* The child-fixed facts a gate emits: the surface it evaluated and the match
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
*
* Strings only — an `AccessPath` never crosses onto the wire
* (`docs/decisions/0002-path-values-string-boundary.md`).
*/
export interface ForwardedAccessFacts {
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
surface: string;
/**
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
* (absolute cwd-relative canonical), computed at the child. Non-path
* surface: the already-portable single value as a one-element array.
*/
matchValues: string[];
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
boundaryValue: string | null;
}
/**
* The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
* plus the requester identity the escalation edge stamps.
*
* The serving node resolves against this intent directly (Step 3, [#597]),
* using `matchValues` as-is — it never re-derives a path through its own
* `PathNormalizer`/cwd. See
* `docs/decisions/0008-cross-session-access-intent.md`.
*/
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
/** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
requesterCwd: string;
/** Who is requesting. */
principal: {
sessionId: string;
agentName: string;
};
}
export type ForwardedPermissionRequest = {
id: string;
createdAt: number;
requesterSessionId: string;
targetSessionId: string;
requesterAgentName: string;
/**
* The child's complete prompt payload (ADR 0011 §2), so the serving node
* renders the child's own facts under the *parent's* budget rather than
* relaying a sentence the child assembled under its own configuration.
*
* Optional for version-skew tolerance: an older child omits it, and the
* serving node renders from the display fields it does carry (ADR 0011 §9).
*/
payload?: PromptPayload;
/**
* Original prompt display fields, persisted so the parent emits a
* non-degraded event. Optional for version-skew tolerance: a parent on a
* newer version may read a request written by an older child during an
* upgrade, in which case the reader defaults `source` to `"tool_call"`.
*/
source?: PermissionUiPromptSource;
surface?: string | null;
value?: string | null;
/**
* The child's session-approval suggestion. Present when the child computed a
* "for this session" pattern for the ask; lets the serving node record a
* whole-session grant. Optional for version-skew tolerance (an older child
* omits it, and the serving dialog then offers no scope choice).
*/
sessionApproval?: ForwardedSessionApproval;
/**
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
* tolerance: an older child omits it, and the serving node floors to `ask`
* (Step 3). Present on a current child's request for every gate surface.
*/
accessIntent?: ForwardedAccessIntent;
};
export type ForwardedPermissionResponse = {
approved: boolean;
state: PermissionDecisionState;
denialReason?: string;
responderSessionId: string;
respondedAt: number;
/**
* What decided, inside the responding session (#726).
*
* `responderSessionId` names *where* the decision was made; this names
* *what* made it, which is the difference between a human at the parent's
* dialog and the parent's policy answering on their behalf.
*
* Optional for version-skew tolerance: an older responder omits it, and the
* requester records the hop with a `null` inner decision rather than
* rejecting the answer.
*/
decidedBy?: DecisionSource;
};
export type PermissionForwardingLocation = {
sessionId: string;
sessionRootDir: string;
requestsDir: string;
responsesDir: string;
label: "primary";
};
export function normalizePermissionForwardingSessionId(
value: unknown,
): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed || trimmed.toLowerCase() === "unknown") {
return null;
}
return trimmed;
}
/**
* Make a session id safe to name a path segment.
*
* Exported because the forwarding tree has two layouts keyed by session id —
* `sessions/<id>/` and the serving-heartbeat records beside it — and a second
* encoding would be a silent way for the two to disagree about which file
* belongs to which session.
*/
export function encodeSessionIdForPath(sessionId: string): string {
return encodeURIComponent(sessionId);
}
export function createPermissionForwardingLocation(
forwardingRootDir: string,
sessionId: string,
): PermissionForwardingLocation {
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
if (!normalizedSessionId) {
throw new Error(
"Permission forwarding session id must be a non-empty string.",
);
}
const sessionRootDir = join(
forwardingRootDir,
SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
encodeSessionIdForPath(normalizedSessionId),
);
return {
sessionId: normalizedSessionId,
sessionRootDir,
requestsDir: join(
sessionRootDir,
SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME,
),
responsesDir: join(
sessionRootDir,
SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME,
),
label: "primary",
};
}
/**
* How a forwarding target was resolved.
*
* `"registry"` is the load-bearing value: it means the requester is an
* **in-process** child of `sessionId`, so the two share a `globalThis` and the
* requester may consult the serving-session registry to decide whether anyone
* is draining its inbox. `"env"` means the target lives in another process,
* where that signal is unavailable; `"self"` is the UI host owning its own
* forwarding location.
*/
export type PermissionForwardingTargetSource = "self" | "registry" | "env";
/** The resolved forwarding target together with how it was found. */
export interface PermissionForwardingTarget {
sessionId: string;
source: PermissionForwardingTargetSource;
}
export function resolvePermissionForwardingTarget(options: {
hasUI: boolean;
isSubagent: boolean;
currentSessionId?: string | null;
env?: NodeJS.ProcessEnv;
/** Child session id for registry lookup. */
sessionId?: string;
/** In-process subagent session registry (checked before env vars). */
registry?: SubagentSessionRegistry;
}): PermissionForwardingTarget | null {
if (options.hasUI) {
const own = normalizePermissionForwardingSessionId(
options.currentSessionId,
);
return own === null ? null : { sessionId: own, source: "self" };
}
if (!options.isSubagent) {
return null;
}
// 1. Registry — in-process subagents register parentSessionId explicitly.
if (options.registry && options.sessionId) {
const entry = options.registry.get(options.sessionId);
const resolved = normalizePermissionForwardingSessionId(
entry?.parentSessionId,
);
if (resolved) return { sessionId: resolved, source: "registry" };
}
// 2. Env vars — process-based subagent extensions.
const env = options.env ?? process.env;
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
const resolved = normalizePermissionForwardingSessionId(env[key]);
if (resolved) return { sessionId: resolved, source: "env" };
}
return null;
}
export function isForwardedPermissionRequestForSession(
request: Pick<ForwardedPermissionRequest, "targetSessionId">,
sessionId: string | null | undefined,
): boolean {
const normalizedRequestSessionId = normalizePermissionForwardingSessionId(
request.targetSessionId,
);
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
return (
normalizedRequestSessionId !== null &&
normalizedRequestSessionId === normalizedSessionId
);
}
@@ -0,0 +1,409 @@
import type {
ExtensionContext,
ExtensionUIContext,
KeybindingsManager,
} from "@earendil-works/pi-coding-agent";
import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
import { collapsePastedNewlines } from "#src/authority/bracketed-paste";
import type {
DecisionSource,
UserDecisionSurface,
} from "#src/authority/decision-source";
import {
type PermissionPromptDecision,
type RequestPermissionOptions,
requestPermissionDecisionFromUi,
type UnattributedDecision,
} from "#src/authority/permission-dialog";
import {
initialPromptState,
type PromptEvent,
type PromptKey,
type PromptModelConfig,
type PromptViewState,
reducePrompt,
} from "#src/authority/permission-prompt-decision";
import {
completeViewBudget,
type DialogView,
type RenderBudget,
renderPromptDialog,
} from "#src/presentation/dialog-renderer";
import { fitLinesToWidth } from "#src/presentation/line-fitting";
import type { PromptPayload } from "#src/presentation/prompt-payload";
/**
* Inline `ctx.ui.custom` permission dialog for TUI sessions.
*
* All interaction logic lives in the pure {@link reducePrompt} model; this
* module is the thin adapter that renders the model's state to lines, maps raw
* keystrokes to {@link PromptEvent}s, and resolves the `ctx.ui.custom` promise
* with the committed {@link PermissionPromptDecision}. The component renders
* inline (never as an overlay).
*/
/** The subset of the session UI surface the inline dialog needs. */
export type PermissionPromptUi = Pick<
ExtensionUIContext,
"select" | "input" | "custom" | "getToolsExpanded" | "setToolsExpanded"
>;
/** The keybindings surface the dialog consults; only `matches` is read (ISP). */
type PromptKeybindings = Pick<KeybindingsManager, "matches">;
/** The resolved presentation context selected once per activation. */
export interface PermissionPromptView extends PromptPreferences {
mode: ExtensionContext["mode"];
ui: PermissionPromptUi;
}
/** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
export interface PromptPreferences {
doublePressToConfirm: boolean;
/** How much room a render has; the terminal width is added per frame. */
budget: RenderBudget;
}
/**
* Route a permission ask to the inline keybind dialog in TUI mode, or the
* `select()`/`input()` flow otherwise (RPC / frontend — the #519 constraint).
*
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
* one place so the fallback and the inline component never both render.
*
* It is therefore also the one place that knows which surface the human
* answered on, so it is where the decision is attributed to that surface
* (#726). Having the dialog model and the fallback each name themselves would
* be two sites that must agree with this branch.
*/
export async function requestPermissionDecision(
view: PermissionPromptView,
title: string,
payload: PromptPayload,
options?: RequestPermissionOptions,
): Promise<PermissionPromptDecision> {
if (view.mode === "tui") {
return attributeToHuman(
await presentInlinePermissionPrompt(view, title, payload, options),
"dialog",
);
}
// The fallback renders once and cannot re-render, so it neither paints nor
// offers an expansion; it substitutes a nominal width for the terminal size
// it is never told, and the host's own select wraps from there.
const rendered = renderPromptDialog(payload, {
...view.budget,
width: FALLBACK_RENDER_WIDTH,
});
return attributeToHuman(
await requestPermissionDecisionFromUi(
view.ui,
title,
rendered.lines.join("\n"),
options,
),
"select",
);
}
function attributeToHuman(
decision: UnattributedDecision,
via: UserDecisionSurface,
): PermissionPromptDecision {
const decidedBy: DecisionSource = { kind: "user", via };
return { ...decision, decidedBy };
}
/** The width the `select`/`input` fallback renders against. */
const FALLBACK_RENDER_WIDTH = 80;
/** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
interface PromptTheme {
fg(color: string, text: string): string;
}
const DEFAULT_SESSION_LABEL = "Yes, for this session";
const OPTION_LABELS: Record<PromptKey, string> = {
y: "Yes",
s: DEFAULT_SESSION_LABEL,
n: "No",
r: "No, provide reason",
};
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
export function presentInlinePermissionPrompt(
view: PermissionPromptView,
title: string,
payload: PromptPayload,
options?: RequestPermissionOptions,
): Promise<UnattributedDecision> {
const config: PromptModelConfig = {
doublePressToConfirm: view.doublePressToConfirm,
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
sessionScope: options?.sessionScope,
};
return view.ui.custom<UnattributedDecision>(
(tui, theme, keybindings, done) =>
new PermissionPromptComponent(
theme,
config,
title,
payload,
view.budget,
(data) => handleToolsExpandAction(data, keybindings, view.ui),
() => {
tui.requestRender();
},
done,
),
{ overlay: false },
);
}
/**
* Forward Pi's tool-expansion action while the dialog holds keyboard focus.
*
* A focused `ctx.ui.custom` component consumes every keystroke, so `Ctrl+O`
* would otherwise be dead for the duration of an ask — exactly when the user
* most needs to see the full pending tool invocation. Returns `true` when the
* keystroke was the action (and was handled), so the caller stops before
* mapping it to a {@link PromptEvent}; expansion is a display concern and must
* never reach the decision model.
*
* Deliberately does not request a render: `setToolsExpanded` re-renders the
* host itself, and the dialog's own lines are unaffected by tool expansion.
*/
function handleToolsExpandAction(
data: string,
keybindings: PromptKeybindings,
ui: PermissionPromptUi,
): boolean {
if (!keybindings.matches(data, "app.tools.expand")) {
return false;
}
ui.setToolsExpanded(!ui.getToolsExpanded());
return true;
}
class PermissionPromptComponent implements Component {
private state: PromptViewState;
/** The denial-reason line editor, rebuilt each time the step is entered. */
private reason: Input;
/** Whether the operator asked to see the complete request (ADR 0011 §4). */
private expanded = false;
constructor(
private readonly theme: PromptTheme,
private readonly config: PromptModelConfig,
private readonly title: string,
private readonly payload: PromptPayload,
private readonly budget: RenderBudget,
private readonly handleAppAction: (data: string) => boolean,
private readonly requestRender: () => void,
private readonly done: (decision: UnattributedDecision) => void,
) {
this.state = initialPromptState(config);
this.reason = this.createReasonEditor();
}
/**
* A fresh editor per visit to the reason step.
*
* The framework editor carries an undo stack and a kill ring, so reusing one
* instance would let a reason the operator backed out of be restored into a
* later ask.
*/
private createReasonEditor(): Input {
const editor = new Input();
// Emits pi-tui's zero-width cursor marker, which positions the hardware
// cursor for IME composition.
editor.focused = true;
editor.onSubmit = (draft) => {
this.apply({ type: "submitReason", draft });
};
editor.onEscape = () => {
this.apply({ type: "cancel" });
};
return editor;
}
invalidate(): void {
// No cached rendering state to clear.
}
render(width: number): string[] {
return fitLinesToWidth(this.renderStep(width), width);
}
private renderStep(width: number): string[] {
switch (this.state.step) {
case "decision":
return this.renderDecision(width);
case "reason":
return this.renderReason(width);
case "scope":
return this.renderScope();
}
}
/**
* The ask itself, bounded to the budget at this frame's width.
*
* Rendered per frame rather than once, because the row budget is a function
* of the width the host gives us, which a resize changes.
*/
private renderAsk(width: number): DialogView {
return renderPromptDialog(
this.payload,
this.expanded ? completeViewBudget(width) : { ...this.budget, width },
(text) => this.theme.fg("warning", text),
);
}
/**
* The key hints, naming the expansion only when it would do something.
*
* An affordance advertised when there is nothing to expand is noise; one
* left unadvertised when the render dropped something is a decision made
* without the evidence.
*/
private hint(view: DialogView): string {
const keys = [
"↑/↓ move",
"enter confirm",
"esc deny",
"press a letter, then again to confirm",
];
if (this.expanded) {
keys.push("ctrl+o collapse");
} else if (view.elided) {
keys.push("ctrl+o full request");
}
return this.theme.fg("muted", keys.join(" · "));
}
handleInput(data: string): void {
if (this.state.step === "reason") {
this.handleReasonInput(data);
return;
}
if (this.handleAppAction(data)) {
// One "expand" for the operator: the host expands its pending tool call
// and the dialog expands its own render, on the same keystroke.
this.expanded = !this.expanded;
this.requestRender();
return;
}
const event = this.toEvent(data);
if (event) {
this.apply(event);
}
}
/**
* Hand the keystroke to the framework line editor.
*
* Delegating is what makes the field accept a paste: a paste arrives as one
* multi-character chunk wrapped in bracketed-paste markers, which the editor
* understands and a per-character reader cannot. Submit and cancel come back
* through the editor's callbacks, so the decision model still owns them.
*/
private handleReasonInput(data: string): void {
this.reason.handleInput(collapsePastedNewlines(data));
// The editor mutates its own buffer silently; only the dialog can repaint.
this.requestRender();
}
private toEvent(data: string): PromptEvent | undefined {
if (matchesKey(data, "up") || matchesKey(data, "k")) {
return { type: "nav", direction: "up" };
}
if (matchesKey(data, "down") || matchesKey(data, "j")) {
return { type: "nav", direction: "down" };
}
if (matchesKey(data, "enter")) {
return { type: "confirm" };
}
if (matchesKey(data, "escape")) {
return { type: "cancel" };
}
if (this.state.step === "decision") {
const key = OPTION_ORDER.find((option) => matchesKey(data, option));
if (key) {
return { type: "hotkey", key };
}
}
return undefined;
}
private apply(event: PromptEvent): void {
const outcome = reducePrompt(this.config, this.state, event);
if (outcome.kind === "decision") {
this.done(outcome.decision);
return;
}
if (outcome.state.step === "reason" && this.state.step !== "reason") {
this.reason = this.createReasonEditor();
}
this.state = outcome.state;
this.requestRender();
}
private renderDecision(width: number): string[] {
const ask = this.renderAsk(width);
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
for (const key of OPTION_ORDER) {
const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
const selected = this.state.highlightedKey === key;
const marker = selected ? "▶" : " ";
const row = `${marker} (${key}) ${label}`;
lines.push(selected ? this.theme.fg("accent", row) : row);
}
lines.push("");
lines.push(this.state.hint || this.hint(ask));
return lines;
}
private renderReason(width: number): string[] {
const lines = [
this.theme.fg("accent", this.title),
...this.renderAsk(width).lines,
"",
"Reason (required):",
// Exactly one row, whatever its length: the editor scrolls horizontally.
...this.reason.render(width),
];
if (this.state.reasonError) {
lines.push(this.theme.fg("error", this.state.reasonError));
}
lines.push("");
lines.push(this.theme.fg("muted", "enter submit · esc back"));
return lines;
}
private renderScope(): string[] {
const scope = this.config.sessionScope;
const subagentLabel = scope?.subagentLabel ?? "This subagent only";
const servingLabel = scope?.servingSessionLabel ?? "The whole session";
const rows: Array<{ label: string; serving: boolean }> = [
{ label: subagentLabel, serving: false },
{ label: servingLabel, serving: true },
];
const lines = [
this.theme.fg("accent", this.title),
"Apply this session grant to:",
"",
];
for (const row of rows) {
const selected = this.state.scopeServing === row.serving;
const marker = selected ? "▶" : " ";
const text = `${marker} ${row.label}`;
lines.push(selected ? this.theme.fg("accent", text) : text);
}
lines.push("");
lines.push(this.theme.fg("muted", "↑/↓ move · enter confirm · esc back"));
return lines;
}
}
@@ -0,0 +1,257 @@
import {
createDeniedPermissionDecision,
normalizePermissionDenialReason,
type RequestPermissionOptions,
type UnattributedDecision,
} from "#src/authority/permission-dialog";
/**
* Pure decision model for the inline keybind permission dialog.
*
* The interaction logic — which hotkey produces which decision, double-press
* arming, step transitions, and reason validation — lives here with no SDK or
* TUI imports, so it is unit-testable directly. The `ctx.ui.custom` component
* ({@link file://./permission-prompt-component.ts}) is a thin adapter that
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
*/
/** The four decision hotkeys, in display order. */
export type PromptKey = "y" | "s" | "n" | "r";
/** Which sub-view the dialog is showing. */
export type PromptStep = "decision" | "reason" | "scope";
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
const OPTION_VERBS: Record<PromptKey, string> = {
y: "approve",
s: "approve for this session",
n: "deny",
r: "deny with a reason",
};
/** Static configuration for a single prompt presentation. */
export interface PromptModelConfig {
/** When true, a letter hotkey arms first and commits only on a second press. */
doublePressToConfirm: boolean;
/** Label shown beside the approve-for-session option. */
sessionLabel: string;
/**
* Forwarded asks only: when set, confirming `s` opens a second step choosing
* whether the grant applies to the requesting subagent only (least-privilege
* default) or the whole serving session.
*/
sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
}
/** The re-render view state the component draws from. */
export interface PromptViewState {
step: PromptStep;
highlightedKey: PromptKey;
/** Set only while awaiting the confirming second press of a hotkey. */
armedKey?: PromptKey;
/** "Press y again to approve." while armed; empty otherwise. */
hint: string;
/** Set when an empty reason submit is rejected. */
reasonError?: string;
/** Scope step: false = subagent-only (default), true = whole serving session. */
scopeServing: boolean;
}
/** An input event the reducer understands. */
export type PromptEvent =
| { type: "nav"; direction: "up" | "down" }
| { type: "hotkey"; key: PromptKey }
| { type: "confirm" }
| { type: "cancel" }
| { type: "submitReason"; draft: string };
/** Either a re-render or a terminal decision. */
export type PromptOutcome =
| { kind: "render"; state: PromptViewState }
| { kind: "decision"; decision: UnattributedDecision };
export function initialPromptState(
_config: PromptModelConfig,
): PromptViewState {
return {
step: "decision",
highlightedKey: "y",
armedKey: undefined,
hint: "",
reasonError: undefined,
scopeServing: false,
};
}
/**
* Advance the dialog by one input event, returning either the next view state
* to render or the committed {@link UnattributedDecision}.
*
* The model states the outcome and not the decider: which human surface this
* is gets attributed by the dispatcher that chose to render this dialog, so
* the two cannot disagree about the surface.
*/
export function reducePrompt(
config: PromptModelConfig,
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
switch (state.step) {
case "decision":
return reduceDecisionStep(config, state, event);
case "reason":
return reduceReasonStep(state, event);
case "scope":
return reduceScopeStep(state, event);
}
}
function reduceDecisionStep(
config: PromptModelConfig,
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
switch (event.type) {
case "nav":
return render({
...state,
highlightedKey: shiftKey(state.highlightedKey, event.direction),
armedKey: undefined,
hint: "",
});
case "hotkey":
return pressHotkey(config, state, event.key);
case "confirm":
return commit(config, state, state.highlightedKey);
case "cancel":
return { kind: "decision", decision: createDeniedPermissionDecision() };
case "submitReason":
return render(state);
}
}
function pressHotkey(
config: PromptModelConfig,
state: PromptViewState,
key: PromptKey,
): PromptOutcome {
if (!config.doublePressToConfirm || state.armedKey === key) {
return commit(config, state, key);
}
return render({
...state,
highlightedKey: key,
armedKey: key,
hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
});
}
function commit(
config: PromptModelConfig,
state: PromptViewState,
key: PromptKey,
): PromptOutcome {
switch (key) {
case "y":
return {
kind: "decision",
decision: { approved: true, state: "approved" },
};
case "n":
return { kind: "decision", decision: createDeniedPermissionDecision() };
case "r":
return render({
...state,
step: "reason",
highlightedKey: "r",
armedKey: undefined,
hint: "",
reasonError: undefined,
});
case "s":
if (config.sessionScope) {
return render({
...state,
step: "scope",
highlightedKey: "s",
armedKey: undefined,
hint: "",
scopeServing: false,
});
}
return {
kind: "decision",
decision: { approved: true, state: "approved_for_session" },
};
}
}
function reduceReasonStep(
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
if (event.type === "cancel") {
return render({
...state,
step: "decision",
armedKey: undefined,
hint: "",
reasonError: undefined,
});
}
if (event.type === "submitReason") {
const reason = normalizePermissionDenialReason(event.draft);
if (reason === undefined) {
return render({
...state,
reasonError: "A reason is required.",
});
}
return {
kind: "decision",
decision: createDeniedPermissionDecision(reason),
};
}
return render(state);
}
function reduceScopeStep(
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
switch (event.type) {
case "nav":
return render({ ...state, scopeServing: event.direction === "down" });
case "confirm":
return {
kind: "decision",
decision: {
approved: true,
state: state.scopeServing
? "approved_for_serving_session"
: "approved_for_session",
},
};
case "cancel":
return render({
...state,
step: "decision",
armedKey: undefined,
hint: "",
});
default:
return render(state);
}
}
function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
const index = OPTION_ORDER.indexOf(current);
const delta = direction === "down" ? 1 : -1;
const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
return OPTION_ORDER[next] ?? current;
}
function render(state: PromptViewState): PromptOutcome {
return { kind: "render", state };
}
@@ -0,0 +1,173 @@
import type { DecisionSource } from "#src/authority/decision-source";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type {
ForwardedAccessFacts,
ForwardedSessionApproval,
} from "#src/authority/permission-forwarding";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
import type { ReviewLogger } from "#src/session-logger";
import type { TerminalAuthorizer } from "./authorizer";
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
/**
* Provenance of a forwarded ask: who is really asking, one hop below.
*
* Present on {@link PromptPermissionDetails} only when the ask was forwarded
* from a subagent. Structurally identical to the event's `ForwardedPromptContext`
* so the details flow straight into `buildUiPrompt`, but declared here to keep
* the prompter layer free of an events-module import.
*/
export interface ForwardedAskProvenance {
requesterAgentName: string | null;
requesterSessionId: string | null;
}
/** Details passed when prompting the user for a permission decision. */
export interface PromptPermissionDetails {
requestId: string;
source: PermissionReviewSource;
agentName: string | null;
/**
* The complete structured description of this ask (ADR 0011 §2).
*
* Required: every ask carries one, and the type is what guarantees it rather
* than a convention each gate has to remember. Every consumer — the dialog,
* the wire, the broadcast, the review log, the agent-facing denial text — is
* a render over it, so no two of them can disagree.
*/
payload: PromptPayload;
toolCallId?: string;
toolName?: string;
skillName?: string;
path?: string;
command?: string;
target?: string;
toolInputPreview?: string;
/** Override label for the "for this session" dialog option. */
sessionLabel?: string;
/** Explicit display-surface override (a forwarded ask carries the child's original). */
surface?: string | null;
/** Explicit display-value override (a forwarded ask carries the child's original). */
value?: string | null;
/** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
forwarding?: ForwardedAskProvenance;
/**
* The session-approval suggestion for this ask. On the child's escalation it
* rides into the forwarded request; on the serving node it lets the dialog
* offer a whole-session grant scope. Absent when the gate computed no
* suggestion.
*/
sessionApproval?: ForwardedSessionApproval;
/**
* The child-fixed access facts the raising gate computed (surface + match
* set). Rides through the runner to the escalation edge, which completes
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
* `principal`. On a serving node these facts are projected back off the
* forwarded request, so a forwarded ask reaches the `Authorizer` chain with
* the same evidence as a local one; only a version-skew request that carried
* no intent leaves this absent.
*/
accessIntent?: ForwardedAccessFacts;
}
/**
* Narrow seam onto {@link PermissionPrompter}.
*
* Kept separate from the concrete class so consumers (e.g. `AuthorizerSelection`)
* can inject a plain `{ prompt: vi.fn() }` mock in tests — a private field on
* the concrete class would create a nominal brand that a structural mock
* cannot satisfy without a cast.
*/
export interface PermissionPrompterApi {
prompt(
authorizer: TerminalAuthorizer,
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
}
/** Dependencies required by {@link PermissionPrompter}. */
export interface PermissionPrompterDeps {
/** Write structured entries to the permission review log. */
logger: ReviewLogger;
}
/**
* Brackets the ask-path flow with review-log entries and delegates the
* live decision to the selected {@link TerminalAuthorizer}:
* 1. Review-log "waiting" entry.
* 2. `authorizer.authorize(details)`.
* 3. Review-log "approved" / "denied" entry.
*
* The UI/forwarding branching this class previously owned now lives on the
* individual `Authorizer` implementations (`LocalUserAuthorizer`,
* `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
* `ExtensionContext` per call.
*
* Yolo-mode auto-approval happens upstream: at the composition stage
* (`PermissionManager.check`'s `rewriteAsksToYolo`) for a rule-driven ask, and
* at `GateRunner`'s auto-approve fast path (`resolveYoloGrant`) for an ask
* synthesized after resolution, which no rule rewrite can reach (#712) — an
* `ask` never reaches this class under yolo, so it has no yolo-mode knowledge.
*/
export class PermissionPrompter implements PermissionPrompterApi {
constructor(private readonly deps: PermissionPrompterDeps) {}
async prompt(
authorizer: TerminalAuthorizer,
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
this.writeReviewEntry("permission_request.waiting", details);
const decision = await authorizer.authorize(details);
this.writeReviewEntry(
decision.approved
? "permission_request.approved"
: "permission_request.denied",
{
...details,
resolution: decision.confirmationUnavailable
? "confirmation_unavailable"
: decision.state,
denialReason: decision.denialReason,
decidedBy: decision.decidedBy,
},
);
return decision;
}
// ── Private helpers ──────────────────────────────────────────────────────
/**
* The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
* a `null` there would read as "decided by nobody" rather than "not yet".
*/
private writeReviewEntry(
event: string,
details: PromptPermissionDetails & {
resolution?: string;
denialReason?: string;
decidedBy?: DecisionSource;
},
): void {
this.deps.logger.review(event, {
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
requestId: details.requestId,
source: details.source,
agentName: details.agentName,
...renderReviewLogFacts(details.payload),
toolCallId: details.toolCallId ?? null,
toolName: details.toolName ?? null,
skillName: details.skillName ?? null,
path: details.path ?? null,
command: details.command ?? null,
target: details.target ?? null,
toolInputPreview: details.toolInputPreview ?? null,
resolution: details.resolution ?? null,
denialReason: details.denialReason ?? null,
});
}
}
@@ -0,0 +1,141 @@
/**
* serving-registry.ts — Which sessions are draining a forwarded-permission inbox.
*
* A session with a UI that is not itself a subagent polls its own
* `<forwardingDir>/sessions/<id>/requests/` directory (see `ForwardingManager`)
* and answers whatever a child forwards to it. Nothing else in the process can
* observe that, so a child whose parent is *not* polling has no way to tell
* "a human is being asked" from "nobody is home", and waits out the full
* forwarding timeout before denying (#719).
*
* This registry publishes that fact: the polling session marks itself while it
* polls, and a forwarding child checks whether its resolved target is marked.
*
* The single instance is stored on `globalThis` (via `Symbol.for()`) for the
* same reason `SubagentSessionRegistry` is: each session's `ResourceLoader`
* creates its own jiti instance and its own event bus, so the parent's
* permission-system instance and an in-process child's instance share no
* module state — only process globals. See `getServingSessionRegistry()`.
*
* The signal is meaningful only for an **in-process** child (one that resolved
* its target through `SubagentSessionRegistry`, i.e. a forwarding target with
* `source: "registry"`). A child in another process shares no `globalThis` with
* its parent and must not read anything into an absent mark.
*/
/** Process-global key for the shared registry slot. Exported for test teardown. */
export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
"@gotgenes/pi-permission-system:serving-registry",
);
/**
* Announce-side seam: the polling session marks and clears itself.
*
* `ForwardingManager` depends on this rather than the concrete registry so it
* neither reads the store nor gains a query it has no business making (ISP).
*/
export interface ServingAnnouncer {
/**
* Record that `sessionId` is polling its inbox.
*
* Idempotent, and called on every poll tick rather than once per session: an
* announcement that can decay (the filesystem heartbeat) has to be kept
* current, and one that cannot (this registry) costs a set insertion to say
* so again.
*/
markServing(sessionId: string): void;
clearServing(sessionId: string): void;
}
/**
* Fan an announcement out to every channel a serving session publishes on.
*
* A session announces to the process-global registry (for its in-process
* children) and to the filesystem (for children in other processes). Composing
* them keeps `ForwardingManager` holding one collaborator, so adding or
* removing a channel never reaches the poll loop.
*/
export function composeServingAnnouncers(
...announcers: readonly ServingAnnouncer[]
): ServingAnnouncer {
return {
markServing(sessionId: string): void {
for (const announcer of announcers) {
announcer.markServing(sessionId);
}
},
clearServing(sessionId: string): void {
for (const announcer of announcers) {
announcer.clearServing(sessionId);
}
},
};
}
/**
* Query-side seam: a forwarding child asks whether its target is draining.
*
* `servingIds()` exists for the diagnostic review entry a child writes when it
* abandons an unserved request — the mismatch between the id it forwarded to
* and the ids actually being served is the whole diagnosis.
*/
export interface ServingLookup {
isServing(sessionId: string): boolean;
servingIds(): readonly string[];
}
/**
* Registry of sessions currently draining a forwarded-permission inbox.
*
* A process-global singleton — obtain it via {@link getServingSessionRegistry},
* never `new` (see that accessor for why). Written exclusively by the owning
* session's `ForwardingManager`, keyed by that session's own id, so one
* session's shutdown cannot clear another's mark.
*
* A mark left behind by a session that died without `session_shutdown` makes a
* child wait out the full timeout instead of abandoning early — the same
* behavior as before this signal existed, which is the safe direction to fail.
*/
export class ServingSessionRegistry implements ServingAnnouncer, ServingLookup {
private readonly serving = new Set<string>();
/** Record that `sessionId` is polling its inbox. Idempotent. */
markServing(sessionId: string): void {
this.serving.add(sessionId);
}
/** Record that `sessionId` has stopped polling. No-op if unmarked. */
clearServing(sessionId: string): void {
this.serving.delete(sessionId);
}
/** Return `true` when `sessionId` is currently polling its inbox. */
isServing(sessionId: string): boolean {
return this.serving.has(sessionId);
}
/** Every currently-serving session id, for diagnostics. */
servingIds(): readonly string[] {
return [...this.serving];
}
}
/**
* Return the process-global ServingSessionRegistry, creating it on first call.
*
* Intentionally has no teardown hook: a child's `session_shutdown` must not be
* able to wipe the parent's mark. Entries are added and removed exclusively by
* the owning session's `ForwardingManager`.
*/
export function getServingSessionRegistry(): ServingSessionRegistry {
const store = globalThis as Record<symbol, unknown>;
const existing = store[SERVING_SESSION_REGISTRY_KEY] as
| ServingSessionRegistry
| undefined;
if (existing) {
return existing;
}
const registry = new ServingSessionRegistry();
store[SERVING_SESSION_REGISTRY_KEY] = registry;
return registry;
}
@@ -0,0 +1,87 @@
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import type { PathFlavor } from "#src/path/path-flavor";
/**
* Narrow context for subagent detection — the only session-manager readers
* {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
* consume. A full `ExtensionContext` satisfies this structurally.
*/
export interface SubagentDetectionContext {
sessionManager: {
getSessionId(): string;
getSessionDir(): string;
};
}
export function normalizeFilesystemPath(
pathValue: string,
flavor: PathFlavor,
): string {
return flavor.fold(flavor.impl.normalize(pathValue));
}
/**
* Return `true` when `ctx` belongs to an in-process subagent child registered
* in `registry` by its session id.
*
* This is the only signal that identifies an **in-process** child (one sharing
* the parent's `globalThis`); env-hint and filesystem heuristics identify
* **process-based** subagents instead. The composition root uses this to decide
* whether the instance owns the process-global service slot — a registered
* child must not publish over its parent.
*/
export function isRegisteredSubagentChild(
ctx: SubagentDetectionContext,
registry: SubagentSessionRegistry,
): boolean {
try {
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) {
return false;
}
return registry.has(sessionId);
} catch {
// getSessionId() unavailable — treat as not-a-registered-child.
return false;
}
}
export function isSubagentExecutionContext(
ctx: SubagentDetectionContext,
subagentSessionsDir: string,
flavor: PathFlavor,
registry?: SubagentSessionRegistry,
): boolean {
// 1. Explicit registry — in-process subagent extensions register by child
// session id before bindExtensions(); checked first so it takes priority
// over heuristics. Each concurrent sibling has a unique session id, so
// one sibling's disposed event cannot affect another's registration.
if (registry && isRegisteredSubagentChild(ctx, registry)) {
return true;
}
const sessionDir = ctx.sessionManager.getSessionDir();
// 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
// HazAT/pi-interactive-subagents, pi-agent-router, etc.).
for (const key of SUBAGENT_ENV_HINT_KEYS) {
const value = process.env[key];
if (typeof value === "string" && value.trim()) {
return true;
}
}
// 3. Filesystem path — fallback heuristic for extensions that store sessions
// under a known subagent root directory.
if (!sessionDir) {
return false;
}
const normalizedSessionDir = normalizeFilesystemPath(sessionDir, flavor);
const normalizedSubagentRoot = normalizeFilesystemPath(
subagentSessionsDir,
flavor,
);
return flavor.isWithin(normalizedSessionDir, normalizedSubagentRoot);
}
@@ -0,0 +1,66 @@
import {
isRegisteredSubagentChild,
isSubagentExecutionContext,
type SubagentDetectionContext,
} from "#src/authority/subagent-context";
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import type { PathFlavor } from "#src/path/path-flavor";
/**
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
*
* `selectAuthorizer`/`AuthorizerSelection` and `ForwardingManager` depend on
* this single-method view so their unit tests inject a one-field fake without
* casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
*/
export interface SubagentDetector {
isSubagent(ctx: SubagentDetectionContext): boolean;
}
/**
* Narrow seam for the service-publication guard (#302): "is the current
* session a registered in-process child?"
*
* `PermissionServiceLifecycle` depends on this single-method view so a
* registered child never publishes over its parent's process-global slot.
*/
export interface RegisteredChildDetector {
isRegisteredChild(ctx: SubagentDetectionContext): boolean;
}
/** Composition-root inputs for {@link SubagentDetection}. */
export interface SubagentDetectionDeps {
subagentSessionsDir: string;
flavor: PathFlavor;
registry?: SubagentSessionRegistry;
}
/**
* Single owner of subagent detection.
*
* Constructed once in the composition root with the detection inputs
* (`subagentSessionsDir`, `flavor`, `registry`) and shared across every
* consumer, replacing the dep triple those consumers previously threaded
* individually. Delegates to the pure detection functions in
* {@link ./subagent-context}, holding only the deps.
*/
export class SubagentDetection
implements SubagentDetector, RegisteredChildDetector
{
constructor(private readonly deps: SubagentDetectionDeps) {}
isSubagent(ctx: SubagentDetectionContext): boolean {
return isSubagentExecutionContext(
ctx,
this.deps.subagentSessionsDir,
this.deps.flavor,
this.deps.registry,
);
}
isRegisteredChild(ctx: SubagentDetectionContext): boolean {
return this.deps.registry
? isRegisteredSubagentChild(ctx, this.deps.registry)
: false;
}
}
@@ -0,0 +1,72 @@
/**
* subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
* lifecycle events and keep the SubagentSessionRegistry in sync.
*
* @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
* event bus (ADR 0002): it no longer calls this package's service directly.
* We register the child on `session-created` and unregister it on `disposed`.
*
* The channel names and payload shapes are declared independently here (the two
* packages must not depend on each other under jiti) and MUST match the
* publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
*
* The `session-created` handler MUST stay synchronous: the core emits it on the
* same synchronous call stack immediately before `bindExtensions()`, and the
* event bus dispatches listeners synchronously, so a synchronous handler lands
* the registry entry before binding proceeds. Introducing an `await` before
* `registry.register(...)` would break the pre-bind ordering.
*/
import type { SubagentSessionRegistry } from "./subagent-registry";
/** Emitted by the core after session creation, before `bindExtensions()`. */
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
/** Emitted by the core in the run's `finally` (success and error). */
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
/** Minimal event-bus surface this module needs (subscribe only). */
interface LifecycleEventBus {
on(channel: string, handler: (data: unknown) => void): () => void;
}
/** Fields read from the `session-created` payload (ISP). */
interface ChildSessionCreatedEvent {
/** Child session id — the registry key. Must match the publisher. */
sessionId: string;
parentSessionId?: string;
}
/** Fields read from the `disposed` payload (ISP). */
interface ChildDisposedEvent {
/** Child session id — the registry key. Must match the publisher. */
sessionId: string;
}
/**
* Subscribe to the subagent child lifecycle.
*
* @returns an unsubscribe that detaches both handlers (call during
* `session_shutdown`).
*/
export function subscribeSubagentLifecycle(
events: LifecycleEventBus,
registry: SubagentSessionRegistry,
): () => void {
const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
const event = data as ChildSessionCreatedEvent;
registry.register(event.sessionId, {
parentSessionId: event.parentSessionId,
});
});
const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
const event = data as ChildDisposedEvent;
registry.unregister(event.sessionId);
});
return () => {
unsubCreated();
unsubDisposed();
};
}
@@ -0,0 +1,105 @@
/**
* subagent-registry.ts — In-process subagent session registry.
*
* In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
* each child session here before calling `bindExtensions()` so that
* `isSubagentExecutionContext()` and permission-forwarding target resolution
* can detect them without relying on environment variables or filesystem
* heuristics.
*
* The registry is keyed by the child's **session id**, which is unique per
* child and available to both producer (via `sessionManager.getSessionId()`
* after `newSession()` in `create-subagent-session.ts`) and consumer (via
* `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
* parent therefore occupy distinct keys, so one sibling's `disposed` event
* cannot evict the entry the others depend on.
*
* The single registry instance is stored on `globalThis` (via `Symbol.for()`)
* so that the parent's permission-system instance (which registers children
* on the parent's event bus) and each child's separate jiti instance (which
* reads the registry to detect itself and resolve its forwarding target) share
* one store across per-session event buses. See `getSubagentSessionRegistry()`.
*
* When a future code path needs the child's agent name, read it from
* `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
* not from this registry.
*/
/** Process-global key for the shared registry slot. */
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
"@gotgenes/pi-permission-system:subagent-registry",
);
/**
* Return the process-global SubagentSessionRegistry, creating it on first call.
*
* Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
* instance (which registers children on the parent event bus) and each child's
* separate jiti instance (which reads the registry to detect itself and resolve
* its forwarding target) share one store across per-session event buses.
*
* Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
* must not be able to wipe the parent's registrations. Entries are added and
* removed exclusively by the parent's `subagents:child:session-created` /
* `subagents:child:disposed` subscription.
*/
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
const store = globalThis as Record<symbol, unknown>;
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
| SubagentSessionRegistry
| undefined;
if (existing) {
return existing;
}
const registry = new SubagentSessionRegistry();
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
return registry;
}
/** Signal stored per registered in-process subagent session. */
export interface SubagentSessionInfo {
/** Parent session ID for permission forwarding. Omit when unknown. */
parentSessionId?: string;
}
/**
* Registry of active in-process subagent sessions.
*
* A process-global singleton — obtain it via `getSubagentSessionRegistry()`,
* never `new` (see that accessor for why). Written exclusively by
* `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
* `subagents:child:disposed` event subscription (ADR 0002 — the core
* publishes, consumers observe).
*
* Keyed by child session id. Each concurrent child of the same parent receives
* a unique session id from `sessionManager.newSession()`, so siblings occupy
* distinct keys and one sibling's `disposed` cannot evict another's entry.
*/
export class SubagentSessionRegistry {
private readonly sessions = new Map<string, SubagentSessionInfo>();
/**
* Register an in-process subagent session.
*
* If a previous entry exists for `sessionId`, it is overwritten
* (last-write-wins; single-writer expected per key).
*/
register(sessionId: string, info: SubagentSessionInfo): void {
this.sessions.set(sessionId, info);
}
/** Remove a previously registered session. No-op if the key is absent. */
unregister(sessionId: string): void {
this.sessions.delete(sessionId);
}
/** Return the registered info for `sessionId`, or `undefined` if absent. */
get(sessionId: string): SubagentSessionInfo | undefined {
return this.sessions.get(sessionId);
}
/** Return `true` when `sessionId` has a registered entry. */
has(sessionId: string): boolean {
return this.sessions.has(sessionId);
}
}