@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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 +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { projectBackgroundTask, type ObservedBackgroundTask } from "./background-observer.ts";
|
|
2
|
+
|
|
3
|
+
/** Non-context session entries: observation only, never executable authority. */
|
|
4
|
+
export const BACKGROUND_WORK_ENTRY = "devspec-background-observation";
|
|
5
|
+
export interface BackgroundWorkScope {
|
|
6
|
+
nativeSessionId: string;
|
|
7
|
+
connectionId: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
attachmentId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface BackgroundWorkEntry {
|
|
12
|
+
version: 1;
|
|
13
|
+
scope: BackgroundWorkScope;
|
|
14
|
+
task: ObservedBackgroundTask;
|
|
15
|
+
}
|
|
16
|
+
const scopeFields = ["nativeSessionId", "connectionId", "sessionId", "attachmentId"] as const;
|
|
17
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
19
|
+
}
|
|
20
|
+
export function backgroundScopeKey(scope: BackgroundWorkScope): string {
|
|
21
|
+
return JSON.stringify(scopeFields.map(field => scope[field]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Copy an allowlist only; never persist the provider's full snapshot. */
|
|
25
|
+
export function backgroundWorkEntry(scope: BackgroundWorkScope, task: ObservedBackgroundTask): BackgroundWorkEntry {
|
|
26
|
+
return {
|
|
27
|
+
version: 1,
|
|
28
|
+
scope: { nativeSessionId: scope.nativeSessionId, connectionId: scope.connectionId, sessionId: scope.sessionId, attachmentId: scope.attachmentId },
|
|
29
|
+
task: { id: task.id, startTime: task.startTime, state: task.state },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseEntry(value: unknown): BackgroundWorkEntry | null {
|
|
34
|
+
const row = record(value);
|
|
35
|
+
const scope = record(row?.scope);
|
|
36
|
+
const task = record(row?.task);
|
|
37
|
+
if (row?.version !== 1 || !scope || !task ||
|
|
38
|
+
Object.keys(row).some(key => !["version", "scope", "task"].includes(key)) ||
|
|
39
|
+
Object.keys(scope).some(key => !(scopeFields as readonly string[]).includes(key)) ||
|
|
40
|
+
Object.keys(task).some(key => !["id", "startTime", "state"].includes(key)) ||
|
|
41
|
+
scopeFields.some(field => typeof scope[field] !== "string" || !scope[field] || (scope[field] as string).length > 256)) return null;
|
|
42
|
+
const projected = projectBackgroundTask({ id: task.id, startTime: task.startTime, status: task.state === "unknown" ? "running" : task.state });
|
|
43
|
+
if (!projected) return null;
|
|
44
|
+
if (task.state === "unknown") projected.state = "unknown";
|
|
45
|
+
return backgroundWorkEntry(scope as unknown as BackgroundWorkScope, projected);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pass getBranch(), never every entry in the file: abandoned branches and other
|
|
50
|
+
* attachments cannot donate work. Restoration proves only ownership. A formerly
|
|
51
|
+
* running task is unknown until the current provider confirms it; terminal rows
|
|
52
|
+
* cannot be resurrected by delayed running snapshots or duplicate launch rows.
|
|
53
|
+
*/
|
|
54
|
+
export function restoreBackgroundWork(entries: readonly unknown[], scope: BackgroundWorkScope): ObservedBackgroundTask[] {
|
|
55
|
+
const key = backgroundScopeKey(scope);
|
|
56
|
+
const tasks = new Map<string, ObservedBackgroundTask>();
|
|
57
|
+
for (const value of entries) {
|
|
58
|
+
const row = record(value);
|
|
59
|
+
if (row?.type !== "custom" || row.customType !== BACKGROUND_WORK_ENTRY) continue;
|
|
60
|
+
const entry = parseEntry(row.data);
|
|
61
|
+
if (!entry || backgroundScopeKey(entry.scope) !== key) continue;
|
|
62
|
+
const previous = tasks.get(entry.task.id);
|
|
63
|
+
if (previous && (previous.startTime !== entry.task.startTime || !["running", "unknown"].includes(previous.state))) continue;
|
|
64
|
+
tasks.set(entry.task.id, entry.task);
|
|
65
|
+
}
|
|
66
|
+
return [...tasks.values()].filter(task => task.state === "running" || task.state === "unknown")
|
|
67
|
+
.map(task => ({ id: task.id, startTime: task.startTime, state: "unknown" }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tasks this conversation and connection journaled as finished (any attachment):
|
|
72
|
+
* a launch restored after /reload whose task already ended will not wake this
|
|
73
|
+
* instance (item 7a4d04fd). Only explicit terminal rows count.
|
|
74
|
+
*/
|
|
75
|
+
export function journaledFinishedTasks(entries: readonly unknown[], nativeSessionId: string, connectionId: string): Set<string> {
|
|
76
|
+
const finished = new Set<string>();
|
|
77
|
+
for (const value of entries) {
|
|
78
|
+
const row = record(value);
|
|
79
|
+
if (row?.type !== "custom" || row.customType !== BACKGROUND_WORK_ENTRY) continue;
|
|
80
|
+
const entry = parseEntry(row.data);
|
|
81
|
+
if (!entry || entry.scope.nativeSessionId !== nativeSessionId || entry.scope.connectionId !== connectionId) continue;
|
|
82
|
+
if (entry.task.state === "completed" || entry.task.state === "failed" || entry.task.state === "killed") finished.add(entry.task.id);
|
|
83
|
+
}
|
|
84
|
+
return finished;
|
|
85
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// db495c8f: observe the provider, never the model's consumption of a notice.
|
|
4
|
+
// Public API: pi-background-tasks/docs/api/eventbus-v1.md. No provider imports,
|
|
5
|
+
// task files, commands, output paths, prompt content or credentials are retained.
|
|
6
|
+
const REQUEST = "pi-background-tasks:request:v1";
|
|
7
|
+
const RESPONSE = "pi-background-tasks:response:v1";
|
|
8
|
+
const TERMINAL = "pi-background-tasks:terminal:v1";
|
|
9
|
+
const REQUEST_SCHEMA = "pi-background-tasks.extension-request.v1";
|
|
10
|
+
const RESPONSE_SCHEMA = "pi-background-tasks.extension-response.v1";
|
|
11
|
+
const TERMINAL_SCHEMA = "pi-background-tasks.extension-terminal.v1";
|
|
12
|
+
const LAUNCH_TOOLS = new Set([
|
|
13
|
+
"bg_run", "bg_run_pi_attested", "bg_delegate", "fusion_reason",
|
|
14
|
+
"fusion_investigate", "fusion_research", "fusion_validate",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export interface BackgroundEventBus {
|
|
18
|
+
on(event: string, handler: (value: unknown) => void): (() => void) | void;
|
|
19
|
+
emit(event: string, value: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Opaque caller-owned native-session + attachment generation, not a room name. */
|
|
23
|
+
export type BackgroundScope = string;
|
|
24
|
+
export type ObservedTaskState = "running" | "unknown" | "completed" | "failed" | "killed";
|
|
25
|
+
export interface ObservedBackgroundTask {
|
|
26
|
+
id: string;
|
|
27
|
+
/** Provider launch identity protects against a stale response for a reused id. */
|
|
28
|
+
startTime: number;
|
|
29
|
+
state: ObservedTaskState;
|
|
30
|
+
}
|
|
31
|
+
export interface BackgroundObservation {
|
|
32
|
+
scope: BackgroundScope | null;
|
|
33
|
+
tasks: ObservedBackgroundTask[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
38
|
+
? value as Record<string, unknown> : null;
|
|
39
|
+
}
|
|
40
|
+
function terminal(state: ObservedTaskState): boolean {
|
|
41
|
+
return state === "completed" || state === "failed" || state === "killed";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Allowlist projection. Never spread a provider snapshot into shared state. */
|
|
45
|
+
export function projectBackgroundTask(value: unknown): ObservedBackgroundTask | null {
|
|
46
|
+
const row = record(value);
|
|
47
|
+
if (!row || typeof row.id !== "string" || !row.id || row.id.length > 200 || row.id.trim() !== row.id ||
|
|
48
|
+
typeof row.startTime !== "number" || !Number.isSafeInteger(row.startTime) || row.startTime < 0 ||
|
|
49
|
+
typeof row.status !== "string" || !["running", "completed", "failed", "killed"].includes(row.status)) return null;
|
|
50
|
+
return { id: row.id, startTime: row.startTime, state: row.status as ObservedTaskState };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A tool that starts provider background work (the launch, not its inspection). */
|
|
54
|
+
export function isBackgroundLaunchTool(toolName: string): boolean {
|
|
55
|
+
return LAUNCH_TOOLS.has(toolName);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A successful launch proves ownership; inspection of someone else's task does not. */
|
|
59
|
+
export function backgroundTaskFromLaunch(toolName: string, result: unknown, isError = false): ObservedBackgroundTask | null {
|
|
60
|
+
if (isError || !LAUNCH_TOOLS.has(toolName)) return null;
|
|
61
|
+
const outer = record(result);
|
|
62
|
+
const details = record(outer?.details) ?? outer;
|
|
63
|
+
return projectBackgroundTask(details?.task);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validated management results observe known work; they never establish ownership. */
|
|
67
|
+
export function backgroundTasksFromManagement(toolName: string, result: unknown, isError = false): ObservedBackgroundTask[] {
|
|
68
|
+
if (isError || (toolName !== "bg_kill" && toolName !== "bg_status")) return [];
|
|
69
|
+
const outer = record(result);
|
|
70
|
+
const details = record(outer?.details) ?? outer;
|
|
71
|
+
const values = toolName === "bg_kill" ? [details?.task] : details?.tasks;
|
|
72
|
+
if (!Array.isArray(values)) return [];
|
|
73
|
+
return values.map(projectBackgroundTask).filter((task): task is ObservedBackgroundTask => task !== null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PendingRequest {
|
|
77
|
+
operation: "capabilities" | "status";
|
|
78
|
+
finish: (value: Record<string, unknown> | null) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Small observation adapter, not a task manager. Only explicitly registered
|
|
83
|
+
* launches/restored identities belong here. A status listing is never authority
|
|
84
|
+
* to adopt work. Missing, unavailable and malformed observations stay unknown.
|
|
85
|
+
*
|
|
86
|
+
* Caller drives refresh from its existing lifecycle/safety cadence. Terminal
|
|
87
|
+
* events are immediate; no extra model turn, polling loop or server request.
|
|
88
|
+
*/
|
|
89
|
+
export class BackgroundWorkObserver {
|
|
90
|
+
private scope: BackgroundScope | null = null;
|
|
91
|
+
private epoch = 0;
|
|
92
|
+
private supported = false;
|
|
93
|
+
private disposed = false;
|
|
94
|
+
private readonly tasks = new Map<string, ObservedBackgroundTask>();
|
|
95
|
+
private readonly earlyTerminals = new Map<string, ObservedBackgroundTask>();
|
|
96
|
+
private readonly pending = new Map<string, PendingRequest>();
|
|
97
|
+
private readonly subscriptions: (() => void)[] = [];
|
|
98
|
+
private inFlight: Promise<void> | null = null;
|
|
99
|
+
private offset = 0;
|
|
100
|
+
private readonly events: BackgroundEventBus;
|
|
101
|
+
private readonly onChange: (observation: BackgroundObservation) => void;
|
|
102
|
+
private readonly options: { timeoutMs?: number; batchSize?: number; concurrency?: number; earlyLimit?: number };
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
events: BackgroundEventBus,
|
|
106
|
+
onChange: (observation: BackgroundObservation) => void,
|
|
107
|
+
options: { timeoutMs?: number; batchSize?: number; concurrency?: number; earlyLimit?: number } = {},
|
|
108
|
+
) {
|
|
109
|
+
this.events = events;
|
|
110
|
+
this.onChange = onChange;
|
|
111
|
+
this.options = options;
|
|
112
|
+
for (const [name, handler] of [
|
|
113
|
+
[RESPONSE, (value: unknown) => this.response(value)],
|
|
114
|
+
[TERMINAL, (value: unknown) => this.terminalEvent(value)],
|
|
115
|
+
] as const) {
|
|
116
|
+
const stop = events.on(name, handler);
|
|
117
|
+
if (typeof stop === "function") this.subscriptions.push(stop);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A real attachment/session change revokes old observations and pending callbacks. */
|
|
122
|
+
bind(scope: BackgroundScope | null): void {
|
|
123
|
+
if (this.disposed || this.scope === scope) return;
|
|
124
|
+
this.epoch += 1;
|
|
125
|
+
this.scope = scope;
|
|
126
|
+
this.supported = false;
|
|
127
|
+
this.tasks.clear();
|
|
128
|
+
this.earlyTerminals.clear();
|
|
129
|
+
this.offset = 0;
|
|
130
|
+
for (const request of [...this.pending.values()]) request.finish(null);
|
|
131
|
+
this.inFlight = null;
|
|
132
|
+
this.changed();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Caller captures scope when work launches, not when its tool result arrives. */
|
|
136
|
+
track(task: ObservedBackgroundTask, scope: BackgroundScope): boolean {
|
|
137
|
+
if (this.disposed || !this.scope || scope !== this.scope || !this.validIdentity(task)) return false;
|
|
138
|
+
const old = this.tasks.get(task.id);
|
|
139
|
+
if (old) {
|
|
140
|
+
// Conflicting launch identities are not evidence the old work finished.
|
|
141
|
+
if (old.startTime !== task.startTime) return false;
|
|
142
|
+
this.apply(old, task.state);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const early = this.earlyTerminals.get(task.id);
|
|
146
|
+
const current: ObservedBackgroundTask = { id: task.id, startTime: task.startTime, state: task.state };
|
|
147
|
+
if (early?.startTime === task.startTime) current.state = early.state;
|
|
148
|
+
this.earlyTerminals.delete(task.id);
|
|
149
|
+
this.tasks.set(task.id, current);
|
|
150
|
+
this.changed();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An inspection/terminal result can update only an already-owned execution. */
|
|
155
|
+
observe(task: ObservedBackgroundTask, scope: BackgroundScope): boolean {
|
|
156
|
+
const owned = this.tasks.get(task.id);
|
|
157
|
+
if (this.disposed || scope !== this.scope || !owned || !this.validIdentity(task) || owned.startTime !== task.startTime) return false;
|
|
158
|
+
this.apply(owned, task.state);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
snapshot(): BackgroundObservation {
|
|
163
|
+
return { scope: this.scope, tasks: [...this.tasks.values()].map(task => ({ ...task })) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Only explicit provider-terminal evidence removes an owned task from pending. */
|
|
167
|
+
pendingIds(): string[] {
|
|
168
|
+
return [...this.tasks.values()].filter(task => !terminal(task.state)).map(task => task.id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
refresh(): Promise<void> {
|
|
172
|
+
if (this.disposed || !this.scope || this.pendingIds().length === 0) return Promise.resolve();
|
|
173
|
+
if (this.inFlight) return this.inFlight;
|
|
174
|
+
const epoch = this.epoch;
|
|
175
|
+
const work = this.reconcile(epoch).finally(() => { if (this.epoch === epoch) this.inFlight = null; });
|
|
176
|
+
this.inFlight = work;
|
|
177
|
+
return work;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
dispose(): void {
|
|
181
|
+
if (this.disposed) return;
|
|
182
|
+
this.bind(null);
|
|
183
|
+
this.disposed = true;
|
|
184
|
+
for (const stop of this.subscriptions) stop();
|
|
185
|
+
this.subscriptions.length = 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private validIdentity(task: ObservedBackgroundTask): boolean {
|
|
189
|
+
return typeof task.id === "string" && task.id.length > 0 && task.id.length <= 200 && task.id.trim() === task.id &&
|
|
190
|
+
Number.isSafeInteger(task.startTime) && task.startTime >= 0 &&
|
|
191
|
+
["running", "unknown", "completed", "failed", "killed"].includes(task.state);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private changed(): void {
|
|
195
|
+
// A consumer fault must never poison the provider's terminal publication.
|
|
196
|
+
try { this.onChange(this.snapshot()); } catch { /* next refresh still observes truth */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private apply(task: ObservedBackgroundTask, state: ObservedTaskState): void {
|
|
200
|
+
// A delayed running/status result cannot resurrect terminal work.
|
|
201
|
+
if (terminal(task.state) || task.state === state) return;
|
|
202
|
+
task.state = state;
|
|
203
|
+
this.changed();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private terminalEvent(value: unknown): void {
|
|
207
|
+
if (this.disposed || !this.scope) return;
|
|
208
|
+
const frame = record(value);
|
|
209
|
+
if (frame?.schema_version !== TERMINAL_SCHEMA) return;
|
|
210
|
+
const task = projectBackgroundTask(frame.task);
|
|
211
|
+
if (!task || !terminal(task.state)) return;
|
|
212
|
+
const owned = this.tasks.get(task.id);
|
|
213
|
+
if (owned) {
|
|
214
|
+
if (owned.startTime === task.startTime) this.apply(owned, task.state);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// A fast job may finish before tool_execution_end supplies its launch
|
|
218
|
+
// receipt. Keep only bounded non-content metadata, never adopt it alone.
|
|
219
|
+
this.earlyTerminals.set(task.id, task);
|
|
220
|
+
const limit = Math.max(0, Math.min(256, this.options.earlyLimit ?? 64));
|
|
221
|
+
while (this.earlyTerminals.size > limit) this.earlyTerminals.delete(this.earlyTerminals.keys().next().value!);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private response(value: unknown): void {
|
|
225
|
+
const frame = record(value);
|
|
226
|
+
if (frame?.schema_version !== RESPONSE_SCHEMA || typeof frame.request_id !== "string") return;
|
|
227
|
+
const request = this.pending.get(frame.request_id);
|
|
228
|
+
if (!request || frame.operation !== request.operation) return;
|
|
229
|
+
request.finish(frame.ok === true ? record(frame.result) : null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private request(operation: "capabilities" | "status", payload: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
|
233
|
+
if (this.disposed || !this.scope) return Promise.resolve(null);
|
|
234
|
+
return new Promise(resolve => {
|
|
235
|
+
const id = randomUUID();
|
|
236
|
+
const timer = setTimeout(() => finish(null), Math.max(1, Math.min(5_000, this.options.timeoutMs ?? 1_000)));
|
|
237
|
+
const finish = (value: Record<string, unknown> | null) => {
|
|
238
|
+
if (!this.pending.delete(id)) return;
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
resolve(value);
|
|
241
|
+
};
|
|
242
|
+
this.pending.set(id, { operation, finish });
|
|
243
|
+
try {
|
|
244
|
+
this.events.emit(REQUEST, { schema_version: REQUEST_SCHEMA, request_id: id, operation, payload });
|
|
245
|
+
} catch { finish(null); }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async reconcile(epoch: number): Promise<void> {
|
|
250
|
+
const current = () => !this.disposed && epoch === this.epoch && this.scope !== null;
|
|
251
|
+
if (!this.supported) {
|
|
252
|
+
const capabilities = await this.request("capabilities", {});
|
|
253
|
+
if (!current()) return;
|
|
254
|
+
this.supported = capabilities?.api_version === 1 && capabilities.status === true;
|
|
255
|
+
if (!this.supported) {
|
|
256
|
+
for (const task of this.tasks.values()) this.apply(task, "unknown");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const all = [...this.tasks.values()].filter(task => !terminal(task.state));
|
|
261
|
+
if (!all.length) return;
|
|
262
|
+
const size = Math.max(1, Math.min(64, this.options.batchSize ?? 32));
|
|
263
|
+
const selected = Array.from({ length: Math.min(size, all.length) }, (_, i) => all[(this.offset + i) % all.length]!);
|
|
264
|
+
this.offset = (this.offset + selected.length) % all.length;
|
|
265
|
+
let next = 0;
|
|
266
|
+
const worker = async () => {
|
|
267
|
+
while (current() && next < selected.length) {
|
|
268
|
+
const task = selected[next++]!;
|
|
269
|
+
if (terminal(task.state)) continue;
|
|
270
|
+
const result = await this.request("status", { taskId: task.id });
|
|
271
|
+
if (!current() || this.tasks.get(task.id) !== task) return;
|
|
272
|
+
const values = result?.tasks;
|
|
273
|
+
const observed = Array.isArray(values) && values.length === 1 ? projectBackgroundTask(values[0]) : null;
|
|
274
|
+
this.apply(task, observed?.id === task.id && observed.startTime === task.startTime ? observed.state : "unknown");
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(4, this.options.concurrency ?? 4, selected.length)) }, worker));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { RemoteConnectionIdentity } from "./remote-payload.ts";
|
|
2
|
+
|
|
3
|
+
export interface PumpAuthorityIdentity {
|
|
4
|
+
generation: number;
|
|
5
|
+
connectionId: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Immutable authority captured before an awaited connection-scoped operation. */
|
|
9
|
+
export function capturePumpAuthority(input: PumpAuthorityIdentity): PumpAuthorityIdentity {
|
|
10
|
+
return { generation: input.generation, connectionId: input.connectionId };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A delayed write may commit only while both pump generation and connection match. */
|
|
14
|
+
export function isPumpAuthorityCurrent(
|
|
15
|
+
captured: PumpAuthorityIdentity,
|
|
16
|
+
current: PumpAuthorityIdentity,
|
|
17
|
+
): boolean {
|
|
18
|
+
return captured.generation === current.generation && captured.connectionId === current.connectionId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RecoveredConnectionScopeInput {
|
|
22
|
+
currentConnectionId: string;
|
|
23
|
+
nextConnectionId: string;
|
|
24
|
+
generation: number;
|
|
25
|
+
nextGeneration: number;
|
|
26
|
+
cursor?: string;
|
|
27
|
+
dispatchCursor?: string;
|
|
28
|
+
systemNoticeCursor?: string;
|
|
29
|
+
synced: boolean;
|
|
30
|
+
agentName: string;
|
|
31
|
+
codename?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RecoveredConnectionScope {
|
|
35
|
+
changedConnection: boolean;
|
|
36
|
+
generation: number;
|
|
37
|
+
cursor?: string;
|
|
38
|
+
dispatchCursor?: string;
|
|
39
|
+
systemNoticeCursor?: string;
|
|
40
|
+
synced: boolean;
|
|
41
|
+
connectionIdentity: RemoteConnectionIdentity;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Plan the connection-scoped state transition for a recovered bond.
|
|
46
|
+
* Same-row revival preserves exactly-once clocks. A replacement row starts with
|
|
47
|
+
* fresh clocks and exact addressee identity; caller rekeys notice memory.
|
|
48
|
+
*/
|
|
49
|
+
export function planRecoveredConnectionScope(input: RecoveredConnectionScopeInput): RecoveredConnectionScope {
|
|
50
|
+
const changedConnection = input.currentConnectionId !== input.nextConnectionId;
|
|
51
|
+
return {
|
|
52
|
+
changedConnection,
|
|
53
|
+
generation: changedConnection ? input.nextGeneration : input.generation,
|
|
54
|
+
...(changedConnection || !input.cursor ? {} : { cursor: input.cursor }),
|
|
55
|
+
...(changedConnection || !input.dispatchCursor ? {} : { dispatchCursor: input.dispatchCursor }),
|
|
56
|
+
...(changedConnection || !input.systemNoticeCursor ? {} : { systemNoticeCursor: input.systemNoticeCursor }),
|
|
57
|
+
synced: changedConnection ? false : input.synced,
|
|
58
|
+
connectionIdentity: {
|
|
59
|
+
connection_id: input.nextConnectionId,
|
|
60
|
+
agent_name: input.agentName,
|
|
61
|
+
codename: input.codename ?? null,
|
|
62
|
+
label: input.codename ? `${input.agentName} · ${input.codename}` : input.agentName,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — bond state (shared across extension files).
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: pi loads every extension file with a FRESH module graph
|
|
5
|
+
* (jiti `moduleCache: false`), so module-level state is NOT shared between
|
|
6
|
+
* extension files. Anything cross-extension must live on globalThis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface BondState {
|
|
10
|
+
connectionId: string;
|
|
11
|
+
/** Immutable DevSpec project bound when this connection generation registered. */
|
|
12
|
+
projectId?: string;
|
|
13
|
+
/** Display-only project name confirmed at registration. */
|
|
14
|
+
projectName?: string;
|
|
15
|
+
/** Immutable factual snapshot captured at registration, injected as a tail message. */
|
|
16
|
+
repositoryContext?: string;
|
|
17
|
+
/** Hidden server-minted capability; transport header only, never model context. */
|
|
18
|
+
connectionCapability?: string;
|
|
19
|
+
codename?: string;
|
|
20
|
+
/** Attached DevSpec session, when attached (sessionless otherwise). */
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
/** Verbatim server attachment fence, not a locally reconstructed Date. */
|
|
23
|
+
attachedAt?: string;
|
|
24
|
+
/** Server-owned speech-only epoch; explicit attachment writes rotate it. */
|
|
25
|
+
speechAttachmentId?: string;
|
|
26
|
+
/** The attached session's title, learned by the attach-time context fetch. */
|
|
27
|
+
sessionTitle?: string;
|
|
28
|
+
/** One-shot room orientation digest, captured at cold-start sync. */
|
|
29
|
+
roomDigest?: string;
|
|
30
|
+
/** One-shot attach-time context block (identity + recent tail), set when the
|
|
31
|
+
* adopted session changes — injected once per attach, never replayed. */
|
|
32
|
+
attachContext?: string;
|
|
33
|
+
/** Instruction tiers returned by register/attach — applied while bonded. */
|
|
34
|
+
instructions?: {
|
|
35
|
+
owner_custom_instructions?: string;
|
|
36
|
+
owner_agent_rules?: string;
|
|
37
|
+
project_custom_instructions?: string;
|
|
38
|
+
project_agent_rules?: string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const GLOBAL_KEY = "__devspecPiBond";
|
|
43
|
+
|
|
44
|
+
interface BondStore {
|
|
45
|
+
current: BondState | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function store(): BondStore {
|
|
49
|
+
const g = globalThis as unknown as Record<string, BondStore | undefined>;
|
|
50
|
+
if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = { current: null };
|
|
51
|
+
return g[GLOBAL_KEY] as BondStore;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getBond(): BondState | null {
|
|
55
|
+
return store().current;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setBond(bond: BondState | null): void {
|
|
59
|
+
store().current = bond;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Extract the negotiated capability from MCP result metadata without touching visible content. */
|
|
63
|
+
export function connectionCapabilityFromResult(raw: unknown): string | undefined {
|
|
64
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
65
|
+
const meta = (raw as { _meta?: unknown })._meta;
|
|
66
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return undefined;
|
|
67
|
+
const devspec = (meta as { devspec?: unknown }).devspec;
|
|
68
|
+
if (!devspec || typeof devspec !== "object" || Array.isArray(devspec)) return undefined;
|
|
69
|
+
const capability = (devspec as { connection_capability?: unknown }).connection_capability;
|
|
70
|
+
if (!capability || typeof capability !== "object" || Array.isArray(capability)) return undefined;
|
|
71
|
+
const row = capability as { version?: unknown; value?: unknown };
|
|
72
|
+
return row.version === 1 && typeof row.value === "string" && /^dvsc_[A-Za-z0-9_-]{20,}$/.test(row.value)
|
|
73
|
+
? row.value
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// There is deliberately no turn-post marker here. Delivery has exactly one
|
|
78
|
+
// writer — the extension's mechanical settle (item cc9e8c43) — so there is
|
|
79
|
+
// nothing to suppress. The marker that lived here (857d984c) guarded a
|
|
80
|
+
// dual-writer model that no longer exists.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clear this agent's conversation for the owner's CONTROL `clear` (item 6beb89c6).
|
|
3
|
+
*
|
|
4
|
+
* Pi's own tree navigation, back to just before the first thing the agent was told
|
|
5
|
+
* (a message, or a context message DevSpec added): the model's context is empty
|
|
6
|
+
* again, and the session is the same one, so the DevSpec connection, which is keyed
|
|
7
|
+
* by the session id, stays bonded and in its room. The old conversation stays in the session tree, reachable with /tree. A new
|
|
8
|
+
* session (/new) would replace the session id, and with it the connection.
|
|
9
|
+
*
|
|
10
|
+
* The context DevSpec adds (instructions, repository facts, room orientation) is
|
|
11
|
+
* added again on the next turn, because each is added whenever the branch lacks it.
|
|
12
|
+
*
|
|
13
|
+
* Navigating to a message puts its text back into an empty editor. That is
|
|
14
|
+
* right for /tree, where the person means to edit and resend it, and wrong here:
|
|
15
|
+
* nobody asked to resend anything, so the editor is left as it was.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The part of a session entry this reads. */
|
|
19
|
+
export interface BranchEntryLike {
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
message?: { role?: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The first thing the agent was told on this branch, or null when it has been told
|
|
27
|
+
* nothing. Entries before it (the model and thinking level the session started
|
|
28
|
+
* with) are settings, not conversation, and stay.
|
|
29
|
+
*/
|
|
30
|
+
export function firstConversationEntryId(branch: readonly BranchEntryLike[]): string | null {
|
|
31
|
+
return branch.find((entry) =>
|
|
32
|
+
entry.type === "custom_message" || (entry.type === "message" && entry.message?.role === "user"))?.id ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ClearConversationHost {
|
|
36
|
+
/** The current branch, root first (`sessionManager.getBranch()`). */
|
|
37
|
+
branch(): readonly BranchEntryLike[];
|
|
38
|
+
navigateTree(targetId: string): Promise<{ cancelled: boolean }>;
|
|
39
|
+
getEditorText(): string;
|
|
40
|
+
setEditorText(text: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ClearOutcome = "cleared" | "already_clear";
|
|
44
|
+
|
|
45
|
+
export async function clearConversation(host: ClearConversationHost): Promise<ClearOutcome> {
|
|
46
|
+
const target = firstConversationEntryId(host.branch());
|
|
47
|
+
if (!target) return "already_clear";
|
|
48
|
+
const editorBefore = host.getEditorText();
|
|
49
|
+
const result = await host.navigateTree(target);
|
|
50
|
+
if (result.cancelled) throw new Error("Pi cancelled clearing the conversation.");
|
|
51
|
+
if (host.getEditorText() !== editorBefore) host.setEditorText(editorBefore);
|
|
52
|
+
return "cleared";
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._+\/-]{0,63}$/;
|
|
5
|
+
export type ClientVersionReport = Readonly<{ plugin_version?: string; host_version?: string }>;
|
|
6
|
+
|
|
7
|
+
/** Only our own executing package is a plugin-version source. */
|
|
8
|
+
export function versionReportFromManifest(manifest: unknown, hostVersion: unknown): ClientVersionReport {
|
|
9
|
+
if (!manifest || typeof manifest !== "object") return {};
|
|
10
|
+
const value = manifest as { name?: unknown; version?: unknown };
|
|
11
|
+
if ((value.name !== "@devspecai/pi-plugin" && value.name !== "@devspec/pi-plugin") || typeof value.version !== "string" || !LABEL.test(value.version)) return {};
|
|
12
|
+
return Object.freeze({
|
|
13
|
+
plugin_version: value.version,
|
|
14
|
+
...(typeof hostVersion === "string" && LABEL.test(hostVersion) ? { host_version: hostVersion } : {}),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readLoadedReport(): ClientVersionReport {
|
|
19
|
+
try {
|
|
20
|
+
return versionReportFromManifest(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")), PI_VERSION);
|
|
21
|
+
} catch {
|
|
22
|
+
// An unreadable artifact is unknown, never the host's or cwd's package.
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Capture at module load. Reading the file on every call would make old loaded
|
|
28
|
+
// code advertise a newer checkout's version after an in-place update (debdc8a7).
|
|
29
|
+
export const LOADED_CLIENT_VERSIONS = readLoadedReport();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The header every DevSpec request carries to say which plugin made it (ADR
|
|
33
|
+
* "Protecting intent" §14; decision bab58fed; server side 2295e089):
|
|
34
|
+
* `x-devspec-plugin: pi/<version>`. It identifies the plugin for the
|
|
35
|
+
* no-plugin notice, agent health and "update your plugin" messages; it is not
|
|
36
|
+
* authority, which the server always checks itself.
|
|
37
|
+
*/
|
|
38
|
+
export const PLUGIN_HEADER = "x-devspec-plugin";
|
|
39
|
+
/** This plugin's id in that header, shared by every DevSpec plugin's `<id>/<version>` contract. */
|
|
40
|
+
export const PLUGIN_ID = "pi";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The identifying header for this loaded artifact. The version is the one the
|
|
44
|
+
* running code was loaded with, read from this package's own manifest. When it
|
|
45
|
+
* cannot be read, no version is invented: the header is left off and DevSpec
|
|
46
|
+
* treats the call as it treats any unidentified client.
|
|
47
|
+
*/
|
|
48
|
+
export function pluginHeaders(report: ClientVersionReport = LOADED_CLIENT_VERSIONS): Readonly<Record<string, string>> {
|
|
49
|
+
return report.plugin_version ? { [PLUGIN_HEADER]: `${PLUGIN_ID}/${report.plugin_version}` } : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function withClientVersions(name: string, args: Record<string, unknown>, report = LOADED_CLIENT_VERSIONS): Record<string, unknown> {
|
|
53
|
+
if (name !== "register_connection" && name !== "attach_connection") return args;
|
|
54
|
+
const { plugin_version: _plugin, host_version: _host, ...rest } = args;
|
|
55
|
+
return { ...rest, ...report };
|
|
56
|
+
}
|