@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe context-budget attribution for DevSpec Pi.
|
|
3
|
+
*
|
|
4
|
+
* The store accepts numbers and generated/registered tool names only. Prompt,
|
|
5
|
+
* instruction, context-file, skill, argument, and result bodies never enter it.
|
|
6
|
+
* State lives on globalThis because Pi loads each extension file in an isolated
|
|
7
|
+
* jiti module graph.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface StaticContextBudget {
|
|
11
|
+
systemPromptChars: number;
|
|
12
|
+
activeToolCount: number;
|
|
13
|
+
activeToolDefinitionChars: number;
|
|
14
|
+
contextFileCount: number;
|
|
15
|
+
contextFileChars: number;
|
|
16
|
+
skillCount: number;
|
|
17
|
+
skillChars: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InjectedContextBudget {
|
|
21
|
+
attachChars: number;
|
|
22
|
+
roomDigestChars: number;
|
|
23
|
+
recallChars: number;
|
|
24
|
+
conflictNudgeChars: number;
|
|
25
|
+
totalChars: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolResultBudget {
|
|
29
|
+
verb: string;
|
|
30
|
+
calls: number;
|
|
31
|
+
chars: number;
|
|
32
|
+
maxChars: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContextBudgetReport {
|
|
36
|
+
v: 1;
|
|
37
|
+
static: StaticContextBudget;
|
|
38
|
+
injected: InjectedContextBudget;
|
|
39
|
+
toolResults: {
|
|
40
|
+
calls: number;
|
|
41
|
+
chars: number;
|
|
42
|
+
maxChars: number;
|
|
43
|
+
byVerb: ToolResultBudget[];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type InjectionKind = "attach" | "roomDigest" | "recall" | "conflictNudge";
|
|
48
|
+
|
|
49
|
+
interface MutableState {
|
|
50
|
+
safeToolNames: Set<string>;
|
|
51
|
+
static: StaticContextBudget;
|
|
52
|
+
injected: Omit<InjectedContextBudget, "totalChars">;
|
|
53
|
+
toolResults: Map<string, Omit<ToolResultBudget, "verb">>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ZERO_STATIC: StaticContextBudget = {
|
|
57
|
+
systemPromptChars: 0,
|
|
58
|
+
activeToolCount: 0,
|
|
59
|
+
activeToolDefinitionChars: 0,
|
|
60
|
+
contextFileCount: 0,
|
|
61
|
+
contextFileChars: 0,
|
|
62
|
+
skillCount: 0,
|
|
63
|
+
skillChars: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const ZERO_INJECTED: Omit<InjectedContextBudget, "totalChars"> = {
|
|
67
|
+
attachChars: 0,
|
|
68
|
+
roomDigestChars: 0,
|
|
69
|
+
recallChars: 0,
|
|
70
|
+
conflictNudgeChars: 0,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const STORE_KEY = Symbol.for("devspec.pi.context-budget.v1");
|
|
74
|
+
|
|
75
|
+
function nonNegativeInteger(value: unknown): number {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
77
|
+
? Math.round(value)
|
|
78
|
+
: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function newState(): MutableState {
|
|
82
|
+
return {
|
|
83
|
+
safeToolNames: new Set<string>(),
|
|
84
|
+
static: { ...ZERO_STATIC },
|
|
85
|
+
injected: { ...ZERO_INJECTED },
|
|
86
|
+
toolResults: new Map(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function state(): MutableState {
|
|
91
|
+
const root = globalThis as typeof globalThis & { [STORE_KEY]?: MutableState };
|
|
92
|
+
if (!root[STORE_KEY]) root[STORE_KEY] = newState();
|
|
93
|
+
return root[STORE_KEY];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Replace the generated allowlist used for per-tool attribution. */
|
|
97
|
+
export function registerContextBudgetToolNames(names: readonly string[]): void {
|
|
98
|
+
state().safeToolNames = new Set(names.filter((name) => /^[a-z][a-z0-9_]{0,79}$/.test(name)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Latest static prompt/tool/resource footprint for the current agent run. */
|
|
102
|
+
export function noteStaticContextBudget(value: Partial<StaticContextBudget>): void {
|
|
103
|
+
const current = state();
|
|
104
|
+
current.static = {
|
|
105
|
+
systemPromptChars: nonNegativeInteger(value.systemPromptChars),
|
|
106
|
+
activeToolCount: nonNegativeInteger(value.activeToolCount),
|
|
107
|
+
activeToolDefinitionChars: nonNegativeInteger(value.activeToolDefinitionChars),
|
|
108
|
+
contextFileCount: nonNegativeInteger(value.contextFileCount),
|
|
109
|
+
contextFileChars: nonNegativeInteger(value.contextFileChars),
|
|
110
|
+
skillCount: nonNegativeInteger(value.skillCount),
|
|
111
|
+
skillChars: nonNegativeInteger(value.skillChars),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Add one tail-injected context block by numeric size only. */
|
|
116
|
+
export function noteInjectedContext(kind: InjectionKind, chars: number): void {
|
|
117
|
+
const n = nonNegativeInteger(chars);
|
|
118
|
+
if (!n) return;
|
|
119
|
+
const injected = state().injected;
|
|
120
|
+
if (kind === "attach") injected.attachChars += n;
|
|
121
|
+
else if (kind === "roomDigest") injected.roomDigestChars += n;
|
|
122
|
+
else if (kind === "recall") injected.recallChars += n;
|
|
123
|
+
else injected.conflictNudgeChars += n;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Add one model-visible DevSpec tool result. Unknown names fail closed. */
|
|
127
|
+
export function noteModelToolResult(verb: string, chars: number): void {
|
|
128
|
+
const current = state();
|
|
129
|
+
if (!current.safeToolNames.has(verb)) return;
|
|
130
|
+
const n = nonNegativeInteger(chars);
|
|
131
|
+
const prior = current.toolResults.get(verb) ?? { calls: 0, chars: 0, maxChars: 0 };
|
|
132
|
+
current.toolResults.set(verb, {
|
|
133
|
+
calls: prior.calls + 1,
|
|
134
|
+
chars: prior.chars + n,
|
|
135
|
+
maxChars: Math.max(prior.maxChars, n),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Snapshot one settled run and clear only its dynamic injected/result counters.
|
|
141
|
+
* Static footprint remains as the last-known value for idle heartbeat refreshes.
|
|
142
|
+
*/
|
|
143
|
+
export function takeContextBudgetReport(): ContextBudgetReport {
|
|
144
|
+
const current = state();
|
|
145
|
+
// Every verb this run used: one numeric row each, so the report adds up to its totals.
|
|
146
|
+
const byVerb = [...current.toolResults.entries()]
|
|
147
|
+
.map(([verb, value]) => ({ verb, ...value }))
|
|
148
|
+
.sort((a, b) => b.chars - a.chars || a.verb.localeCompare(b.verb));
|
|
149
|
+
const injectedBase = { ...current.injected };
|
|
150
|
+
const injected: InjectedContextBudget = {
|
|
151
|
+
...injectedBase,
|
|
152
|
+
totalChars: Object.values(injectedBase).reduce((sum, value) => sum + value, 0),
|
|
153
|
+
};
|
|
154
|
+
const toolValues = [...current.toolResults.values()];
|
|
155
|
+
const report: ContextBudgetReport = {
|
|
156
|
+
v: 1,
|
|
157
|
+
static: { ...current.static },
|
|
158
|
+
injected,
|
|
159
|
+
toolResults: {
|
|
160
|
+
calls: toolValues.reduce((sum, value) => sum + value.calls, 0),
|
|
161
|
+
chars: toolValues.reduce((sum, value) => sum + value.chars, 0),
|
|
162
|
+
maxChars: toolValues.reduce((max, value) => Math.max(max, value.maxChars), 0),
|
|
163
|
+
byVerb,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
current.injected = { ...ZERO_INJECTED };
|
|
167
|
+
current.toolResults.clear();
|
|
168
|
+
return report;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Test/reload helper; not used by the model surface. */
|
|
172
|
+
export function resetContextBudgetState(): void {
|
|
173
|
+
const root = globalThis as typeof globalThis & { [STORE_KEY]?: MutableState };
|
|
174
|
+
root[STORE_KEY] = newState();
|
|
175
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ModelCatalogSnapshot } from './model-catalog.ts';
|
|
2
|
+
|
|
3
|
+
export const CONTROL_RESULT_ENTRY = 'devspec-control-result-v1';
|
|
4
|
+
export type ControlResult = { status: 'completed' } | { status: 'failed'; error: 'model_catalog_unavailable' | 'control_failed' };
|
|
5
|
+
export interface ProducedControlResult {
|
|
6
|
+
connectionId: string;
|
|
7
|
+
controlId: string;
|
|
8
|
+
result: ControlResult;
|
|
9
|
+
catalog?: ModelCatalogSnapshot;
|
|
10
|
+
collectionMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export type ControlResultEntry =
|
|
13
|
+
| { phase: 'produced'; value: ProducedControlResult }
|
|
14
|
+
| { phase: 'acknowledged' | 'superseded'; connectionId: string; controlId: string };
|
|
15
|
+
export type ControlDeliveryOutcome = 'acknowledged' | 'superseded';
|
|
16
|
+
type Persist = (value: ControlResultEntry) => void;
|
|
17
|
+
|
|
18
|
+
/** Session-journal-backed results: retry delivery, not execution (33d1e0a1). */
|
|
19
|
+
export class ControlResultOutbox {
|
|
20
|
+
private pending = new Map<string, ProducedControlResult>();
|
|
21
|
+
private handled = new Set<string>();
|
|
22
|
+
private sending = false;
|
|
23
|
+
|
|
24
|
+
private key(connectionId: string, controlId: string): string { return JSON.stringify([connectionId, controlId]); }
|
|
25
|
+
has(connectionId: string, controlId: string): boolean { return this.handled.has(this.key(connectionId, controlId)); }
|
|
26
|
+
hasPending(connectionId: string): boolean { return [...this.pending.values()].some(value => value.connectionId === connectionId); }
|
|
27
|
+
|
|
28
|
+
produce(value: ProducedControlResult, persist: Persist): void {
|
|
29
|
+
const key = this.key(value.connectionId, value.controlId);
|
|
30
|
+
if (this.handled.has(key)) return;
|
|
31
|
+
// Retain in memory even when persistence throws; never execute twice in this runtime.
|
|
32
|
+
this.pending.set(key, value);
|
|
33
|
+
this.handled.add(key);
|
|
34
|
+
persist({ phase: 'produced', value });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static reconstruct(entries: readonly unknown[]): ControlResultOutbox {
|
|
38
|
+
const outbox = new ControlResultOutbox();
|
|
39
|
+
for (const raw of entries) {
|
|
40
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
41
|
+
const entry = raw as { type?: unknown; customType?: unknown; data?: unknown };
|
|
42
|
+
if (entry.type !== 'custom' || entry.customType !== CONTROL_RESULT_ENTRY) continue;
|
|
43
|
+
const data = entry.data as ControlResultEntry | undefined;
|
|
44
|
+
if (!data || typeof data !== 'object') throw new Error('Invalid persisted CONTROL result');
|
|
45
|
+
if (data.phase === 'produced') {
|
|
46
|
+
const value = data.value;
|
|
47
|
+
if (!value || typeof value.connectionId !== 'string' || !value.connectionId || typeof value.controlId !== 'string' || !value.controlId ||
|
|
48
|
+
!value.result || (value.result.status !== 'completed' && !(value.result.status === 'failed' &&
|
|
49
|
+
(value.result.error === 'model_catalog_unavailable' || value.result.error === 'control_failed')))) {
|
|
50
|
+
throw new Error('Invalid persisted CONTROL result');
|
|
51
|
+
}
|
|
52
|
+
const key = outbox.key(value.connectionId, value.controlId);
|
|
53
|
+
if (!outbox.handled.has(key)) outbox.pending.set(key, value);
|
|
54
|
+
outbox.handled.add(key);
|
|
55
|
+
} else if ((data.phase === 'acknowledged' || data.phase === 'superseded') && typeof data.connectionId === 'string' && typeof data.controlId === 'string') {
|
|
56
|
+
const key = outbox.key(data.connectionId, data.controlId);
|
|
57
|
+
outbox.pending.delete(key);
|
|
58
|
+
outbox.handled.add(key);
|
|
59
|
+
} else throw new Error('Invalid persisted CONTROL result');
|
|
60
|
+
}
|
|
61
|
+
return outbox;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async drain(connectionId: string, send: (value: ProducedControlResult) => Promise<ControlDeliveryOutcome>, persist: Persist,
|
|
65
|
+
current: () => boolean = () => true): Promise<void> {
|
|
66
|
+
if (this.sending || !current()) return;
|
|
67
|
+
this.sending = true;
|
|
68
|
+
try {
|
|
69
|
+
for (const [key, value] of this.pending) {
|
|
70
|
+
if (value.connectionId !== connectionId || !current()) continue;
|
|
71
|
+
const outcome = await send(value);
|
|
72
|
+
if (!current()) return;
|
|
73
|
+
// Persist first. A crash or append failure retries result delivery only.
|
|
74
|
+
// A stale slot is superseded, not evidence this reply stored our result.
|
|
75
|
+
persist({ phase: outcome, connectionId, controlId: value.controlId });
|
|
76
|
+
this.pending.delete(key);
|
|
77
|
+
}
|
|
78
|
+
} finally { this.sending = false; }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-authority CONTROL execution (item c2cc8679 / d0d37274).
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle only. Never authors conversational content.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const CONTROL_VERBS = ["abort", "set_model", "set_thinking", "compact", "clear", "reload", "list_models"] as const;
|
|
8
|
+
export type ControlVerb = (typeof CONTROL_VERBS)[number];
|
|
9
|
+
|
|
10
|
+
export interface ControlCommand {
|
|
11
|
+
id: string;
|
|
12
|
+
verb: ControlVerb;
|
|
13
|
+
issuedAt: string;
|
|
14
|
+
issuedByUserId: string;
|
|
15
|
+
args?: { model?: string; thinking?: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseControlCommand(raw: unknown): ControlCommand | null {
|
|
19
|
+
if (!raw || typeof raw !== "object") return null;
|
|
20
|
+
const rec = raw as Record<string, unknown>;
|
|
21
|
+
if (typeof rec.id !== "string" || !rec.id.trim()) return null;
|
|
22
|
+
if (typeof rec.verb !== "string" || !(CONTROL_VERBS as readonly string[]).includes(rec.verb)) return null;
|
|
23
|
+
if (typeof rec.issuedAt !== "string") return null;
|
|
24
|
+
if (typeof rec.issuedByUserId !== "string") return null;
|
|
25
|
+
const argsRaw = rec.args && typeof rec.args === "object" ? (rec.args as Record<string, unknown>) : undefined;
|
|
26
|
+
return {
|
|
27
|
+
id: rec.id.trim(),
|
|
28
|
+
verb: rec.verb as ControlVerb,
|
|
29
|
+
issuedAt: rec.issuedAt,
|
|
30
|
+
issuedByUserId: rec.issuedByUserId,
|
|
31
|
+
args: argsRaw
|
|
32
|
+
? {
|
|
33
|
+
model: typeof argsRaw.model === "string" ? argsRaw.model : undefined,
|
|
34
|
+
thinking: typeof argsRaw.thinking === "string" ? argsRaw.thinking : undefined,
|
|
35
|
+
}
|
|
36
|
+
: undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ControlHost {
|
|
41
|
+
isIdle(): boolean;
|
|
42
|
+
abort(): void;
|
|
43
|
+
compact(): Promise<void> | void;
|
|
44
|
+
/** Empty this agent's conversation, keeping its session and DevSpec connection (item 6beb89c6). */
|
|
45
|
+
clear(): Promise<void> | void;
|
|
46
|
+
reload(): Promise<void> | void;
|
|
47
|
+
/** Set the requested level and read back the host's effective (possibly clamped) level. */
|
|
48
|
+
setThinkingLevel?(level: string): string;
|
|
49
|
+
setModel(modelId: string): Promise<boolean> | boolean;
|
|
50
|
+
notify(message: string): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Execute one CONTROL verb. Returns local feedback; the wire ack is consumption-only. */
|
|
54
|
+
export async function executeControlCommand(cmd: ControlCommand, host: ControlHost): Promise<string> {
|
|
55
|
+
switch (cmd.verb) {
|
|
56
|
+
case "abort":
|
|
57
|
+
if (!host.isIdle()) host.abort();
|
|
58
|
+
return "aborted";
|
|
59
|
+
case "compact":
|
|
60
|
+
await host.compact();
|
|
61
|
+
return "compacted";
|
|
62
|
+
case "clear":
|
|
63
|
+
await host.clear();
|
|
64
|
+
return "cleared";
|
|
65
|
+
case "set_thinking": {
|
|
66
|
+
const level = cmd.args?.thinking;
|
|
67
|
+
if (!level) return "set_thinking missing level";
|
|
68
|
+
if (typeof host.setThinkingLevel !== "function") throw new Error("setThinkingLevel is not available");
|
|
69
|
+
const effectiveLevel = host.setThinkingLevel(level);
|
|
70
|
+
return `thinking=${effectiveLevel}`;
|
|
71
|
+
}
|
|
72
|
+
case "set_model": {
|
|
73
|
+
const model = cmd.args?.model;
|
|
74
|
+
if (!model) return "set_model missing model";
|
|
75
|
+
const ok = await host.setModel(model);
|
|
76
|
+
return ok ? `model=${model}` : `model not available: ${model}`;
|
|
77
|
+
}
|
|
78
|
+
case "reload":
|
|
79
|
+
await host.reload();
|
|
80
|
+
return "reloaded";
|
|
81
|
+
case "list_models":
|
|
82
|
+
return "listed";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reload invalidates the pump ctx (Pi assertActive). The old lap must ack
|
|
88
|
+
* first so a revived bond cannot see the same pending CONTROL, then reload,
|
|
89
|
+
* then stop. Do not notify on the old ctx after a successful reload.
|
|
90
|
+
*/
|
|
91
|
+
export type ReloadLapStatus = "reloaded" | "ack_failed" | "reload_failed";
|
|
92
|
+
|
|
93
|
+
export async function runReloadLap(opts: {
|
|
94
|
+
ack: () => Promise<void>;
|
|
95
|
+
reload: () => Promise<void>;
|
|
96
|
+
}): Promise<{ status: ReloadLapStatus; error?: string }> {
|
|
97
|
+
try {
|
|
98
|
+
await opts.ack();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { status: "ack_failed", error: err instanceof Error ? err.message : String(err) };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await opts.reload();
|
|
104
|
+
return { status: "reloaded" };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return { status: "reload_failed", error: err instanceof Error ? err.message : String(err) };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run Pi command-context reload (same as TUI /reload).
|
|
112
|
+
* Event/pump ctx does not have reload(); sendUserMessage("/devspec.reload")
|
|
113
|
+
* is not a substitute — it skips command execution (item e82a690a).
|
|
114
|
+
*/
|
|
115
|
+
export async function reloadWithContext(
|
|
116
|
+
ctx: { reload?: () => Promise<void> } | null | undefined,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const reload = ctx?.reload;
|
|
119
|
+
if (typeof reload !== "function") {
|
|
120
|
+
throw new Error("reload is not available on this context");
|
|
121
|
+
}
|
|
122
|
+
await reload.call(ctx);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Run Pi ctx.compact and wait for onComplete / onError.
|
|
127
|
+
* Fire-and-forget compact is how “requested” lied after a no-op.
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
export function compactWithContext(
|
|
131
|
+
ctx: {
|
|
132
|
+
compact?: (opts?: {
|
|
133
|
+
onComplete?: (result: unknown) => void;
|
|
134
|
+
onError?: (error: Error) => void;
|
|
135
|
+
}) => void;
|
|
136
|
+
} | null | undefined,
|
|
137
|
+
timeoutMs = 60_000,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
if (!ctx || typeof ctx.compact !== "function") {
|
|
141
|
+
reject(new Error("compact is not available on this context"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
reject(new Error(`compact timed out after ${timeoutMs}ms`));
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
const done = (fn: () => void) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
fn();
|
|
150
|
+
};
|
|
151
|
+
ctx.compact({
|
|
152
|
+
onComplete: () => done(() => resolve()),
|
|
153
|
+
onError: (error) => done(() => reject(error)),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi invalidates the extension runner after reload / session replacement.
|
|
3
|
+
* Accessors on the old ctx (hasUI, ui, cwd, …) throw assertActive.
|
|
4
|
+
* UI toasts and the footer are best-effort — never let that throw kill the process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const STALE_CTX_MARK = "stale after session replacement or reload";
|
|
8
|
+
|
|
9
|
+
export function isStaleCtxError(err: unknown): boolean {
|
|
10
|
+
return err instanceof Error && err.message.includes(STALE_CTX_MARK);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read hasUI without throwing when the runner has already been invalidated. */
|
|
14
|
+
export function safeHasUI(ctx: { hasUI?: boolean } | null | undefined): boolean {
|
|
15
|
+
if (!ctx) return false;
|
|
16
|
+
try {
|
|
17
|
+
return Boolean(ctx.hasUI);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (isStaleCtxError(err)) return false;
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run a UI side-effect; swallow only the Pi stale-ctx class. */
|
|
25
|
+
export function withLiveUi(ctx: { hasUI?: boolean } | null | undefined, fn: () => void): void {
|
|
26
|
+
if (!safeHasUI(ctx)) return;
|
|
27
|
+
try {
|
|
28
|
+
fn();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (isStaleCtxError(err)) return;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — debug log (shared, env-gated).
|
|
3
|
+
*
|
|
4
|
+
* DEVSPEC_DEBUG_REMOTE=1 appends privacy-safe pump/recall/lifecycle metadata
|
|
5
|
+
* to a platform-native temporary file. Cross-extension-file safe
|
|
6
|
+
* (append-only). Event labels and numeric/boolean field names are closed here;
|
|
7
|
+
* unknown runtime input is dropped. Prompt text, instructions, context
|
|
8
|
+
* contents, arguments, results, credentials, names and provider payloads have
|
|
9
|
+
* no representable field.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const DEVSPEC_DEBUG_LOG_PATH = join(tmpdir(), "devspec-remote-debug.log");
|
|
17
|
+
|
|
18
|
+
const DEBUG_FIELDS = {
|
|
19
|
+
"adapter guard applied": ["serverCount", "deactivatedTools"],
|
|
20
|
+
"adapter guard failed": ["errorPresent"],
|
|
21
|
+
"adapter guard get tools failed": ["errorPresent"],
|
|
22
|
+
"adapter guard persist failed": ["errorPresent"],
|
|
23
|
+
"adapter guard set active tools failed": ["errorPresent"],
|
|
24
|
+
"adapter guard turn failed": ["errorPresent"],
|
|
25
|
+
"attachment adopted from server": ["attached"],
|
|
26
|
+
"background observation": ["pendingBg", "unknown"],
|
|
27
|
+
"background observation persistence failed": ["errorPresent"],
|
|
28
|
+
"background observation reconciliation failed": ["errorPresent"],
|
|
29
|
+
"background task noted": ["pendingBg"],
|
|
30
|
+
"background task resolved from tool result": ["pendingBg"],
|
|
31
|
+
"canonical ingress rejected": ["errors", "errorPresent"],
|
|
32
|
+
"canonical ingress synced": ["cursorPresent"],
|
|
33
|
+
"commit ledger captured": [],
|
|
34
|
+
"commit provenance delivery": ["sent", "dropped", "expired", "capacity", "retry"],
|
|
35
|
+
"command offer preempt": ["settled"],
|
|
36
|
+
"command offer prepared": ["foregroundIdle", "pendingBg"],
|
|
37
|
+
"command handoff ready": ["pendingBg"],
|
|
38
|
+
"command offer preparation failed": ["errorPresent"],
|
|
39
|
+
"context budget": ["staticChars", "injectedChars", "resultCalls", "resultChars"],
|
|
40
|
+
"control completed": ["resultPresent"],
|
|
41
|
+
"control acknowledgement failed": ["errorPresent"],
|
|
42
|
+
"control catalogue collected": ["models", "collectionMs", "catalogBytes"],
|
|
43
|
+
"control result persistence failed": ["errorPresent"],
|
|
44
|
+
"control failed": ["messageChars"],
|
|
45
|
+
"control reload": ["succeeded", "errorPresent"],
|
|
46
|
+
"delivery boundary": ["completeTurn"],
|
|
47
|
+
"duplicate-tool recovery": ["count"],
|
|
48
|
+
"duplicate-tool recovery continue failed": ["errorPresent"],
|
|
49
|
+
"duplicate-tool recovery persist failed": ["errorPresent"],
|
|
50
|
+
"ensureTurn: not attached": [],
|
|
51
|
+
"ensureTurn: tracking (run-scoped)": [],
|
|
52
|
+
"interaction event application failed": ["errorPresent"],
|
|
53
|
+
"interaction event persistence confirmation failed": ["errorPresent"],
|
|
54
|
+
"interaction event exact-workflow guard rejected": ["errors"],
|
|
55
|
+
"interaction event payload rejected": ["errors", "errorPresent"],
|
|
56
|
+
"interaction preempt already aborted for event; awaiting idle": [],
|
|
57
|
+
"interaction apply deferred": ["busy", "occupancyBusy", "ctxIdle"],
|
|
58
|
+
"interaction authority revoked before agent start": [],
|
|
59
|
+
"stale tool receipt abort": [],
|
|
60
|
+
"message_end: no turn": [],
|
|
61
|
+
"notice reply": ["posted", "idempotent", "refused", "unconfirmed", "skipped", "notices"],
|
|
62
|
+
"optional room state rejected": ["sessionPolls", "stillToDiscuss", "sessionActivity", "noticeReply"],
|
|
63
|
+
"room storage unavailable": ["errorPresent"],
|
|
64
|
+
"poll recoverable": ["errors", "errorPresent"],
|
|
65
|
+
"poll recoverable server response": ["ended", "reasonPresent"],
|
|
66
|
+
"post delivery failed": ["skipped", "rejected", "malformed", "unknown"],
|
|
67
|
+
"post stored": ["idempotent"],
|
|
68
|
+
preempt: ["settled", "immediate"],
|
|
69
|
+
"preempt: aborting running turn for owner instruction": [],
|
|
70
|
+
"preempt: aborting exact question for validated command availability": [],
|
|
71
|
+
"preempt: aborting running turn for question answer": [],
|
|
72
|
+
"preempt: cleared stranded busy after unsettled abort": [],
|
|
73
|
+
"push channel closed": ["reasonPresent"],
|
|
74
|
+
"push channel connect failed": ["errorPresent"],
|
|
75
|
+
"push ended recoverable": ["reasonPresent"],
|
|
76
|
+
"ended from DevSpec — closing Pi": [],
|
|
77
|
+
"shutdown after End failed": ["errorPresent"],
|
|
78
|
+
"push reconnect attempt": ["attempt", "delayMs"],
|
|
79
|
+
"push reconnects exhausted — held long-poll fallback": [],
|
|
80
|
+
"push unavailable — held long-poll fallback": [],
|
|
81
|
+
"push wake": [],
|
|
82
|
+
recall: ["queryChars", "newHits"],
|
|
83
|
+
"request chain failed": ["open", "close", "restore", "persist"],
|
|
84
|
+
"request write unstamped": ["connectionChanged", "notRecorded", "errorPresent"],
|
|
85
|
+
"request launches restored": ["restored"],
|
|
86
|
+
"request launches ended after restore": ["ended"],
|
|
87
|
+
"request launches restore failed": ["errorPresent"],
|
|
88
|
+
"request launch record failed": ["errorPresent"],
|
|
89
|
+
"recall failed": ["errorPresent"],
|
|
90
|
+
"sender style repair": ["commands"],
|
|
91
|
+
"sender styles without a delivered command": ["count"],
|
|
92
|
+
"session agenda error": ["errorPresent"],
|
|
93
|
+
"session agenda fetch failed": ["resultChars"],
|
|
94
|
+
"session enriched": ["titlePresent", "ownerPresent", "tail", "agenda", "attachContext"],
|
|
95
|
+
"session enrich error": ["errorPresent"],
|
|
96
|
+
"session enrich fetch failed": ["resultChars"],
|
|
97
|
+
"settle segment": ["completeTurn", "trailChars", "planFinal", "planMirror", "answerChars", "pendingBg", "harness", "bound"],
|
|
98
|
+
"settle segment not attached": [],
|
|
99
|
+
"wake adopted command turn": [],
|
|
100
|
+
"settleTurn: no turn state": [],
|
|
101
|
+
"settleTurn: settle": ["answerChars", "pendingBg", "harness"],
|
|
102
|
+
speech: ["candidate", "process", "split", "chars", "tools", "closedStop"],
|
|
103
|
+
"system notice idle delivery failed": ["errorPresent"],
|
|
104
|
+
"system notice memory queue rejected": ["errors", "count"],
|
|
105
|
+
"system notice settle drain failed": ["errorPresent"],
|
|
106
|
+
"sync deferred: empty wake, still cursor-less": [],
|
|
107
|
+
synced: ["cursorPresent", "historical", "live"],
|
|
108
|
+
"synced with room digest": ["digestChars", "historical", "live"],
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
type DebugEvent = keyof typeof DEBUG_FIELDS;
|
|
112
|
+
type DebugField = number | boolean;
|
|
113
|
+
type DebugFields<E extends DebugEvent> = Partial<Record<(typeof DEBUG_FIELDS)[E][number], DebugField>>;
|
|
114
|
+
|
|
115
|
+
/** Pure runtime boundary used by tests and by the file writer. */
|
|
116
|
+
export function formatDebugRecord(event: unknown, fields: unknown = {}): string | null {
|
|
117
|
+
if (typeof event !== "string" || !Object.hasOwn(DEBUG_FIELDS, event)) return null;
|
|
118
|
+
const allowed = new Set<string>(DEBUG_FIELDS[event as DebugEvent]);
|
|
119
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return event;
|
|
120
|
+
const suffix = Object.entries(fields as Record<string, unknown>)
|
|
121
|
+
.filter(([key, value]) => allowed.has(key) && (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))))
|
|
122
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
123
|
+
.join(" ");
|
|
124
|
+
return `${event}${suffix ? ` ${suffix}` : ""}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function debugRemote<E extends DebugEvent>(event: E, fields: DebugFields<E> = {}): void {
|
|
128
|
+
if (!process.env.DEVSPEC_DEBUG_REMOTE) return;
|
|
129
|
+
const record = formatDebugRecord(event, fields);
|
|
130
|
+
if (!record) return;
|
|
131
|
+
try {
|
|
132
|
+
appendFileSync(DEVSPEC_DEBUG_LOG_PATH, `${new Date().toISOString()} ${record}\n`);
|
|
133
|
+
} catch {
|
|
134
|
+
/* debug never breaks anything */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export function nextDeliveryRetryDelayMs(
|
|
2
|
+
failureCount: number,
|
|
3
|
+
baseMs = 5_000,
|
|
4
|
+
maxMs = 60_000,
|
|
5
|
+
): number {
|
|
6
|
+
const exponent = Math.max(0, Math.floor(failureCount) - 1);
|
|
7
|
+
return Math.min(baseMs * 2 ** exponent, maxMs);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pump- and automation-run-scoped authority lease for one asynchronous delivery. */
|
|
11
|
+
export interface DeliveryLease {
|
|
12
|
+
readonly generation: number;
|
|
13
|
+
readonly keyGeneration: number;
|
|
14
|
+
isCurrent(): boolean;
|
|
15
|
+
assertCurrent(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class StaleDeliveryError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super("delivery authority expired before preparation completed");
|
|
21
|
+
this.name = "StaleDeliveryError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ordered, non-blocking automation delivery outside the remote pump loop. */
|
|
26
|
+
export class DeliveryQueue {
|
|
27
|
+
private tail: Promise<void> = Promise.resolve();
|
|
28
|
+
private seen = new Set<string>();
|
|
29
|
+
private generation = 1;
|
|
30
|
+
private keyGenerations = new Map<string, number>();
|
|
31
|
+
private active = true;
|
|
32
|
+
|
|
33
|
+
private keyGeneration(key: string): number {
|
|
34
|
+
return this.keyGenerations.get(key) ?? 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns immediately; tasks execute serially in delivery order. */
|
|
38
|
+
enqueue(key: string, task: (lease: DeliveryLease) => Promise<void>, onError: (error: unknown) => void): boolean {
|
|
39
|
+
if (!this.active || !key || this.seen.has(key)) return false;
|
|
40
|
+
this.seen.add(key);
|
|
41
|
+
const generation = this.generation;
|
|
42
|
+
const keyGeneration = this.keyGeneration(key);
|
|
43
|
+
const isCurrent = () =>
|
|
44
|
+
this.active &&
|
|
45
|
+
this.generation === generation &&
|
|
46
|
+
this.keyGeneration(key) === keyGeneration;
|
|
47
|
+
const lease: DeliveryLease = {
|
|
48
|
+
generation,
|
|
49
|
+
keyGeneration,
|
|
50
|
+
isCurrent,
|
|
51
|
+
assertCurrent: () => {
|
|
52
|
+
if (!isCurrent()) throw new StaleDeliveryError();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
this.tail = this.tail
|
|
56
|
+
.then(() => {
|
|
57
|
+
lease.assertCurrent();
|
|
58
|
+
return task(lease);
|
|
59
|
+
})
|
|
60
|
+
.catch((error) => {
|
|
61
|
+
// A stopped/replaced bond owns neither warnings nor retries. The new
|
|
62
|
+
// pump has a new queue and receives any still-live delivery itself.
|
|
63
|
+
if (!lease.isCurrent() || error instanceof StaleDeliveryError) return;
|
|
64
|
+
// A live inbox may offer the same automation run again after a transient
|
|
65
|
+
// send failure. Task work never enters this queue.
|
|
66
|
+
this.seen.delete(key);
|
|
67
|
+
onError(error);
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Revoke in-flight and queued delivery for one automation-run identity only. */
|
|
73
|
+
invalidateKey(key: string): void {
|
|
74
|
+
if (!this.active || !key) return;
|
|
75
|
+
this.keyGenerations.set(key, this.keyGeneration(key) + 1);
|
|
76
|
+
this.seen.delete(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Revoke every in-flight and queued delivery owned by this pump generation. */
|
|
80
|
+
invalidate(): void {
|
|
81
|
+
if (!this.active) return;
|
|
82
|
+
this.active = false;
|
|
83
|
+
this.generation += 1;
|
|
84
|
+
this.seen.clear();
|
|
85
|
+
this.keyGenerations.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Test/shutdown observation only; the pump never awaits this queue. */
|
|
89
|
+
idle(): Promise<void> {
|
|
90
|
+
return this.tail;
|
|
91
|
+
}
|
|
92
|
+
}
|