@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,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — native tools.
|
|
3
|
+
*
|
|
4
|
+
* The generated catalog is the WIRE map. Every safe model-facing verb is a
|
|
5
|
+
* real Pi tool, immediately active when its context permits. Optional hybrid
|
|
6
|
+
* search recommends among those tools without changing activation. Pump/lifecycle
|
|
7
|
+
* verbs and post_session_message are never registered.
|
|
8
|
+
*
|
|
9
|
+
* Headless: tools work in every mode — errors come back as text the model can
|
|
10
|
+
* act on (e.g. "run /devspec.login"), never as UI-only state.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { resolveToken } from "./core/auth-store.ts";
|
|
15
|
+
import { selectTools } from "./core/tool-selection.ts";
|
|
16
|
+
import { getBond } from "./core/bond-state.ts";
|
|
17
|
+
import { registerRoomReader } from "./core/room-tools.ts";
|
|
18
|
+
import { ROOM_READ_TOOL } from "./core/room-reader.ts";
|
|
19
|
+
import { agentsSettingsUrl, TOKEN_ENV_VAR } from "./core/config.ts";
|
|
20
|
+
import { callDevspec, MODEL_TIMEOUT_MS } from "./core/client.ts";
|
|
21
|
+
import { repoResolver, validateCommitSha } from "./core/commit-ledger.ts";
|
|
22
|
+
import { observeReviewResult, listFlags, type ReviewObservation } from "./core/review-watch.ts";
|
|
23
|
+
import { reviewScope, reviewData } from "./core/review-context.ts";
|
|
24
|
+
import { jsonSchemaToTypebox } from "./core/json-schema.ts";
|
|
25
|
+
import { consumeDeliveryLifecycle } from "./core/active-deliveries.ts";
|
|
26
|
+
import { observeSuccessfulWorkResult } from "./core/work-state.ts";
|
|
27
|
+
import { guardWriteRefs, observeResultClaimRefs } from "./core/provenance-refs.ts";
|
|
28
|
+
import {
|
|
29
|
+
activeRequestChain,
|
|
30
|
+
SERVE_REQUEST_DESCRIPTION,
|
|
31
|
+
SERVE_REQUEST_SCHEMA,
|
|
32
|
+
SERVE_REQUEST_TOOL,
|
|
33
|
+
stampRequestRef,
|
|
34
|
+
unstampedNote,
|
|
35
|
+
} from "./core/request-chain.ts";
|
|
36
|
+
import { directedQuestionTranscriptReceiptId } from "./core/question-receipt-lifecycle.ts";
|
|
37
|
+
import {
|
|
38
|
+
catalogEntry,
|
|
39
|
+
DIRECTED_QUESTION_TOOL_NAME,
|
|
40
|
+
HOT_SET_VERBS,
|
|
41
|
+
isConnectionBoundTool,
|
|
42
|
+
scopeKeysForTool,
|
|
43
|
+
TOOL_LOADER_DESCRIPTION,
|
|
44
|
+
TOOL_LOADER_NAME,
|
|
45
|
+
TOOL_LOADER_PROMPT_METADATA,
|
|
46
|
+
allModelFacingTools,
|
|
47
|
+
hotToolPromptMetadata,
|
|
48
|
+
toolLoaderInputSchema,
|
|
49
|
+
withDirectedQuestionToolActive,
|
|
50
|
+
withPlanToolActive,
|
|
51
|
+
withPollToolActive,
|
|
52
|
+
} from "./core/model-surface.ts";
|
|
53
|
+
import {
|
|
54
|
+
planDuplicateRecovery,
|
|
55
|
+
RECOVERY_ENTRY_TYPE,
|
|
56
|
+
scanTranscriptToolState,
|
|
57
|
+
withRestoredTools,
|
|
58
|
+
} from "./core/activation-recovery.ts";
|
|
59
|
+
import { debugRemote } from "./core/debug.ts";
|
|
60
|
+
import { projectedToolResult, toolErrorResult, toolTextResult, unwrapMcpResult } from "./core/result.ts";
|
|
61
|
+
import {
|
|
62
|
+
noteModelToolResult,
|
|
63
|
+
registerContextBudgetToolNames,
|
|
64
|
+
} from "./core/context-budget.ts";
|
|
65
|
+
import {
|
|
66
|
+
clearAutomation,
|
|
67
|
+
clearWork,
|
|
68
|
+
extractItemRef,
|
|
69
|
+
extractAutomationRef,
|
|
70
|
+
noteAutomation,
|
|
71
|
+
noteWork,
|
|
72
|
+
paintStatus,
|
|
73
|
+
} from "./core/status-line.ts";
|
|
74
|
+
import { MODEL_VERB_NAMES, type CatalogEntry } from "./generated/mcp-catalog.ts";
|
|
75
|
+
import { toolVersionMismatchNote } from "./core/version-mismatch.ts";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A write the server queued instead of committing (DevSpec item 06936ee9). The
|
|
79
|
+
* served authority_contract decides who may commit and who a queued change waits
|
|
80
|
+
* on; Pi's only job is to make the outcome impossible to miss. The model relays
|
|
81
|
+
* the proposal id, the reason and who it waits on exactly as DevSpec sent them,
|
|
82
|
+
* and never reports the change as done. Pi does not say who that is itself: a
|
|
83
|
+
* pending version waits on whoever can give the OK, which is not always a
|
|
84
|
+
* Maintainer (contract 4.31.0, reversal_rule).
|
|
85
|
+
*/
|
|
86
|
+
export function queuedForReviewRelay(details: unknown): string | null {
|
|
87
|
+
if (details === null || typeof details !== "object" || Array.isArray(details)) return null;
|
|
88
|
+
const row = details as Record<string, unknown>;
|
|
89
|
+
if (row.outcome !== "queued_for_review") return null;
|
|
90
|
+
const text = (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
91
|
+
return [
|
|
92
|
+
"QUEUED — not in effect.",
|
|
93
|
+
reviewData({ proposal_id: text(row.proposal_id), reason: text(row.reason), waiting_on: text(row.waiting_on) }),
|
|
94
|
+
"Relay the proposal id, the reason and who it waits on to the person, and say it applies only once it is accepted; do not report this change as done. Who can accept it is the served contract's authority_contract.",
|
|
95
|
+
].join(" ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unwrap and project a DevSpec result, retaining complete data in details.
|
|
100
|
+
* `hostNote` is Pi's own note about the call (never server data), appended last.
|
|
101
|
+
* `modelArgs` are the arguments the model sent, so a receipt can name the fields
|
|
102
|
+
* that merely repeat them instead of printing them again.
|
|
103
|
+
*/
|
|
104
|
+
export function unwrapOrThrow(name: string, raw: unknown, hostNote?: string, modelArgs?: Record<string, unknown>) {
|
|
105
|
+
const unwrapped = unwrapMcpResult(raw);
|
|
106
|
+
const relay = unwrapped.isError ? null : queuedForReviewRelay(unwrapped.details);
|
|
107
|
+
const { details, isError } = unwrapped;
|
|
108
|
+
const text = relay ? `${relay}\n\n${unwrapped.text}` : unwrapped.text;
|
|
109
|
+
// Errors intentionally stay full rather than passing through a size-oriented
|
|
110
|
+
// success projection: the model must see every actionable server detail.
|
|
111
|
+
if (isError) {
|
|
112
|
+
let structuredError = "";
|
|
113
|
+
try {
|
|
114
|
+
structuredError = details == null ? "" : JSON.stringify(details, null, 2);
|
|
115
|
+
} catch {
|
|
116
|
+
/* parsed JSON should be serializable; retain the generic fallback if not */
|
|
117
|
+
}
|
|
118
|
+
// A refusal caused by DevSpec and this extension describing the tool
|
|
119
|
+
// differently says so, so the model can tell the person to update
|
|
120
|
+
// (item 39c05c1f). Ordinary mistakes carry no such note.
|
|
121
|
+
const mismatch = toolVersionMismatchNote(name, details, catalogEntry(name)?.inputSchema);
|
|
122
|
+
const reason = text || structuredError || `DevSpec tool ${name} failed.`;
|
|
123
|
+
throw new Error(mismatch ? `${reason}\n\n${mismatch}` : reason);
|
|
124
|
+
}
|
|
125
|
+
const projected = projectedToolResult(name, unwrapped, modelArgs);
|
|
126
|
+
// Append AFTER projection: ack/full projections intentionally read details,
|
|
127
|
+
// so putting the relay into input.text silently threw it away.
|
|
128
|
+
const body = relay ? `${relay}\n\n${projected.content[0].text}` : projected.content[0].text;
|
|
129
|
+
return relay || hostNote ? toolTextResult(hostNote ? `${body}\n\n${hostNote}` : body, details) : projected;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Feed the conflict watch: signals in results become structural flags. */
|
|
133
|
+
function watchForSignals(name: string, result: unknown, args: Record<string, unknown>, scope: string, observed?: readonly ReviewObservation[]): void {
|
|
134
|
+
try {
|
|
135
|
+
const details = (result as { details?: unknown }).details;
|
|
136
|
+
observeReviewResult(name, details, args, scope, observed);
|
|
137
|
+
} catch {
|
|
138
|
+
/* never let the watch break a tool call */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function claimFlag(result: unknown): unknown {
|
|
143
|
+
if (!result || typeof result !== "object") return undefined;
|
|
144
|
+
const root = result as { claimed?: unknown; details?: { claimed?: unknown } };
|
|
145
|
+
return root.details && typeof root.details === "object" ? root.details.claimed : root.claimed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applyWorkStatus(name: string, result: unknown, claimArmed: boolean): void {
|
|
149
|
+
const ref = extractItemRef(result);
|
|
150
|
+
if (name === "claim_work_item") {
|
|
151
|
+
// Footer state is a view of observed coordination, never its source. A
|
|
152
|
+
// rejected claim must not look active.
|
|
153
|
+
if (claimArmed) noteWork(ref);
|
|
154
|
+
} else if (name === "record_implementation" || name === "release_work_item" || name === "fail_work_item") {
|
|
155
|
+
clearWork(ref.id);
|
|
156
|
+
} else if (name === "claim_automation_run") {
|
|
157
|
+
if (claimFlag(result) !== false) noteAutomation(extractAutomationRef(result));
|
|
158
|
+
} else if (name === "record_automation_run") {
|
|
159
|
+
clearAutomation(extractAutomationRef(result).runId);
|
|
160
|
+
}
|
|
161
|
+
paintStatus();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Consume complete structured details for conflict and footer mechanics. */
|
|
165
|
+
export function consumeResultMechanics(
|
|
166
|
+
name: string,
|
|
167
|
+
result: unknown,
|
|
168
|
+
args: Record<string, unknown> = {},
|
|
169
|
+
sessionId?: string,
|
|
170
|
+
projectId?: string,
|
|
171
|
+
scope = reviewScope(sessionId, projectId),
|
|
172
|
+
observed?: readonly ReviewObservation[],
|
|
173
|
+
): void {
|
|
174
|
+
watchForSignals(name, result, args, scope, observed);
|
|
175
|
+
// Claim refs are dealt through results, not deliveries — observe them so the
|
|
176
|
+
// guard knows them (item a03ba7ab).
|
|
177
|
+
observeResultClaimRefs(result);
|
|
178
|
+
// executeVerb calls this only after unwrapOrThrow has established MCP success.
|
|
179
|
+
// This observation drives status and nudge suppression only; it grants no permission.
|
|
180
|
+
const claimArmed = sessionId
|
|
181
|
+
? observeSuccessfulWorkResult(name, args, result, sessionId, projectId) && name === "claim_work_item"
|
|
182
|
+
: false;
|
|
183
|
+
applyWorkStatus(name, result, claimArmed);
|
|
184
|
+
consumeDeliveryLifecycle(name, args, result);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const NOT_CONNECTED =
|
|
188
|
+
`Not connected to DevSpec. Run /devspec.login, or set ${TOKEN_ENV_VAR}. ` +
|
|
189
|
+
`Tokens are minted at ${agentsSettingsUrl()}.`;
|
|
190
|
+
|
|
191
|
+
type CallCtx = { sessionManager: { getSessionId(): string }; cwd: string };
|
|
192
|
+
|
|
193
|
+
type PiToolResult = {
|
|
194
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
195
|
+
terminate?: boolean;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A created directed-question card is the blocking turn's terminal output. Pi
|
|
200
|
+
* stops only when every result in the same tool batch carries terminate=true,
|
|
201
|
+
* so mixed create/read/work batches remain safe and continue normally.
|
|
202
|
+
*/
|
|
203
|
+
export function shouldTerminateAfterDirectedQuestionCreate(
|
|
204
|
+
name: string,
|
|
205
|
+
args: Record<string, unknown>,
|
|
206
|
+
result: unknown,
|
|
207
|
+
): boolean {
|
|
208
|
+
return name === DIRECTED_QUESTION_TOOL_NAME && args.action === "create" &&
|
|
209
|
+
args.keep_turn !== true &&
|
|
210
|
+
directedQuestionTranscriptReceiptId(result) !== null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Attribute model-visible characters only; details/args never enter telemetry. */
|
|
214
|
+
function measuredToolResult<T extends PiToolResult>(name: string, result: T): T {
|
|
215
|
+
const chars = (result.content ?? [])
|
|
216
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
217
|
+
.reduce((sum, block) => sum + (block.text?.length ?? 0), 0);
|
|
218
|
+
noteModelToolResult(name, chars);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function applyCommitLedgerVerdict(
|
|
223
|
+
params: Record<string, unknown>,
|
|
224
|
+
verdict: Extract<ReturnType<typeof validateCommitSha>, { ok: true }>,
|
|
225
|
+
): Record<string, unknown> {
|
|
226
|
+
// Provenance source is a host observation, never a model-authored parameter.
|
|
227
|
+
const { commit_provenance_source: _modelSource, commit_sha: _modelSha, ...hostSafeParams } = params;
|
|
228
|
+
return {
|
|
229
|
+
...hostSafeParams,
|
|
230
|
+
// sha null = no sha sent at all: the server's own mechanical fill (or the
|
|
231
|
+
// tag on the landed commit) carries the identity — never write a null key.
|
|
232
|
+
// Supplied hints retain their bytes. Only an explicit full object may be
|
|
233
|
+
// locally corroborated; omitted/short inputs need server-scoped evidence.
|
|
234
|
+
...(verdict.sha !== null ? { commit_sha: verdict.sha } : {}),
|
|
235
|
+
// Pi can mechanically attach a recognized commit object's message, but
|
|
236
|
+
// cannot prove that a Bash surface authored its DevSpec reference. Source
|
|
237
|
+
// therefore stays absent and the server records conservative linkage.
|
|
238
|
+
...(verdict.commitMessage ? { commit_message: verdict.commitMessage } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Per-verb argument transforms that cannot live in the generated catalog. */
|
|
243
|
+
const AUGMENTS: Record<string, (params: Record<string, unknown>, ctx: CallCtx) => Record<string, unknown>> = {
|
|
244
|
+
record_implementation: (params, ctx) => {
|
|
245
|
+
// The ledger is explicit-object assistance, not a work selector. Omitted
|
|
246
|
+
// SHAs stay omitted; short hints stay short so local context cannot erase
|
|
247
|
+
// project-wide ambiguity. The server owns claim-scoped selection and
|
|
248
|
+
// resolution (e47fcd4e, ADR 60efaed0). Only malformed shape throws.
|
|
249
|
+
const verdict = validateCommitSha(params.commit_sha as string | undefined, repoResolver(ctx.cwd));
|
|
250
|
+
if (!verdict.ok) throw new Error(verdict.error);
|
|
251
|
+
return applyCommitLedgerVerdict(params, verdict);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const LOCAL_SESSION_STAMPED_VERBS: ReadonlySet<string> = new Set([
|
|
256
|
+
"update_task",
|
|
257
|
+
"record_automation_run",
|
|
258
|
+
"claim_work_item",
|
|
259
|
+
"record_implementation",
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
/** The current Pi runtime, never model parameters, owns local resume identity. */
|
|
263
|
+
export function stampHostLocalSession(
|
|
264
|
+
name: string,
|
|
265
|
+
params: Record<string, unknown>,
|
|
266
|
+
localSessionId: string,
|
|
267
|
+
): Record<string, unknown> {
|
|
268
|
+
if (!LOCAL_SESSION_STAMPED_VERBS.has(name)) return params;
|
|
269
|
+
return { ...params, local_session_id: localSessionId };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* This host is Pi. `provider` on these verbs is not a model choice — it is a
|
|
274
|
+
* fact about which coding agent is running, and DevSpec renders it as the tool
|
|
275
|
+
* logo on the Agents page, active-agent tiles and provenance chips. A model that
|
|
276
|
+
* omits it leaves the work unattributed; a model that guesses it attributes Pi's
|
|
277
|
+
* work to another tool. So it is stamped here, after the model's params, and
|
|
278
|
+
* always wins — the same treatment the commit ledger gives commit_sha.
|
|
279
|
+
*
|
|
280
|
+
* NOT included: create_automation / update_automation take `preferred_provider`,
|
|
281
|
+
* which is "which agent is this automation written FOR" — a genuine authoring
|
|
282
|
+
* choice that belongs to the caller, not the host's identity.
|
|
283
|
+
*/
|
|
284
|
+
const HOST_PROVIDER = "pi";
|
|
285
|
+
|
|
286
|
+
const PROVIDER_STAMPED_VERBS: ReadonlySet<string> = new Set([
|
|
287
|
+
// Attribution: persisted to tasks.provider.
|
|
288
|
+
"claim_work_item",
|
|
289
|
+
"record_implementation",
|
|
290
|
+
"record_completed_work",
|
|
291
|
+
// Automation runs: free-text "your coding agent", used to match a run's
|
|
292
|
+
// provider constraint and to build the resume affordance.
|
|
293
|
+
"claim_automation_run",
|
|
294
|
+
"record_automation_run",
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
/** Stamp the host's own provider identity on the verbs that record or filter it. */
|
|
298
|
+
export function stampHostProvider(
|
|
299
|
+
name: string,
|
|
300
|
+
params: Record<string, unknown>,
|
|
301
|
+
): Record<string, unknown> {
|
|
302
|
+
if (!PROVIDER_STAMPED_VERBS.has(name)) return params;
|
|
303
|
+
return { ...params, provider: HOST_PROVIDER };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Drop scope the extension already knows, then stamp bond/cwd values. */
|
|
307
|
+
export function stampKnownScope(params: Record<string, unknown>, name?: string): Record<string, unknown> {
|
|
308
|
+
const omit = scopeKeysForTool(name);
|
|
309
|
+
const stamped: Record<string, unknown> = {};
|
|
310
|
+
for (const [key, value] of Object.entries(params)) {
|
|
311
|
+
if (omit.has(key)) continue;
|
|
312
|
+
stamped[key] = value;
|
|
313
|
+
}
|
|
314
|
+
// Connection-bound ledgers derive identity, session and project exclusively
|
|
315
|
+
// from the capability-authenticated connection. Even host-known ids do not
|
|
316
|
+
// become model arguments.
|
|
317
|
+
if (name && isConnectionBoundTool(name)) return stamped;
|
|
318
|
+
const bond = getBond();
|
|
319
|
+
// Target session_id (get_session_transcript) is model-owned; only fill the
|
|
320
|
+
// bonded room when the model omitted it. Other verbs still stamp over.
|
|
321
|
+
if (!stamped.session_id && bond?.sessionId) stamped.session_id = bond.sessionId;
|
|
322
|
+
if (bond?.connectionId) stamped.connection_id = bond.connectionId;
|
|
323
|
+
return stamped;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function executeVerb(name: string, params: Record<string, unknown>, ctx: CallCtx) {
|
|
327
|
+
// The provenance-ref guard (item a03ba7ab): a ref this process was never
|
|
328
|
+
// dealt is a transcription error — refuse it locally, before any network.
|
|
329
|
+
// Omission is always allowed: the server attributes to the open turn.
|
|
330
|
+
const refVerdict = guardWriteRefs(params);
|
|
331
|
+
if (!refVerdict.ok) return measuredToolResult(name, toolErrorResult(refVerdict.message));
|
|
332
|
+
const resolved = await resolveToken();
|
|
333
|
+
if (!resolved) return measuredToolResult(name, toolErrorResult(NOT_CONNECTED));
|
|
334
|
+
try {
|
|
335
|
+
const augment = AUGMENTS[name];
|
|
336
|
+
const bond = getBond();
|
|
337
|
+
const scopedParams = stampKnownScope(params, name);
|
|
338
|
+
const augmentedParams = augment ? augment(scopedParams, ctx) : scopedParams;
|
|
339
|
+
// The request this write serves (item 7a4d04fd): host-only request_ref,
|
|
340
|
+
// stamped from what woke this turn, on every tool whose wire schema takes
|
|
341
|
+
// it. A turn carrying several people's requests that the agent has not
|
|
342
|
+
// named one of refuses the write here, before the network.
|
|
343
|
+
const stamped = await stampRequestRef({
|
|
344
|
+
params: stampHostLocalSession(name, stampHostProvider(name, augmentedParams), ctx.sessionManager.getSessionId()),
|
|
345
|
+
inputSchema: catalogEntry(name)?.inputSchema,
|
|
346
|
+
connectionId: bond?.connectionId,
|
|
347
|
+
});
|
|
348
|
+
if ("deny" in stamped) return measuredToolResult(name, toolErrorResult(stamped.deny));
|
|
349
|
+
const args = stamped.args;
|
|
350
|
+
const unstamped = stamped.unstamped;
|
|
351
|
+
if (unstamped) debugRemote("request write unstamped", { connectionChanged: unstamped === "connection_changed", notRecorded: unstamped === "not_recorded", errorPresent: unstamped === "error" });
|
|
352
|
+
const localSessionId = ctx.sessionManager.getSessionId();
|
|
353
|
+
const scope = reviewScope(localSessionId, bond?.projectId ?? ctx.cwd, resolved.token);
|
|
354
|
+
const observed = listFlags(scope);
|
|
355
|
+
const raw = await callDevspec(resolved.token, name, args, {
|
|
356
|
+
cwd: ctx.cwd,
|
|
357
|
+
lane: "work",
|
|
358
|
+
timeoutMs: MODEL_TIMEOUT_MS,
|
|
359
|
+
...((isConnectionBoundTool(name)) && bond?.connectionCapability
|
|
360
|
+
? { connectionCapability: bond.connectionCapability }
|
|
361
|
+
: {}),
|
|
362
|
+
});
|
|
363
|
+
const projected = unwrapOrThrow(name, raw, unstamped ? unstampedNote(unstamped) : undefined, params);
|
|
364
|
+
const result = shouldTerminateAfterDirectedQuestionCreate(name, args, projected)
|
|
365
|
+
? { ...projected, terminate: true as const }
|
|
366
|
+
: projected;
|
|
367
|
+
consumeResultMechanics(name, result, args, localSessionId, bond?.projectId, scope, observed);
|
|
368
|
+
return measuredToolResult(name, result);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
371
|
+
return measuredToolResult(name, toolErrorResult(message));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function registerCatalogTool(pi: ExtensionAPI, def: CatalogEntry): void {
|
|
376
|
+
pi.registerTool({
|
|
377
|
+
name: def.name,
|
|
378
|
+
label: def.label,
|
|
379
|
+
description: def.description,
|
|
380
|
+
// Deferred tools must not mutate the system prompt when activated.
|
|
381
|
+
...hotToolPromptMetadata(def),
|
|
382
|
+
parameters: jsonSchemaToTypebox(def.inputSchema),
|
|
383
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
384
|
+
return executeVerb(def.name, params as Record<string, unknown>, ctx);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Recover legacy deferred announcements without re-announcing denied tools. */
|
|
390
|
+
const RARE_VERB_SET: ReadonlySet<string> = new Set(MODEL_VERB_NAMES);
|
|
391
|
+
const MODEL_VERB_SET: ReadonlySet<string> = new Set([...MODEL_VERB_NAMES, ROOM_READ_TOOL]);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Tools deactivated by duplicate-tool recovery (item dc8f52d4). Re-seeded from
|
|
395
|
+
* the transcript on every session_start, so it survives an in-process /reload
|
|
396
|
+
* of the same session but never leaks into a different one.
|
|
397
|
+
*/
|
|
398
|
+
const sessionDeniedTools = new Set<string>();
|
|
399
|
+
|
|
400
|
+
function registerToolLoader(pi: ExtensionAPI): void {
|
|
401
|
+
pi.registerTool({
|
|
402
|
+
name: TOOL_LOADER_NAME,
|
|
403
|
+
label: "Search DevSpec tools",
|
|
404
|
+
description: TOOL_LOADER_DESCRIPTION,
|
|
405
|
+
...TOOL_LOADER_PROMPT_METADATA,
|
|
406
|
+
parameters: jsonSchemaToTypebox(toolLoaderInputSchema()),
|
|
407
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
408
|
+
const input = params as { query?: unknown; limit?: unknown; offset?: unknown };
|
|
409
|
+
const query = String(input.query ?? "").trim();
|
|
410
|
+
if (!query || query.length > 1000) return toolErrorResult("query must contain 1–1000 characters");
|
|
411
|
+
const result = await selectTools(query, input.limit, pi.getActiveTools().filter(name => !sessionDeniedTools.has(name)), async args => {
|
|
412
|
+
const token = await resolveToken();
|
|
413
|
+
if (!token) throw new Error("not connected");
|
|
414
|
+
const bond = getBond();
|
|
415
|
+
const raw = await callDevspec(token.token, TOOL_LOADER_NAME, stampKnownScope(args, TOOL_LOADER_NAME), {
|
|
416
|
+
cwd: ctx.cwd, lane: "work", timeoutMs: 5000,
|
|
417
|
+
...(bond?.connectionCapability ? { connectionCapability: bond.connectionCapability } : {}),
|
|
418
|
+
});
|
|
419
|
+
const unwrapped = unwrapMcpResult(raw);
|
|
420
|
+
if (unwrapped.isError) throw new Error("search unavailable");
|
|
421
|
+
return unwrapped.details;
|
|
422
|
+
}, input.offset);
|
|
423
|
+
const lines = [
|
|
424
|
+
result.degraded ? result.reason : "These tools are already available; call the appropriate one directly.",
|
|
425
|
+
...result.matches.map(hit => `${hit.name}: ${hit.description}`),
|
|
426
|
+
!result.matches.length ? "No matching callable tool found. No operation was executed." : "",
|
|
427
|
+
result.unavailable.length ? `Unavailable here: ${result.unavailable.join(", ")}. No substitute was selected.` : "",
|
|
428
|
+
result.page.has_more ? `${result.page.returned} of ${result.page.total_matching} matches; pass offset ${result.page.next_offset} for more.` : "",
|
|
429
|
+
].filter(Boolean);
|
|
430
|
+
return measuredToolResult(TOOL_LOADER_NAME, toolTextResult(lines.join("\n"), result));
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The agent names the request its next writes serve (item 7a4d04fd) when a
|
|
437
|
+
* turn carries several people's requests. A host tool, not a DevSpec verb:
|
|
438
|
+
* the request chain lives in this process. Active only while it can matter
|
|
439
|
+
* (devspec-remote shows it when a second request joins a turn).
|
|
440
|
+
*/
|
|
441
|
+
function registerServeRequestTool(pi: ExtensionAPI): void {
|
|
442
|
+
pi.registerTool({
|
|
443
|
+
name: SERVE_REQUEST_TOOL,
|
|
444
|
+
label: "Name the request",
|
|
445
|
+
description: SERVE_REQUEST_DESCRIPTION,
|
|
446
|
+
parameters: jsonSchemaToTypebox(SERVE_REQUEST_SCHEMA as unknown as Record<string, unknown>),
|
|
447
|
+
async execute(_toolCallId, params) {
|
|
448
|
+
const input = params as { message_id?: unknown; terminal?: unknown; none?: unknown };
|
|
449
|
+
const chain = activeRequestChain();
|
|
450
|
+
const bond = getBond();
|
|
451
|
+
if (!chain || !bond || chain.connectionId !== bond.connectionId) {
|
|
452
|
+
return measuredToolResult(SERVE_REQUEST_TOOL, toolErrorResult("This Pi is not carrying any DevSpec requests, so there is nothing to name."));
|
|
453
|
+
}
|
|
454
|
+
const served = await chain.serve({ messageId: input.message_id, terminal: input.terminal, none: input.none });
|
|
455
|
+
return measuredToolResult(SERVE_REQUEST_TOOL, served.ok
|
|
456
|
+
? toolTextResult(`Your next DevSpec writes serve ${served.serving}, until another request reaches you.`, { serving: served.serving })
|
|
457
|
+
: toolErrorResult(served.error));
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Every safe generated model verb, each with its own slim schema. */
|
|
463
|
+
export function modelFacingCatalog(): CatalogEntry[] {
|
|
464
|
+
return allModelFacingTools();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Preserve foreign tools and expose every safe non-contextual operation. */
|
|
468
|
+
export function initialActiveTools(active: readonly string[]): string[] {
|
|
469
|
+
const devspecVerbs = new Set(modelFacingCatalog().map((entry) => entry.name));
|
|
470
|
+
return [
|
|
471
|
+
...new Set([
|
|
472
|
+
// serve_request is shown only while a turn carries several requests.
|
|
473
|
+
...active.filter((name) => !devspecVerbs.has(name) && name !== TOOL_LOADER_NAME && name !== ROOM_READ_TOOL && name !== SERVE_REQUEST_TOOL),
|
|
474
|
+
...HOT_SET_VERBS,
|
|
475
|
+
TOOL_LOADER_NAME,
|
|
476
|
+
ROOM_READ_TOOL,
|
|
477
|
+
]),
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export default function (pi: ExtensionAPI) {
|
|
482
|
+
registerContextBudgetToolNames([...MODEL_VERB_NAMES, TOOL_LOADER_NAME, ROOM_READ_TOOL, SERVE_REQUEST_TOOL]);
|
|
483
|
+
for (const entry of allModelFacingTools()) if (entry.name !== TOOL_LOADER_NAME) registerCatalogTool(pi, entry);
|
|
484
|
+
registerToolLoader(pi);
|
|
485
|
+
registerServeRequestTool(pi);
|
|
486
|
+
registerRoomReader(pi, () => {
|
|
487
|
+
if (sessionDeniedTools.has("get_session_transcript")) return false;
|
|
488
|
+
const active = pi.getActiveTools();
|
|
489
|
+
if (!active.includes("get_session_transcript")) pi.setActiveTools([...active, "get_session_transcript"]);
|
|
490
|
+
return true;
|
|
491
|
+
});
|
|
492
|
+
pi.on("session_start", (_event, ctx) => {
|
|
493
|
+
const bond = getBond();
|
|
494
|
+
const initial = initialActiveTools(pi.getActiveTools());
|
|
495
|
+
// Resume/reload: the transcript keeps every addedToolNames announcement
|
|
496
|
+
// forever, so re-activate exactly what this branch already announced
|
|
497
|
+
// (item dc8f52d4) — otherwise the loader re-announces a stripped tool and
|
|
498
|
+
// Kimi-mode serializers put the same definition on the wire twice. Fresh
|
|
499
|
+
// sessions have no announcements, so the happy path is unchanged.
|
|
500
|
+
const { restore, denied } = scanTranscriptToolState(
|
|
501
|
+
ctx?.sessionManager?.getBranch?.() ?? [],
|
|
502
|
+
(name) => RARE_VERB_SET.has(name) || name === ROOM_READ_TOOL,
|
|
503
|
+
);
|
|
504
|
+
sessionDeniedTools.clear();
|
|
505
|
+
for (const name of denied) sessionDeniedTools.add(name);
|
|
506
|
+
const withRestored = withRestoredTools(initial, restore).filter(name => !sessionDeniedTools.has(name));
|
|
507
|
+
const attached = Boolean(bond?.sessionId && bond.connectionCapability);
|
|
508
|
+
const withPlan = withPlanToolActive(withRestored, Boolean(bond?.sessionId));
|
|
509
|
+
const withPoll = withPollToolActive(withPlan, attached);
|
|
510
|
+
const withQuestion = withDirectedQuestionToolActive(withPoll, attached);
|
|
511
|
+
pi.setActiveTools(withQuestion.filter(name => !sessionDeniedTools.has(name)));
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Safety net (item dc8f52d4): if a duplicate-tools provider error still
|
|
515
|
+
// occurs — e.g. a transcript poisoned before the resume fix — deactivate the
|
|
516
|
+
// named tools and continue the turn instead of letting the session brick.
|
|
517
|
+
// Compensates for Pi's Kimi deferred-tools serializer re-injecting a tool
|
|
518
|
+
// definition after every announcing toolResult group; the serializer-side
|
|
519
|
+
// fix is upstream (see the follow-up item linked from dc8f52d4).
|
|
520
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
521
|
+
const plan = planDuplicateRecovery(event.messages, {
|
|
522
|
+
isCatalogTool: (name) => MODEL_VERB_SET.has(name),
|
|
523
|
+
isActive: (name) => pi.getActiveTools().includes(name),
|
|
524
|
+
isDenied: (name) => sessionDeniedTools.has(name),
|
|
525
|
+
});
|
|
526
|
+
if (!plan) return;
|
|
527
|
+
for (const name of plan.names) sessionDeniedTools.add(name);
|
|
528
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !plan.names.includes(name)));
|
|
529
|
+
debugRemote("duplicate-tool recovery", { count: plan.names.length });
|
|
530
|
+
// Persist the denial with the session (appendEntry never reaches the
|
|
531
|
+
// model) so a later resume does not restore a twice-announced tool.
|
|
532
|
+
try {
|
|
533
|
+
pi.appendEntry(RECOVERY_ENTRY_TYPE, { denied: plan.names, error: plan.errorMessage });
|
|
534
|
+
} catch (err) {
|
|
535
|
+
debugRemote("duplicate-tool recovery persist failed", { errorPresent: Boolean(err) });
|
|
536
|
+
}
|
|
537
|
+
if (ctx.hasUI) {
|
|
538
|
+
ctx.ui.notify(
|
|
539
|
+
`DevSpec recovered from a provider error — deactivated for this session: ${plan.names.join(", ")}`,
|
|
540
|
+
"warning",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
pi.sendUserMessage(
|
|
545
|
+
`DevSpec recovery: the provider rejected the last request because the tool${plan.names.length === 1 ? "" : "s"} ${plan.names.join(", ")} ${plan.names.length === 1 ? "was" : "were"} defined twice. ${plan.names.length === 1 ? "It is" : "They are"} deactivated for the rest of this session and cannot be re-loaded here — please continue without ${plan.names.length === 1 ? "it" : "them"}.`,
|
|
546
|
+
{ deliverAs: "followUp" },
|
|
547
|
+
);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
debugRemote("duplicate-tool recovery continue failed", { errorPresent: Boolean(err) });
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|