@xzzpig/pi-openspec-x 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { loadOpsxConfig, type OpsxConfig } from "./config.ts";
4
+ import {
5
+ createOpsxAgentsSessionStartHandler,
6
+ registerOpsxAgents,
7
+ } from "./agents.ts";
8
+ import {
9
+ probeGoalXDependency,
10
+ probeSandboxDependency,
11
+ probeSubagentsDependency,
12
+ noteSandboxUnavailable,
13
+ describeError,
14
+ requireGoalXSupport,
15
+ } from "./dependencies.ts";
16
+ import { createResourcesDiscoverHandler } from "./discover.ts";
17
+ import { createOpsxRecoverySessionStartHandler } from "./goal-base.ts";
18
+ import {
19
+ registerOpsxAuditorResolver,
20
+ buildOpsxAuditorOverride,
21
+ } from "./final-gate.ts";
22
+ import {
23
+ registerFlowRenderers,
24
+ createStatusSnapshotTurnEndHandler,
25
+ type OpsxFlowState,
26
+ } from "./flow-entries.ts";
27
+ import {
28
+ registerImplementFlow,
29
+ activeImplementFlow,
30
+ } from "./implement-command.ts";
31
+ import { createOpsxSessionStartHandler } from "./mode.ts";
32
+ import { registerPlanFlow, activePlanSession } from "./plan-command.ts";
33
+ import { registerRollbackFlow } from "./rollback-command.ts";
34
+ import { registerPlanGateTools } from "./plan-gate.ts";
35
+ import { registerProgressObserver } from "./progress-observer.ts";
36
+ import { registerReportTools } from "./report-tools.ts";
37
+ import { buildSandboxProfiles } from "./sandbox-profiles.ts";
38
+
39
+ // Read once at extension load (merge semantics in loadOpsxConfig).
40
+ const config: OpsxConfig = loadOpsxConfig();
41
+
42
+ /**
43
+ * pi-openspec-x extension entry point.
44
+ *
45
+ * pi-sandbox is probed lazily through a dynamic import (dependencies.ts): on
46
+ * success the three opsx sandbox profiles (D4: opsx-planner / opsx-agent /
47
+ * opsx-reviewer) are registered into pi-sandbox's runtime registry — in-memory
48
+ * for the session lifetime, never written to a user's sandbox.json, a
49
+ * user-defined same-name profile always keeping priority. On failure the
50
+ * reason is recorded as dependency state and the extension continues: the
51
+ * official-track skills and every other capability keep working, and a later
52
+ * request for a restricted mode is refused with a typed
53
+ * OpsxDependencyMissingError (fail-closed) instead of the extension (or the
54
+ * official track) dying at load time.
55
+ *
56
+ * Registers the resources_discover handler, which:
57
+ * - generates (and caches, keyed by CLI version) the official-track
58
+ * openspec-* skills under `<agentDir>/cache/pi-openspec-x/skills/` and
59
+ * exposes the directory to pi via `skillPaths`;
60
+ * - warns once per project when static `<cwd>/.pi/skills/openspec-*`
61
+ * directories collide with the generated skills.
62
+ *
63
+ * Registers the session_start handler that clears a stale `active_agent`
64
+ * identity left in the session file by an earlier session (pi-agent-role's
65
+ * semantics; see mode.ts).
66
+ *
67
+ * Registers the three child reporting tools (report_gap_analysis,
68
+ * report_plan_review, report_work) that the opsx subagents submit their
69
+ * structured results through.
70
+ *
71
+ * pi-subagents is probed the same lazy way: on success the four opsx
72
+ * subagents (opsx-gap-analysis / opsx-plan-review / opsx-worker / opsx-reviewer) are
73
+ * registered into pi-subagents' runtime registry via the owner's event bus
74
+ * (D3). A missing pi-subagents costs only the enhanced flow's subagents —
75
+ * the official track and direct main-session implementation are unaffected.
76
+ * Because the owner listener only exists once pi-subagents has loaded (load
77
+ * order between extensions is not guaranteed), a failed init-time attempt is
78
+ * retried on session_start.
79
+ *
80
+ * Missing openspec CLI: no skills are registered and no error escapes — a
81
+ * one-time notice is sent instead.
82
+ */
83
+ export default async function piOpenspecX(pi: ExtensionAPI): Promise<void> {
84
+ const probe = await probeSandboxDependency();
85
+ if (probe.module) {
86
+ try {
87
+ probe.module.registerSandboxProfiles(buildSandboxProfiles(config));
88
+ } catch (error) {
89
+ // Invalid profile definitions are recorded like any other sandbox
90
+ // failure: the official track keeps working, restricted modes are
91
+ // refused with the actual reason.
92
+ noteSandboxUnavailable(
93
+ `registering the opsx sandbox profiles failed: ${describeError(error)}`,
94
+ );
95
+ }
96
+ }
97
+
98
+ registerReportTools(pi);
99
+ // Parent-side projection of the subagents' structured progress (task 9.2).
100
+ registerProgressObserver(pi);
101
+ // Progress lines for the flow entries (task 9.1).
102
+ registerFlowRenderers(pi);
103
+ // `/opsx:plan`: planner mode + append-only phase injection (task 7.1).
104
+ registerPlanFlow(pi);
105
+ // REJECT rollback with backup (spec: REJECT 回滚带备份).
106
+ registerRollbackFlow(pi);
107
+ // Parent-side plan gate tools: ledger recording + user approval (7.2/7.3).
108
+ registerPlanGateTools(pi);
109
+ // `/opsx:implement`: mode selection + goal-based implementation (task 8.1).
110
+ registerImplementFlow(pi);
111
+
112
+ await probeSubagentsDependency();
113
+ // Record goal-x availability for the /opsx:implement fail-closed gate
114
+ // (task 6.6). A missing/incompatible goal-x never blocks extension load.
115
+ const goalXProbe = await probeGoalXDependency();
116
+ if (goalXProbe.available) {
117
+ // S2 seam: make the custom opsx-reviewer visible to goal-x's completion
118
+ // audit preflight for the whole session (task 8.3). Ordinary goals never
119
+ // resolve through this resolver (it only answers "opsx-reviewer").
120
+ registerOpsxAuditorResolver(goalXProbe.module);
121
+ }
122
+ registerOpsxAgents(pi);
123
+ const disposeAgentsSessionStart = pi.on(
124
+ "session_start",
125
+ createOpsxAgentsSessionStartHandler(pi),
126
+ );
127
+
128
+ const disposeResourcesDiscover = pi.on(
129
+ "resources_discover",
130
+ createResourcesDiscoverHandler(pi),
131
+ );
132
+ const disposeSessionStart = pi.on(
133
+ "session_start",
134
+ createOpsxSessionStartHandler(pi),
135
+ );
136
+ // Read-only recovery entry: if the goal base holds an unfinished opsx
137
+ // implementation goal, point the user at goal-x's /goal-resume (task 6.4).
138
+ const disposeRecoverySessionStart = pi.on(
139
+ "session_start",
140
+ createOpsxRecoverySessionStartHandler(pi, {
141
+ // The S1 override is in-memory; re-install it (task 8.3).
142
+ installAuditor: (goalId) => {
143
+ requireGoalXSupport().setGoalAuditorOverride(goalId, {
144
+ ...buildOpsxAuditorOverride(),
145
+ });
146
+ },
147
+ }),
148
+ );
149
+ // Per-turn status snapshot (custom entry; survives compaction) — task 9.1.
150
+ const disposeStatusSnapshot = pi.on(
151
+ "turn_end",
152
+ createStatusSnapshotTurnEndHandler(
153
+ pi,
154
+ (sessionId): OpsxFlowState | undefined => {
155
+ const plan = activePlanSession(sessionId);
156
+ if (plan) return { mode: "plan", changeId: plan.changeId };
157
+ const impl = activeImplementFlow(sessionId);
158
+ if (impl)
159
+ return {
160
+ mode: impl.mode,
161
+ changeId: impl.changeId,
162
+ phase: impl.phase,
163
+ };
164
+ return undefined;
165
+ },
166
+ ),
167
+ );
168
+ void disposeAgentsSessionStart;
169
+ void disposeResourcesDiscover;
170
+ void disposeSessionStart;
171
+ void disposeRecoverySessionStart;
172
+ void disposeStatusSnapshot;
173
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Lifecycle bus events and the pi-notify bridge (design D9; openspec change
3
+ * add-pi-openspec-x, tasks 9.3 and 9.4).
4
+ *
5
+ * Every key flow node publishes one versioned lifecycle event on the single
6
+ * channel `pi-openspec-x:lifecycle:v1` (payload carries the change id and a
7
+ * structured `type`), so third parties — for example pi-sentinel's `event:`
8
+ * trigger — can subscribe declaratively.
9
+ *
10
+ * pi-notify is bridged by LITERAL channel name, never by importing the package:
11
+ * `pi-notify:publish` carries `{ eventId, source, label? }`, and a silent UI
12
+ * span is claimed with `pi-notify:ui_span_silent` before a blocking dialog
13
+ * opens. When pi-notify is absent the emits are harmless no-ops on a bus with
14
+ * no listener; nothing here throws or depends on the package.
15
+ */
16
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
17
+
18
+ /** The one versioned lifecycle channel. */
19
+ export const OPSX_LIFECYCLE_CHANNEL = "pi-openspec-x:lifecycle:v1";
20
+
21
+ /** pi-notify's publish channel (literal; no import). */
22
+ export const PI_NOTIFY_PUBLISH_CHANNEL = "pi-notify:publish";
23
+ /** pi-notify's silent-UI-span channel (literal; no import). */
24
+ export const PI_NOTIFY_UI_SPAN_SILENT_CHANNEL = "pi-notify:ui_span_silent";
25
+ /** The source name every pi-notify payload carries. */
26
+ export const PI_NOTIFY_SOURCE = "pi-openspec-x";
27
+
28
+ export type OpsxLifecycleEventType =
29
+ | "plan_started"
30
+ | "phase_changed"
31
+ | "review_verdict"
32
+ | "approval_wait"
33
+ | "plan_approved"
34
+ | "implement_started"
35
+ | "task_dispatched"
36
+ | "task_completed"
37
+ | "final_verdict"
38
+ | "flow_completed"
39
+ | "mode_exited";
40
+
41
+ /** The structured lifecycle payload consumers can rely on. */
42
+ export interface OpsxLifecyclePayload {
43
+ version: 1;
44
+ type: OpsxLifecycleEventType;
45
+ /** The openspec change this flow belongs to. */
46
+ change: string;
47
+ /** ISO timestamp. */
48
+ at: string;
49
+ mode?: "plan" | "agent" | "direct";
50
+ phase?: string;
51
+ round?: number;
52
+ verdict?: string;
53
+ taskId?: string;
54
+ message?: string;
55
+ }
56
+
57
+ export type OpsxLifecycleInput = Omit<
58
+ OpsxLifecyclePayload,
59
+ "version" | "at"
60
+ > & {
61
+ at?: string;
62
+ };
63
+
64
+ /** Minimal bus view; the real `pi.events` EventBus satisfies it. */
65
+ export type EventEmitterLike = Pick<EventBus, "emit">;
66
+
67
+ /**
68
+ * Publish one lifecycle event. A throwing bus never breaks the flow: the
69
+ * payload is still returned for tests and local logging.
70
+ */
71
+ export function publishLifecycle(
72
+ events: EventEmitterLike,
73
+ input: OpsxLifecycleInput,
74
+ ): OpsxLifecyclePayload {
75
+ const payload: OpsxLifecyclePayload = {
76
+ version: 1,
77
+ ...input,
78
+ at: input.at ?? new Date().toISOString(),
79
+ };
80
+ try {
81
+ events.emit(OPSX_LIFECYCLE_CHANNEL, payload);
82
+ } catch {
83
+ // Observability is best-effort.
84
+ }
85
+ return payload;
86
+ }
87
+
88
+ /** pi-notify's notification event ids this plugin uses. */
89
+ export type PiNotifyEventId =
90
+ | "input-required"
91
+ | "task-completed"
92
+ | "integration-error";
93
+
94
+ export interface PiNotifyPayload {
95
+ eventId: PiNotifyEventId;
96
+ source: string;
97
+ label?: string;
98
+ }
99
+
100
+ /**
101
+ * Publish one pi-notify notification. Returns the payload; a missing
102
+ * pi-notify simply means no listener sees it.
103
+ */
104
+ export function publishNotify(
105
+ events: EventEmitterLike,
106
+ input: { eventId: PiNotifyEventId; label?: string },
107
+ ): PiNotifyPayload {
108
+ const payload: PiNotifyPayload = {
109
+ eventId: input.eventId,
110
+ source: PI_NOTIFY_SOURCE,
111
+ ...(input.label ? { label: input.label } : {}),
112
+ };
113
+ try {
114
+ events.emit(PI_NOTIFY_PUBLISH_CHANNEL, payload);
115
+ } catch {
116
+ // Best-effort.
117
+ }
118
+ return payload;
119
+ }
120
+
121
+ /**
122
+ * Claim the next UI span as silent before opening a blocking dialog that is
123
+ * not an agent-waiting prompt.
124
+ */
125
+ export function emitSilentUiSpan(
126
+ events: EventEmitterLike,
127
+ reason?: string,
128
+ ): void {
129
+ try {
130
+ events.emit(PI_NOTIFY_UI_SPAN_SILENT_CHANNEL, reason ? { reason } : {});
131
+ } catch {
132
+ // Best-effort.
133
+ }
134
+ }
135
+
136
+ /** The flow nodes that map onto a pi-notify event id. */
137
+ export const NOTIFY_NODE_EVENT_ID: Record<
138
+ "approval_wait" | "flow_completed" | "gate_error",
139
+ PiNotifyEventId
140
+ > = {
141
+ approval_wait: "input-required",
142
+ flow_completed: "task-completed",
143
+ gate_error: "integration-error",
144
+ };
145
+
146
+ /** Publish the pi-notify notification for a flow node. */
147
+ export function notifyFlowNode(
148
+ events: EventEmitterLike,
149
+ node: keyof typeof NOTIFY_NODE_EVENT_ID,
150
+ label?: string,
151
+ ): PiNotifyPayload {
152
+ return publishNotify(events, {
153
+ eventId: NOTIFY_NODE_EVENT_ID[node],
154
+ ...(label ? { label } : {}),
155
+ });
156
+ }
package/src/mode.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Restricted-mode activation channel for pi-openspec-x (design D4).
3
+ *
4
+ * Entering a restricted mode writes the same session-entry channel
5
+ * pi-agent-role owns — the `active_agent` custom entry, byte-identical in
6
+ * shape — so pi-permission-system resolves the identity without knowing about
7
+ * this package. The sharing is one-way at the channel level only: `/role`'s
8
+ * agent enumeration lists configured agents, not runtime agents (verified in
9
+ * the 5.3 investigation), so an opsx mode never appears in its menu. Adopt,
10
+ * inspect, or clear an opsx mode through the opsx commands; only the entry
11
+ * shape, not the `/role` surface, is shared:
12
+ *
13
+ * 1. the `active_agent` session identity entry, byte-identical in shape to
14
+ * pi-agent-role's (`{ name: string | null }`);
15
+ * 2. pi-sandbox's per-session `SandboxService.setProfile` with the matching
16
+ * opsx profile.
17
+ *
18
+ * Stale-entry cleanup on `session_start` copies pi-agent-role's semantics
19
+ * verbatim (see `clearStaleActiveAgentIdentity`): entries are deliberately
20
+ * indistinguishable across the two packages, and a session start means the
21
+ * selection that produced any persisted identity no longer exists.
22
+ *
23
+ * Fail-closed asymmetry: entering throws a typed
24
+ * {@link OpsxDependencyMissingError} when pi-sandbox is unavailable — a
25
+ * restricted mode must never run unsandboxed. Leaving never throws for a
26
+ * missing dependency: exiting only removes restrictions, so it must always
27
+ * be possible and is reported as an outcome instead.
28
+ */
29
+ import type {
30
+ ExtensionAPI,
31
+ ExtensionContext,
32
+ ExtensionHandler,
33
+ SessionStartEvent,
34
+ } from "@earendil-works/pi-coding-agent";
35
+
36
+ import {
37
+ describeError,
38
+ loadSessionSandboxService,
39
+ OpsxDependencyMissingError,
40
+ requireRestrictedModeSupport,
41
+ SANDBOX_MISSING_CONSEQUENCE,
42
+ type SandboxServiceLike,
43
+ } from "./dependencies.ts";
44
+
45
+ /**
46
+ * Entry type pi-permission-system resolves into an agent identity; the exact
47
+ * contract pi-agent-role broadcasts on (broadcast.ts ACTIVE_AGENT_ENTRY_TYPE).
48
+ */
49
+ export const ACTIVE_AGENT_ENTRY_TYPE = "active_agent";
50
+
51
+ /** Restricted modes available to the opsx workflow (each maps 1:1 to a profile). */
52
+ export type OpsxMode = "planner" | "agent";
53
+
54
+ /**
55
+ * Identity (active_agent name) and sandbox profile per restricted mode. The
56
+ * names equal the registered profile names so /role interop and pi-sandbox
57
+ * resolve the same string.
58
+ */
59
+ const MODE_IDS: Record<
60
+ OpsxMode,
61
+ { agentName: string; sandboxProfile: string }
62
+ > = {
63
+ planner: { agentName: "opsx-planner", sandboxProfile: "opsx-planner" },
64
+ agent: { agentName: "opsx-agent", sandboxProfile: "opsx-agent" },
65
+ };
66
+
67
+ export interface OpsxModeNotice {
68
+ message: string;
69
+ severity: "warning" | "error";
70
+ }
71
+
72
+ export interface OpsxModeOutcome {
73
+ /** True when no channel reported an error. */
74
+ ok: boolean;
75
+ notices: OpsxModeNotice[];
76
+ }
77
+
78
+ export interface OpsxModeDeps {
79
+ /**
80
+ * Session id provider, defaulting to none (pi-sandbox then resolves the
81
+ * service only when exactly one session published one). Command handlers
82
+ * pass `() => ctx.sessionManager.getSessionId()`.
83
+ */
84
+ sessionId?: () => string | undefined;
85
+ /** Injectable service locator (tests); defaults to the recorded pi-sandbox module. */
86
+ loadService?: (
87
+ sessionId: string | undefined,
88
+ ) => Promise<SandboxServiceLike | undefined>;
89
+ }
90
+
91
+ function defaultLoadService(
92
+ sessionId: string | undefined,
93
+ ): Promise<SandboxServiceLike | undefined> {
94
+ return loadSessionSandboxService(sessionId);
95
+ }
96
+
97
+ function resolveSessionId(deps: OpsxModeDeps): string | undefined {
98
+ try {
99
+ return deps.sessionId?.();
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Enter a restricted mode: apply the sandbox profile first, then record the
107
+ * `active_agent` identity. All-or-nothing — on any failure nothing (or
108
+ * nothing observable) is applied and the session stays unrestricted:
109
+ *
110
+ * - dependency missing → typed {@link OpsxDependencyMissingError}, nothing done;
111
+ * - profile rejected by pi-sandbox → error, no identity entry written;
112
+ * - identity entry write fails → the profile is rolled back before throwing.
113
+ *
114
+ * A succeeded selection that carries a message (pi-sandbox warns when the
115
+ * sandbox switch is currently off) is returned as a warning notice: the mode
116
+ * is recorded, and the caller must surface that isolation is not active.
117
+ */
118
+ export async function enterOpsxMode(
119
+ pi: ExtensionAPI,
120
+ mode: OpsxMode,
121
+ deps: OpsxModeDeps = {},
122
+ ): Promise<OpsxModeOutcome> {
123
+ const { agentName, sandboxProfile } = MODE_IDS[mode];
124
+ // Fail-closed gate first: no identity entry may claim a mode whose sandbox
125
+ // cannot back it.
126
+ requireRestrictedModeSupport();
127
+
128
+ const service = await (deps.loadService ?? defaultLoadService)(
129
+ resolveSessionId(deps),
130
+ );
131
+ if (!service) {
132
+ throw new OpsxDependencyMissingError(
133
+ "pi-sandbox session service",
134
+ "no sandbox service is published for this session (pi-sandbox not loaded, or its session_start has not run)",
135
+ SANDBOX_MISSING_CONSEQUENCE,
136
+ );
137
+ }
138
+
139
+ // Profile before entry: "restricted mode entered" must mean both channels
140
+ // applied, and a rejected profile must leave the session untouched.
141
+ let result: { ok: boolean; message?: string };
142
+ try {
143
+ result = await service.setProfile(sandboxProfile);
144
+ } catch (error) {
145
+ throw new Error(
146
+ `Sandbox profile '${sandboxProfile}' could not be applied; refusing to enter restricted mode '${agentName}' (fail-closed): ${describeError(error)}`,
147
+ );
148
+ }
149
+ if (!result.ok) {
150
+ throw new Error(
151
+ `Sandbox profile '${sandboxProfile}' was rejected; refusing to enter restricted mode '${agentName}' (fail-closed): ${result.message ?? "unknown reason"}`,
152
+ );
153
+ }
154
+
155
+ try {
156
+ pi.appendEntry(ACTIVE_AGENT_ENTRY_TYPE, { name: agentName });
157
+ } catch (error) {
158
+ // Roll the profile back so nothing claims a restriction the identity
159
+ // entry denies.
160
+ try {
161
+ await service.setProfile(undefined);
162
+ } catch {
163
+ // Best effort; the thrown error below is what the caller sees.
164
+ }
165
+ throw new Error(
166
+ `Could not record the ${agentName} session identity: ${describeError(error)}`,
167
+ );
168
+ }
169
+
170
+ return {
171
+ ok: true,
172
+ notices: result.message
173
+ ? [{ message: result.message, severity: "warning" }]
174
+ : [],
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Leave the restricted mode, mirroring pi-agent-role's exit (applyRoleSelection
180
+ * with a cleared selection): null the identity entry and clear the sandbox
181
+ * profile back to the session default. Best-effort by design — every failure
182
+ * becomes a notice, never an exception, so exiting is always possible.
183
+ */
184
+ export async function exitOpsxMode(
185
+ pi: ExtensionAPI,
186
+ deps: OpsxModeDeps = {},
187
+ ): Promise<OpsxModeOutcome> {
188
+ const notices: OpsxModeNotice[] = [];
189
+
190
+ try {
191
+ pi.appendEntry(ACTIVE_AGENT_ENTRY_TYPE, { name: null });
192
+ } catch (error) {
193
+ notices.push({
194
+ message: `Could not clear the session agent identity: ${describeError(error)}`,
195
+ severity: "error",
196
+ });
197
+ }
198
+
199
+ const service = await (deps.loadService ?? defaultLoadService)(
200
+ resolveSessionId(deps),
201
+ );
202
+ if (!service) {
203
+ notices.push({
204
+ message:
205
+ "pi-sandbox is unavailable in this session; there is no sandbox profile to restore.",
206
+ severity: "warning",
207
+ });
208
+ } else {
209
+ try {
210
+ const result = await service.setProfile(undefined);
211
+ if (!result.ok) {
212
+ notices.push({
213
+ message: result.message ?? "Sandbox profile could not be cleared.",
214
+ severity: "error",
215
+ });
216
+ }
217
+ } catch (error) {
218
+ notices.push({
219
+ message: `Sandbox profile could not be cleared: ${describeError(error)}`,
220
+ severity: "error",
221
+ });
222
+ }
223
+ }
224
+
225
+ return {
226
+ ok: notices.every((notice) => notice.severity !== "error"),
227
+ notices,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Build the `session_start` handler that drops an `active_agent` identity a
233
+ * previous session persisted in the same session file. Register alongside the
234
+ * resources_discover handler; the semantics are pi-agent-role's, unmodified.
235
+ */
236
+ export function createOpsxSessionStartHandler(
237
+ pi: ExtensionAPI,
238
+ ): ExtensionHandler<SessionStartEvent> {
239
+ return (_event: SessionStartEvent, ctx: ExtensionContext): void => {
240
+ clearStaleActiveAgentIdentity(pi, ctx);
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Remove an `active_agent` identity left behind by a previous session in the
246
+ * same session file (pi-agent-role's semantics, kept byte-compatible because
247
+ * the entries are shared): every session starts from no role, so a tail entry
248
+ * with a non-empty name is stale — without the cleanup the permission system
249
+ * would keep applying an agent scope that no longer has a role behind it.
250
+ */
251
+ function clearStaleActiveAgentIdentity(
252
+ pi: ExtensionAPI,
253
+ ctx: ExtensionContext,
254
+ ): void {
255
+ if (!hasPersistedActiveAgent(ctx)) return;
256
+ try {
257
+ pi.appendEntry(ACTIVE_AGENT_ENTRY_TYPE, { name: null });
258
+ } catch {
259
+ // Best effort: the identity is advisory and a session that cannot be
260
+ // written must not fail startup.
261
+ }
262
+ }
263
+
264
+ function hasPersistedActiveAgent(ctx: ExtensionContext): boolean {
265
+ try {
266
+ const entries = ctx.sessionManager.getEntries();
267
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
268
+ const entry = entries[index] as {
269
+ type?: string;
270
+ customType?: string;
271
+ data?: { name?: unknown };
272
+ };
273
+ if (
274
+ entry?.type !== "custom" ||
275
+ entry.customType !== ACTIVE_AGENT_ENTRY_TYPE
276
+ )
277
+ continue;
278
+ const name = entry.data?.name;
279
+ return typeof name === "string" && name.length > 0;
280
+ }
281
+ } catch {
282
+ return false;
283
+ }
284
+ return false;
285
+ }