@danypops/pi-papyrus 0.46.3 → 0.46.5
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/extension/src/index.ts
CHANGED
|
@@ -694,21 +694,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
694
694
|
},
|
|
695
695
|
});
|
|
696
696
|
|
|
697
|
+
// registerNotesVehicle defers the actual registerVehicleTools() call to session_start
|
|
698
|
+
// internally (via registerVehicleToolsWhenReady), since Pi's extension runtime only
|
|
699
|
+
// finishes initializing (pi.getAllTools()/etc. becoming callable) after every
|
|
700
|
+
// extension's top-level factory has resolved. Calling it here, at factory time, is
|
|
701
|
+
// therefore safe -- and deliberately not awaited, so a slow-starting daemon's bounded
|
|
702
|
+
// retries never block this factory or session_start itself.
|
|
703
|
+
void registerNotesVehicle(pi);
|
|
704
|
+
|
|
697
705
|
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
698
706
|
|
|
699
707
|
pi.on("session_start", async (_event, ctx) => {
|
|
700
|
-
// registerVehicleTools() (which registerNotesVehicle wraps) needs
|
|
701
|
-
// pi.getAllTools()/getActiveTools()/setActiveTools() -- Pi's extension
|
|
702
|
-
// runtime only finishes initializing after every extension's top-level
|
|
703
|
-
// factory (this one included) has resolved, so calling it directly from
|
|
704
|
-
// there throws "Extension runtime not initialized" (previously silently
|
|
705
|
-
// swallowed by registerNotesVehicle's own daemon-unreachable try/catch,
|
|
706
|
-
// making every projected notes.* tool invisible to the model with zero
|
|
707
|
-
// visible sign why -- confirmed live in the identical pi-tickets bug).
|
|
708
|
-
// session_start fires only after that initialization completes, and Pi
|
|
709
|
-
// awaits every session_start handler before the model's first turn, so
|
|
710
|
-
// registering here is both safe and still visible on turn one.
|
|
711
|
-
await registerNotesVehicle(pi);
|
|
712
708
|
// Registers this session's identity with the daemon as early as possible -- before any
|
|
713
709
|
// Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
|
|
714
710
|
// domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporary diagnostic instrumentation for the still-open /reload rendering-fallback
|
|
3
|
+
* investigation (papyrus task 4930cd9b): a Vehicle tool call executes and returns real
|
|
4
|
+
* data but renders as raw JSON (call args and result both) instead of through its
|
|
5
|
+
* registered renderCall/renderResult. Leading theory: a timing race between
|
|
6
|
+
* registerVehicleToolsWhenReady's fire-and-forget registration attempt and Pi resolving
|
|
7
|
+
* a tool's ToolDefinition (session.getToolDefinition(name), read live from Pi's own
|
|
8
|
+
* _toolDefinitions Map at the moment a tool-call message streams in -- see
|
|
9
|
+
* @earendil-works/pi-coding-agent's agent-session.js/interactive-mode.ts).
|
|
10
|
+
*
|
|
11
|
+
* Opt-in (PAPYRUS_RENDER_DIAG=1) and best-effort (never throws, never blocks a real
|
|
12
|
+
* call) -- appends JSONL to PAPYRUS_RENDER_DIAG_PATH (default
|
|
13
|
+
* ~/.cache/papyrus/render-diag.log). Content-safe: never logs artifact body/title text,
|
|
14
|
+
* only lengths and a small allow-listed set of short categorical fields (id/kind/status/
|
|
15
|
+
* subtype/alias), matching the same discipline as every other diagnostic log in this
|
|
16
|
+
* ecosystem (e.g. pi-web-spider's own diag.log).
|
|
17
|
+
*
|
|
18
|
+
* Remove once the investigation concludes either way.
|
|
19
|
+
*/
|
|
20
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const SAFE_LITERAL_KEYS = new Set(["id", "kind", "status", "subtype", "alias"]);
|
|
25
|
+
|
|
26
|
+
function isEnabled(): boolean {
|
|
27
|
+
return process.env.PAPYRUS_RENDER_DIAG === "1";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function diagPath(): string {
|
|
31
|
+
return process.env.PAPYRUS_RENDER_DIAG_PATH ?? join(homedir(), ".cache", "papyrus", "render-diag.log");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Redacts to shape + length, never content -- except a small allow-listed set of short, non-sensitive categorical fields. */
|
|
35
|
+
export function shapeFingerprint(value: unknown, key?: string): unknown {
|
|
36
|
+
if (value === null || value === undefined) return value;
|
|
37
|
+
if (Array.isArray(value))
|
|
38
|
+
return { type: "array", length: value.length, sample: value.length > 0 ? shapeFingerprint(value[0]) : undefined };
|
|
39
|
+
if (typeof value === "string") return key && SAFE_LITERAL_KEYS.has(key) ? value : { type: "string", length: value.length };
|
|
40
|
+
if (typeof value === "boolean" || typeof value === "number") return value;
|
|
41
|
+
if (typeof value === "object") {
|
|
42
|
+
const row = value as Record<string, unknown>;
|
|
43
|
+
const fields: Record<string, unknown> = {};
|
|
44
|
+
for (const [fieldKey, fieldValue] of Object.entries(row)) fields[fieldKey] = shapeFingerprint(fieldValue, fieldKey);
|
|
45
|
+
return { type: "object", keys: Object.keys(row), fields };
|
|
46
|
+
}
|
|
47
|
+
return { type: typeof value };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function recordRenderDiagnostic(entry: Record<string, unknown>): void {
|
|
51
|
+
if (!isEnabled()) return;
|
|
52
|
+
try {
|
|
53
|
+
const path = diagPath();
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
|
|
56
|
+
} catch {
|
|
57
|
+
/* best-effort -- a broken diagnostic log must never break a real render or invocation */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { Artifact } from "@danypops/papyrus";
|
|
16
16
|
import { TOOL_COLLAPSED_ROW_LIMIT } from "@danypops/papyrus";
|
|
17
17
|
import type { VehicleToolRenderers } from "@danypops/vehicle-client-pi";
|
|
18
|
-
import { renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render";
|
|
18
|
+
import { renderVehicleCall, renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render";
|
|
19
19
|
import type { VehicleOperationDescriptor } from "@danypops/vehicle-core";
|
|
20
20
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { ArtifactCard, detailViewTheme, expandHint, measure, statusColor, statusGlyph } from "../tool-rendering/artifact-card.ts";
|
|
32
32
|
import { ArtifactListCard } from "../tool-rendering/artifact-list.ts";
|
|
33
33
|
import { type ArtifactFocusAnnotation, createArtifactDetails, createArtifactListDetails } from "../tool-rendering/render-model.ts";
|
|
34
|
+
import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
|
|
34
35
|
|
|
35
36
|
function isArtifact(value: unknown): value is Artifact {
|
|
36
37
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -385,9 +386,29 @@ function renderTaskCompletion(result: TaskCompletionOutput, theme: Theme, expand
|
|
|
385
386
|
|
|
386
387
|
export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor): VehicleToolRenderers {
|
|
387
388
|
return {
|
|
389
|
+
// Pure pass-through to the generic renderer -- the only reason this exists at all is
|
|
390
|
+
// the /reload investigation (papyrus task 4930cd9b): its absence from the diagnostic
|
|
391
|
+
// log for a real invocation (see onInvoked in vehicle-notes-client.ts) is itself
|
|
392
|
+
// evidence Pi never found ANY renderer -- ours or vehicle-client-pi's generic default
|
|
393
|
+
// -- for that specific tool call, distinct from this renderer running and choosing
|
|
394
|
+
// the generic path internally (which DOES show up here).
|
|
395
|
+
renderCall(args, theme, context) {
|
|
396
|
+
recordRenderDiagnostic({ event: "render-call-invoked", operation: descriptor.name });
|
|
397
|
+
return renderVehicleCall(descriptor, args, theme, context);
|
|
398
|
+
},
|
|
388
399
|
renderResult(result, options, theme, context) {
|
|
389
400
|
if (!options.isPartial && !context.isError) {
|
|
390
401
|
const output = (result.details as { output?: unknown } | undefined)?.output;
|
|
402
|
+
// /reload rendering-fallback investigation (papyrus task 4930cd9b) -- correlates
|
|
403
|
+
// against vehicle-notes-client.ts's onInvoked/vehicle-ready diagnostics by
|
|
404
|
+
// descriptor.name and wall-clock time.
|
|
405
|
+
recordRenderDiagnostic({
|
|
406
|
+
event: "render-result-dispatch",
|
|
407
|
+
operation: descriptor.name,
|
|
408
|
+
isArtifact: isArtifact(output),
|
|
409
|
+
isArtifactArray: isArtifactArray(output),
|
|
410
|
+
output: shapeFingerprint(output),
|
|
411
|
+
});
|
|
391
412
|
if (isArtifactArray(output)) {
|
|
392
413
|
return new ArtifactListCard(createArtifactListDetails(descriptor.name, output), theme, options.expanded);
|
|
393
414
|
}
|
|
@@ -428,6 +449,7 @@ export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor):
|
|
|
428
449
|
if (isTaskCompletion(output)) {
|
|
429
450
|
return renderTaskCompletion(output, theme, options.expanded);
|
|
430
451
|
}
|
|
452
|
+
recordRenderDiagnostic({ event: "render-result-fell-through-to-generic", operation: descriptor.name });
|
|
431
453
|
}
|
|
432
454
|
return renderVehicleResult(descriptor, result, options, theme, context);
|
|
433
455
|
},
|
|
@@ -4,30 +4,34 @@
|
|
|
4
4
|
* src/handlers/registry.ts. discuss.* is the last of the six domains to
|
|
5
5
|
* migrate off pi-papyrus's own retired hand-rolled pi.registerTool() mega-tool.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Deferred to registerVehicleToolsWhenReady's own internal session_start handler
|
|
8
|
+
* (bounded retry/backoff, matching pi-tickets' registerTicketsVehicle) rather than
|
|
9
|
+
* a single unretried attempt: a daemon that's merely slow to start, or transiently
|
|
10
|
+
* unreachable right when session_start fires (including on /reload, which re-runs
|
|
11
|
+
* this extension's factory and this call), no longer permanently drops every
|
|
12
|
+
* notes/rules/docs/playbooks/tasks/discuss/artifact tool for the rest of the
|
|
13
|
+
* session. Every outcome logs through ctx.ui.notify instead of vanishing.
|
|
10
14
|
*
|
|
11
15
|
* Uses service-client.ts's currentVehicleClientTarget() (test-injectable) rather
|
|
12
16
|
* than resolveVehicleClientTarget() directly, so a test exercising the full
|
|
13
17
|
* extension entrypoint doesn't resolve a real daemonStateDir().
|
|
14
18
|
*
|
|
15
|
-
* The client itself is wrapped in createReconnectingVehicleClient(), re-resolving
|
|
19
|
+
* The client itself is wrapped in createReconnectingVehicleClient() once, re-resolving
|
|
16
20
|
* currentVehicleClientTarget() on every reconnect attempt rather than closing over
|
|
17
|
-
* one target captured here
|
|
18
|
-
* a
|
|
19
|
-
*
|
|
20
|
-
* rest of the Pi session until a full extension reload.
|
|
21
|
+
* one target captured here -- the daemon rebinds a new random port on every restart,
|
|
22
|
+
* so a bare RemoteVehicleClient built once would have no way to notice its baseUrl
|
|
23
|
+
* had died.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon-client";
|
|
24
27
|
import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
|
|
25
|
-
import {
|
|
28
|
+
import { type RegisteredPiVehicle, registerVehicleToolsWhenReady, type VehicleReadyEvent } from "@danypops/vehicle-client-pi";
|
|
26
29
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
30
|
import { discussLiveFollowUp } from "../discuss/discuss-live-follow-up.ts";
|
|
28
31
|
import { currentVehicleClientTarget } from "../service-client.ts";
|
|
29
32
|
import { sessionSecretField } from "../session-identity.ts";
|
|
30
33
|
import { emitTaskFocusEvent } from "../task/task-focus-events.ts";
|
|
34
|
+
import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
|
|
31
35
|
import { papyrusVehicleRenderers } from "./vehicle-artifact-renderers.ts";
|
|
32
36
|
|
|
33
37
|
const REGISTERED_PERMISSIONS = [
|
|
@@ -59,82 +63,127 @@ const FOCUS_MUTATION_OPERATIONS = new Set(["tasks.focus", "tasks.pause", "tasks.
|
|
|
59
63
|
*/
|
|
60
64
|
const CORE_OPERATIONS = ["tasks.list", "tasks.create", "tasks.start", "tasks.submit", "tasks.complete", "tasks.context"];
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// explicitly overrides session_id to a DIFFERENT session never gets this
|
|
91
|
-
// session's secret smuggled in on its behalf.
|
|
92
|
-
const requestedSessionId = (input as { session_id?: unknown } | undefined)?.session_id;
|
|
93
|
-
const sessionId =
|
|
94
|
-
typeof requestedSessionId === "string" && requestedSessionId.length > 0
|
|
95
|
-
? requestedSessionId
|
|
96
|
-
: context.sessionManager.getSessionId();
|
|
97
|
-
const { session_secret: sessionSecret } = sessionSecretField(sessionId);
|
|
98
|
-
// Omit sessionSecret entirely when nothing is cached (unregistered session) --
|
|
99
|
-
// {sessionSecret: null} would fail the module's own optionalString(input,
|
|
100
|
-
// "session_secret") check (undefined-or-string, not null), a real regression from
|
|
101
|
-
// sessionSecretField()'s own {} (key omitted) return for the same case.
|
|
102
|
-
const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
|
|
103
|
-
return { principal: { id: "pi-papyrus", claims } };
|
|
104
|
-
},
|
|
105
|
-
// papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (e.g. a
|
|
106
|
-
// token-cost router correlating its own telemetry with the currently focused task)
|
|
107
|
-
// -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
|
|
108
|
-
// than from the operation's own output.
|
|
109
|
-
// discuss.open/discuss.reply's own live:true synchronous human round-trip --
|
|
110
|
-
// see discuss/discuss-live-follow-up.ts. Every other operation's resolver call
|
|
111
|
-
// returns undefined, meaning zero behavior change for the other 5 domains.
|
|
112
|
-
interactiveFollowUps: (descriptor) =>
|
|
113
|
-
descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? discussLiveFollowUp : undefined,
|
|
114
|
-
// The retired discuss tool declared executionMode: "sequential" so the model
|
|
115
|
-
// couldn't batch a live ask alongside other tool calls in the same turn and
|
|
116
|
-
// let those run before the human sees the prompt -- same reasoning here.
|
|
117
|
-
executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
|
|
118
|
-
onInvoked: ({ descriptor }, output) => {
|
|
119
|
-
if (descriptor.name === "tasks.focus") {
|
|
120
|
-
const artifact = output as { id: string } | undefined;
|
|
121
|
-
if (artifact?.id) emitTaskFocusEvent({ taskId: artifact.id, status: "focused" });
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
if (descriptor.name === "tasks.pause" || descriptor.name === "tasks.unpause") {
|
|
125
|
-
const focus = output as { artifact: { id: string } } | undefined;
|
|
126
|
-
if (focus?.artifact?.id)
|
|
127
|
-
emitTaskFocusEvent({ taskId: focus.artifact.id, status: descriptor.name === "tasks.pause" ? "paused" : "unpaused" });
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (descriptor.name === "tasks.clear_focus") {
|
|
131
|
-
const result = output as { cleared: boolean } | undefined;
|
|
132
|
-
if (result?.cleared) emitTaskFocusEvent({ taskId: null, status: "cleared" });
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
} catch {
|
|
137
|
-
// Daemon state is stale/unreachable -- degrade silently, matching
|
|
138
|
-
// subscribeTaskPushChannel's own tolerance for the same condition.
|
|
66
|
+
function errorMessage(error: unknown): string {
|
|
67
|
+
return error instanceof Error ? error.message : String(error);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Surfaces a real resolution/registration error and the terminal exhausted state (the
|
|
72
|
+
* case that used to leave every notes/rules/docs/playbooks/tasks/discuss/artifact tool
|
|
73
|
+
* unregistered for the whole session with no visible sign why) -- a daemon merely still
|
|
74
|
+
* starting up (repeated client-unavailable before the last attempt) stays quiet, matching
|
|
75
|
+
* pi-tickets' own notifyReadyEvent.
|
|
76
|
+
*/
|
|
77
|
+
function notifyReadyEvent(event: VehicleReadyEvent): void {
|
|
78
|
+
switch (event.kind) {
|
|
79
|
+
case "client-resolution-failed":
|
|
80
|
+
event.ctx.ui.notify(`papyrus daemon target resolution failed: ${errorMessage(event.error)}`, "warning");
|
|
81
|
+
return;
|
|
82
|
+
case "registration-failed":
|
|
83
|
+
event.ctx.ui.notify(`papyrus tool registration failed: ${errorMessage(event.error)}`, "warning");
|
|
84
|
+
return;
|
|
85
|
+
case "exhausted":
|
|
86
|
+
event.ctx.ui.notify(
|
|
87
|
+
`papyrus tools unavailable this session -- the daemon never became reachable after ${event.attempts} attempts`,
|
|
88
|
+
"warning",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
case "client-unavailable":
|
|
92
|
+
case "registered":
|
|
93
|
+
return;
|
|
139
94
|
}
|
|
140
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fire-and-forget from the extension's top-level factory: registerVehicleToolsWhenReady
|
|
99
|
+
* registers its own session_start handler internally and defers the actual
|
|
100
|
+
* pi.getAllTools()/getActiveTools()/setActiveTools() calls to it (Pi's extension runtime
|
|
101
|
+
* only finishes initializing after every extension's factory has resolved, so calling
|
|
102
|
+
* registerVehicleTools directly from here throws "Extension runtime not initialized").
|
|
103
|
+
* The returned promise settles once that sequence succeeds or exhausts its attempts --
|
|
104
|
+
* awaiting it is optional and mainly useful for tests.
|
|
105
|
+
*/
|
|
106
|
+
export function registerNotesVehicle(pi: ExtensionAPI): Promise<RegisteredPiVehicle | undefined> {
|
|
107
|
+
recordRenderDiagnostic({ event: "register-notes-vehicle-called" });
|
|
108
|
+
const client = createReconnectingVehicleClient(async () => {
|
|
109
|
+
const resolved = currentVehicleClientTarget();
|
|
110
|
+
if (!resolved) throw new Error("Papyrus daemon is not running");
|
|
111
|
+
return new RemoteVehicleClient({ baseUrl: resolved.baseUrl, token: resolved.token });
|
|
112
|
+
});
|
|
113
|
+
return registerVehicleToolsWhenReady(pi, () => Promise.resolve(currentVehicleClientTarget() ? client : undefined), {
|
|
114
|
+
log: (event) => {
|
|
115
|
+
// Correlates against onInvoked's own timestamps below -- the /reload investigation's
|
|
116
|
+
// leading theory is a race between this fire-and-forget registration actually
|
|
117
|
+
// completing (this "registered" event) and Pi resolving a tool's ToolDefinition for
|
|
118
|
+
// rendering at the moment its tool-call message streams in.
|
|
119
|
+
recordRenderDiagnostic({ event: "vehicle-ready", kind: event.kind, ...("attempt" in event ? { attempt: event.attempt } : {}) });
|
|
120
|
+
notifyReadyEvent(event);
|
|
121
|
+
},
|
|
122
|
+
permissions: REGISTERED_PERMISSIONS,
|
|
123
|
+
principal: { id: "pi-papyrus" },
|
|
124
|
+
renderers: papyrusVehicleRenderers,
|
|
125
|
+
shell: { coreOperations: CORE_OPERATIONS },
|
|
126
|
+
// playbooks.invoke's own module handler, and tasks.focus/pause/unpause/clear_focus's
|
|
127
|
+
// own module handlers, authorize an internal Task Focus write via
|
|
128
|
+
// sessionIdentity.assertAuthorized(session_id, session_secret) -- see
|
|
129
|
+
// @danypops/papyrus's src/handlers/playbooks.ts and tasks.ts. That
|
|
130
|
+
// secret must never be a model-visible input field (the model has no business
|
|
131
|
+
// knowing or supplying it), so it travels here instead, in principal.claims, from
|
|
132
|
+
// this extension's own already-cached secret (registered at session_start -- see
|
|
133
|
+
// index.ts) -- the same value sessionSecretField() used to thread through as a raw
|
|
134
|
+
// RPC input field before these operations moved onto Vehicle.
|
|
135
|
+
resolveInvocation: ({ descriptor, input, context }) => {
|
|
136
|
+
if (descriptor.name !== "playbooks.invoke" && !FOCUS_MUTATION_OPERATIONS.has(descriptor.name)) return {};
|
|
137
|
+
// tasks.* defaults session_id to this Pi session's own id, same as the removed
|
|
138
|
+
// hand-rolled tool -- but the secret cache is keyed by whichever session_id is
|
|
139
|
+
// actually being authorized, not blindly this session's, so a model that
|
|
140
|
+
// explicitly overrides session_id to a DIFFERENT session never gets this
|
|
141
|
+
// session's secret smuggled in on its behalf.
|
|
142
|
+
const requestedSessionId = (input as { session_id?: unknown } | undefined)?.session_id;
|
|
143
|
+
const sessionId =
|
|
144
|
+
typeof requestedSessionId === "string" && requestedSessionId.length > 0
|
|
145
|
+
? requestedSessionId
|
|
146
|
+
: context.sessionManager.getSessionId();
|
|
147
|
+
const { session_secret: sessionSecret } = sessionSecretField(sessionId);
|
|
148
|
+
// Omit sessionSecret entirely when nothing is cached (unregistered session) --
|
|
149
|
+
// {sessionSecret: null} would fail the module's own optionalString(input,
|
|
150
|
+
// "session_secret") check (undefined-or-string, not null), a real regression from
|
|
151
|
+
// sessionSecretField()'s own {} (key omitted) return for the same case.
|
|
152
|
+
const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
|
|
153
|
+
return { principal: { id: "pi-papyrus", claims } };
|
|
154
|
+
},
|
|
155
|
+
// papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (e.g. a
|
|
156
|
+
// token-cost router correlating its own telemetry with the currently focused task)
|
|
157
|
+
// -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
|
|
158
|
+
// than from the operation's own output.
|
|
159
|
+
// discuss.open/discuss.reply's own live:true synchronous human round-trip --
|
|
160
|
+
// see discuss/discuss-live-follow-up.ts. Every other operation's resolver call
|
|
161
|
+
// returns undefined, meaning zero behavior change for the other 5 domains.
|
|
162
|
+
interactiveFollowUps: (descriptor) =>
|
|
163
|
+
descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? discussLiveFollowUp : undefined,
|
|
164
|
+
// The retired discuss tool declared executionMode: "sequential" so the model
|
|
165
|
+
// couldn't batch a live ask alongside other tool calls in the same turn and
|
|
166
|
+
// let those run before the human sees the prompt -- same reasoning here.
|
|
167
|
+
executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
|
|
168
|
+
onInvoked: ({ descriptor }, output) => {
|
|
169
|
+
// See vehicle-notes-client.ts's log wiring above -- correlates a real invocation's
|
|
170
|
+
// timestamp against when registration actually completed.
|
|
171
|
+
recordRenderDiagnostic({ event: "invoked", operation: descriptor.name, output: shapeFingerprint(output) });
|
|
172
|
+
if (descriptor.name === "tasks.focus") {
|
|
173
|
+
const artifact = output as { id: string } | undefined;
|
|
174
|
+
if (artifact?.id) emitTaskFocusEvent({ taskId: artifact.id, status: "focused" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (descriptor.name === "tasks.pause" || descriptor.name === "tasks.unpause") {
|
|
178
|
+
const focus = output as { artifact: { id: string } } | undefined;
|
|
179
|
+
if (focus?.artifact?.id)
|
|
180
|
+
emitTaskFocusEvent({ taskId: focus.artifact.id, status: descriptor.name === "tasks.pause" ? "paused" : "unpaused" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (descriptor.name === "tasks.clear_focus") {
|
|
184
|
+
const result = output as { cleared: boolean } | undefined;
|
|
185
|
+
if (result?.cleared) emitTaskFocusEvent({ taskId: null, status: "cleared" });
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
package/package.json
CHANGED