@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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/index.ts +1 -0
- package/package.json +55 -0
- package/src/agent-dir.ts +48 -0
- package/src/agent-prompts.ts +115 -0
- package/src/agents.ts +262 -0
- package/src/approval-gate.ts +139 -0
- package/src/cli.ts +442 -0
- package/src/command-util.ts +33 -0
- package/src/config.ts +89 -0
- package/src/dependencies.ts +445 -0
- package/src/discover.ts +112 -0
- package/src/dispatch-gate.ts +257 -0
- package/src/final-gate.ts +158 -0
- package/src/flow-entries.ts +258 -0
- package/src/flow-recovery.ts +227 -0
- package/src/goal-base.ts +339 -0
- package/src/implement-command.ts +1151 -0
- package/src/index.ts +173 -0
- package/src/lifecycle.ts +156 -0
- package/src/mode.ts +285 -0
- package/src/objective.ts +246 -0
- package/src/plan-command.ts +285 -0
- package/src/plan-flow.ts +133 -0
- package/src/plan-gate.ts +279 -0
- package/src/plan-review.ts +452 -0
- package/src/progress-observer.ts +78 -0
- package/src/progress-projection.ts +118 -0
- package/src/report-tools.ts +317 -0
- package/src/review-scope.ts +172 -0
- package/src/rollback-command.ts +241 -0
- package/src/sandbox-profiles.ts +73 -0
- package/src/skills.ts +386 -0
- package/src/static-conflict.ts +89 -0
- package/src/task-sync.ts +226 -0
- package/src/tasks-md.ts +136 -0
- package/src/worker-dispatch.ts +152 -0
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
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|