@code-yeongyu/senpi 2026.11.6-3 → 2026.11.9
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 +22 -0
- package/dist/bundle/chunks/{chunk-JMQOLOC2.js → chunk-3CRPNPTR.js} +1 -1
- package/dist/bundle/chunks/{chunk-5LKB732P.js → chunk-3TZQXZKH.js} +1 -1
- package/dist/bundle/chunks/{chunk-X53PD4ZC.js → chunk-45MVD7BU.js} +1 -1
- package/dist/bundle/chunks/{chunk-L7ZIS3MC.js → chunk-4JIBEZZE.js} +3 -3
- package/dist/bundle/chunks/{chunk-CF5OGBC4.js → chunk-4NYNMAYF.js} +1 -1
- package/dist/bundle/chunks/{chunk-6CEDL5S2.js → chunk-4UR6UFBH.js} +1 -1
- package/dist/bundle/chunks/{chunk-F5JLDCYD.js → chunk-527Q2UYM.js} +5 -5
- package/dist/bundle/chunks/{chunk-7DRWPZ3N.js → chunk-622VYTRI.js} +1 -1
- package/dist/bundle/chunks/{chunk-GKAYPJ4E.js → chunk-7PROV6BB.js} +1 -1
- package/dist/bundle/chunks/{chunk-4E7GDG4H.js → chunk-7VMEE62L.js} +1 -1
- package/dist/bundle/chunks/{chunk-CFEHJ4VU.js → chunk-HXNXISJD.js} +1 -1
- package/dist/bundle/chunks/{chunk-4BATXPB7.js → chunk-K3UHGDUQ.js} +1 -1
- package/dist/bundle/chunks/{chunk-SB6MXNO5.js → chunk-M7GF73FO.js} +2 -2
- package/dist/bundle/chunks/{chunk-V2QAYGOK.js → chunk-OCMGRBJ5.js} +22 -22
- package/dist/bundle/chunks/{chunk-EO2VPZ2K.js → chunk-SGAFIMAU.js} +1 -1
- package/dist/bundle/chunks/{chunk-BASS47UD.js → chunk-STGOYIXC.js} +1 -1
- package/dist/bundle/chunks/{chunk-DSWDJ7HT.js → chunk-WQ4RBPMS.js} +1 -1
- package/dist/bundle/chunks/{chunk-MJTCZUXS.js → chunk-WX2HB4DD.js} +1 -1
- package/dist/bundle/chunks/chunk-XZDCMDUH.js +2 -0
- package/dist/bundle/chunks/{chunk-LUZVGPQ7.js → chunk-Y5L4UFMI.js} +1 -1
- package/dist/bundle/chunks/{cli-main-RWHDZKTQ.js → cli-main-E4NDWAUD.js} +1 -1
- package/dist/bundle/chunks/{hand-entry-G5LYF62Q.js → hand-entry-5X5Z5Y3D.js} +1 -1
- package/dist/bundle/chunks/{headless-host-ES74ZB6M.js → headless-host-YXUGONR3.js} +1 -1
- package/dist/bundle/chunks/{host-command-I2JZ5JM7.js → host-command-VHX2ILLM.js} +1 -1
- package/dist/bundle/chunks/{host-session-command-Y52NFPP2.js → host-session-command-P3MRW63Z.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-PA7DGVS6.js → interactive-mode-XUSBLLWI.js} +1 -1
- package/dist/bundle/chunks/{main-OBL6375C.js → main-3TWVKTJN.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-NM6I5BWZ.js → multi-session-host-HRGM66PY.js} +1 -1
- package/dist/bundle/chunks/{package-manager-cli-GNRQ5MVR.js → package-manager-cli-WSH7U4YE.js} +1 -1
- package/dist/bundle/chunks/{role-F56BS2ZQ.js → role-4BUU46VW.js} +3 -3
- package/dist/bundle/chunks/{rotation-stream-BN2YK2MC.js → rotation-stream-TODBIHXJ.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-G4KIP7XS.js → rpc-mode-OAPIV46G.js} +1 -1
- package/dist/bundle/chunks/{run-attached-tui-XTGPAGYM.js → run-attached-tui-MXCC5IZR.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-L4M3TSQ3.js → schedule-command-MX56D6UE.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-V46WMCDF.js → session-control-endpoint-IL6ERD4U.js} +1 -1
- package/dist/bundle/chunks/{session-picker-AG6KSR5A.js → session-picker-6VRC25MJ.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +44 -44
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-session-services.d.ts +2 -0
- package/dist/core/agent-session-services.js +1 -0
- package/dist/core/agent-session.d.ts +8 -0
- package/dist/core/agent-session.js +51 -4
- package/dist/core/egress/classify.d.ts +8 -3
- package/dist/core/egress/classify.js +177 -158
- package/dist/core/egress/console-redactor.d.ts +7 -0
- package/dist/core/egress/console-redactor.js +74 -0
- package/dist/core/egress/credentials.d.ts +73 -0
- package/dist/core/egress/credentials.js +20 -0
- package/dist/core/egress/device-custody.d.ts +1 -1
- package/dist/core/egress/device-custody.js +18 -15
- package/dist/core/egress/device-proxy-call.d.ts +59 -0
- package/dist/core/egress/device-proxy-call.js +148 -0
- package/dist/core/egress/device-proxy-lost-calls.d.ts +27 -0
- package/dist/core/egress/device-proxy-lost-calls.js +28 -0
- package/dist/core/egress/device-proxy-ports.d.ts +49 -0
- package/dist/core/egress/device-proxy-ports.js +2 -0
- package/dist/core/egress/device-proxy-request.d.ts +34 -0
- package/dist/core/egress/device-proxy-request.js +69 -0
- package/dist/core/egress/device-proxy-waits.d.ts +40 -0
- package/dist/core/egress/device-proxy-waits.js +67 -0
- package/dist/core/egress/device-proxy.d.ts +3 -0
- package/dist/core/egress/device-proxy.js +214 -0
- package/dist/core/egress/endpoint.js +2 -1
- package/dist/core/egress/entry-verify.d.ts +13 -0
- package/dist/core/egress/entry-verify.js +33 -0
- package/dist/core/egress/hpke.js +13 -9
- package/dist/core/egress/key-policy-template.d.ts +7 -1
- package/dist/core/egress/ledger-schema.d.ts +80 -0
- package/dist/core/egress/ledger-schema.js +117 -0
- package/dist/core/egress/ledger.d.ts +55 -0
- package/dist/core/egress/ledger.js +159 -0
- package/dist/core/egress/pinned-infra.d.ts +23 -2
- package/dist/core/egress/pinned-infra.js +9 -2
- package/dist/core/egress/redact.d.ts +10 -0
- package/dist/core/egress/redact.js +134 -0
- package/dist/core/egress/sealed-source.d.ts +35 -0
- package/dist/core/egress/sealed-source.js +140 -0
- package/dist/core/egress/verified-keys.js +1 -1
- package/dist/core/egress/wire.d.ts +5 -3
- package/dist/core/egress/wire.js +5 -3
- package/dist/core/extensions/builtin/monitor-state-event.d.ts +26 -0
- package/dist/core/extensions/builtin/monitor-state-event.js +2 -0
- package/dist/core/extensions/builtin/terminal/background-age.d.ts +25 -0
- package/dist/core/extensions/builtin/terminal/background-age.js +91 -0
- package/dist/core/extensions/builtin/terminal/extension-state.d.ts +1 -1
- package/dist/core/extensions/builtin/terminal/extension-state.js +21 -3
- package/dist/core/extensions/builtin/terminal/notify.d.ts +6 -0
- package/dist/core/extensions/builtin/terminal/notify.js +39 -5
- package/dist/core/extensions/builtin/terminal/session-bundle.d.ts +9 -2
- package/dist/core/extensions/builtin/terminal/session-bundle.js +36 -1
- package/dist/core/extensions/builtin/terminal/settings.d.ts +2 -0
- package/dist/core/extensions/builtin/terminal/settings.js +8 -0
- package/dist/core/extensions/builtin/terminal/tools/background-auto-reap.d.ts +15 -0
- package/dist/core/extensions/builtin/terminal/tools/background-auto-reap.js +87 -0
- package/dist/core/extensions/builtin/terminal/tools/bash.d.ts +2 -0
- package/dist/core/extensions/builtin/terminal/tools/bash.js +6 -6
- package/dist/core/extensions/builtin/terminal/tools/child-placement.d.ts +8 -0
- package/dist/core/extensions/builtin/terminal/tools/child-placement.js +43 -6
- package/dist/core/extensions/builtin/terminal/tools/context.d.ts +11 -2
- package/dist/core/extensions/session-control-types.d.ts +15 -0
- package/dist/core/extensions/session-control-types.js +11 -0
- package/dist/core/external-admission.d.ts +16 -2
- package/dist/core/external-admission.js +22 -4
- package/dist/core/retry-fallback/controller.d.ts +1 -0
- package/dist/core/retry-fallback/controller.js +3 -0
- package/dist/core/retry-fallback/unusable.d.ts +2 -0
- package/dist/core/retry-fallback/unusable.js +7 -1
- package/dist/core/retry-fallback/usage-limit.d.ts +6 -1
- package/dist/core/retry-fallback/usage-limit.js +9 -4
- package/dist/core/sdk.d.ts +6 -1
- package/dist/core/sdk.js +1 -0
- package/dist/core/terminal-settings.d.ts +1 -0
- package/dist/core/turn-origin.d.ts +4 -0
- package/dist/core/turn-origin.js +10 -0
- package/dist/modes/interactive/components/remote-delivery-message.d.ts +2 -1
- package/dist/modes/interactive/components/remote-delivery-message.js +13 -5
- package/dist/modes/rpc/admit-external-message-command.d.ts +37 -0
- package/dist/modes/rpc/admit-external-message-command.js +61 -0
- package/dist/modes/rpc/admit-external-message-wire.d.ts +42 -0
- package/dist/modes/rpc/admit-external-message-wire.js +55 -0
- package/dist/modes/rpc/connection-handler.js +14 -0
- package/dist/modes/rpc/effect-commands.d.ts +1 -1
- package/dist/modes/rpc/host-idle-handover.js +1 -0
- package/dist/modes/rpc/method-classes-schema-delivery.d.ts +32 -0
- package/dist/modes/rpc/method-classes-schema-delivery.js +22 -0
- package/dist/modes/rpc/method-classes-schema-session.d.ts +27 -0
- package/dist/modes/rpc/method-classes-schema-session.js +2 -0
- package/dist/modes/rpc/method-classes-schema.d.ts +27 -0
- package/dist/modes/rpc/method-classes.d.ts +1 -0
- package/dist/modes/rpc/method-classes.js +2 -0
- package/dist/modes/rpc/rpc-input-validation.js +4 -3
- package/dist/modes/rpc/rpc-response-types.d.ts +2 -1
- package/dist/modes/rpc/rpc-session-command-types.d.ts +2 -1
- package/dist/modes/rpc/tool-child/terminal-jobs.d.ts +1 -0
- package/dist/modes/rpc/tool-child/terminal-jobs.js +17 -1
- package/docs/engine-socket/fixtures/admission-routes/h-legacy.json +1 -1
- package/docs/engine-socket/fixtures/rpc-inbound-samples.json +19 -2
- package/docs/rpc.md +57 -0
- package/docs/terminal-tools.md +25 -1
- package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
- package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-UODSYQ2D.js +0 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following one device call (row CK9; custody design 4.3-4.5). Frames are handled in order: `hooked` is stored (W4')
|
|
3
|
+
* only as the call's seq-0 frame while the stored request has no hooks applied (a late or unsolicited `hooked` never
|
|
4
|
+
* rewrites the stored request), events are pushed as they arrive, and the terminal event is held until the row is
|
|
5
|
+
* durably `ended` with the assembled message, then pushed, then acked. Silence is bounded with the loop's own numbers:
|
|
6
|
+
* a liveness probe every half idle window re-attaches on progress, waits for a device offline longer than the resume
|
|
7
|
+
* window, and cancels with `budget` after one idle window plus one resume window without progress. An events stream
|
|
8
|
+
* that ends before the terminal frame is parked, so the probe (and with it the bound) keeps running. Open question
|
|
9
|
+
* (design 4.4 "or stream-start bound without a first event"): the design names no value for that bound on this seam,
|
|
10
|
+
* so the idle+resume bound also covers a call with no first event until CK1 exports the loop's stream-start constant.
|
|
11
|
+
*/
|
|
12
|
+
import type { DeviceId } from "@earendil-works/pi-agent-core";
|
|
13
|
+
import type { Api, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { EgressLedger } from "./ledger.ts";
|
|
15
|
+
import type { EgressCallRow } from "./ledger-schema.ts";
|
|
16
|
+
import type { Abortion, EgressClock, StepWaits, WaitConditionSource } from "./device-proxy-waits.ts";
|
|
17
|
+
import type { SignedPlacementClaim } from "./placement-claim.ts";
|
|
18
|
+
import type { ProviderCallChannel, ProviderCallStart } from "./wire.ts";
|
|
19
|
+
export type ClaimRefusal = "claim_expired" | "registry_key_revoked" | "registry_key_unknown";
|
|
20
|
+
export type CallOutcome = {
|
|
21
|
+
readonly kind: "ended" | "mismatch" | "unknown";
|
|
22
|
+
readonly row: EgressCallRow;
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: "refused";
|
|
25
|
+
readonly row: EgressCallRow;
|
|
26
|
+
readonly code: ClaimRefusal;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
};
|
|
29
|
+
export interface DeviceRouteBounds {
|
|
30
|
+
readonly idleMs: number;
|
|
31
|
+
readonly resumeWindowMs: number;
|
|
32
|
+
readonly deadlineMs: number;
|
|
33
|
+
}
|
|
34
|
+
export interface CallContext {
|
|
35
|
+
readonly out: AssistantMessageEventStream;
|
|
36
|
+
readonly model: Model<Api>;
|
|
37
|
+
readonly ledger: EgressLedger;
|
|
38
|
+
readonly channel: ProviderCallChannel;
|
|
39
|
+
readonly offlineFor: (deviceId: DeviceId) => number;
|
|
40
|
+
readonly waits: StepWaits;
|
|
41
|
+
readonly conditions: WaitConditionSource;
|
|
42
|
+
readonly clock: EgressClock;
|
|
43
|
+
readonly bounds: DeviceRouteBounds;
|
|
44
|
+
readonly signal: AbortSignal;
|
|
45
|
+
/** The egress's own abort registry (one listener per signal). */
|
|
46
|
+
readonly abortion: Abortion;
|
|
47
|
+
readonly claim: () => Promise<SignedPlacementClaim>;
|
|
48
|
+
/** The row a user abort must cancel; kept current as `hooked` and `ended` replace it. */
|
|
49
|
+
readonly live: {
|
|
50
|
+
row: EgressCallRow | undefined;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Sends `start` (or, to resume a row the brain already dispatched, first asks the hand for its result) and follows it. */
|
|
54
|
+
export declare function followCall(ctx: CallContext, initial: EgressCallRow, begin: {
|
|
55
|
+
readonly start: ProviderCallStart;
|
|
56
|
+
} | {
|
|
57
|
+
readonly resume: true;
|
|
58
|
+
}): Promise<CallOutcome>;
|
|
59
|
+
//# sourceMappingURL=device-proxy-call.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { failureMessage, idleTimeoutText, storedRequestJson } from "./device-proxy-request.js";
|
|
2
|
+
/** Sends `start` (or, to resume a row the brain already dispatched, first asks the hand for its result) and follows it. */
|
|
3
|
+
export async function followCall(ctx, initial, begin) {
|
|
4
|
+
const { channel, ledger, out, signal } = ctx;
|
|
5
|
+
const deviceId = initial.deviceId;
|
|
6
|
+
if (deviceId === undefined)
|
|
7
|
+
throw new Error(`egress_call ${initial.callId} has no device`);
|
|
8
|
+
const aborted = ctx.abortion(signal);
|
|
9
|
+
let row = initial;
|
|
10
|
+
ctx.live.row = row;
|
|
11
|
+
let lastSeq = 0;
|
|
12
|
+
let terminal;
|
|
13
|
+
let lastProgress = ctx.clock.now();
|
|
14
|
+
let frames = channel.events(row.callId, 0)[Symbol.asyncIterator]();
|
|
15
|
+
let pending;
|
|
16
|
+
/** The events stream ended before the terminal frame: only the timed probe can move the call on until it re-attaches. */
|
|
17
|
+
let streamEnded = false;
|
|
18
|
+
const parked = new Promise(() => { });
|
|
19
|
+
const close = (stream) => {
|
|
20
|
+
stream.return?.().catch(() => { });
|
|
21
|
+
};
|
|
22
|
+
const reattach = (fromSeq) => {
|
|
23
|
+
close(frames);
|
|
24
|
+
frames = channel.events(row.callId, fromSeq)[Symbol.asyncIterator]();
|
|
25
|
+
pending = undefined;
|
|
26
|
+
streamEnded = false;
|
|
27
|
+
};
|
|
28
|
+
const store = async (next) => {
|
|
29
|
+
row = await next;
|
|
30
|
+
ctx.live.row = row;
|
|
31
|
+
};
|
|
32
|
+
const onEvent = (seq, event) => {
|
|
33
|
+
if (seq <= lastSeq)
|
|
34
|
+
return;
|
|
35
|
+
lastSeq = seq;
|
|
36
|
+
if (event.type === "done" || event.type === "error") {
|
|
37
|
+
terminal = event;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
out.push(event);
|
|
41
|
+
};
|
|
42
|
+
const settle = async (end) => {
|
|
43
|
+
if (end.t === "provider.call.error")
|
|
44
|
+
return onError(end);
|
|
45
|
+
if (!terminal)
|
|
46
|
+
throw new Error(`device call ${row.callId} ended without a terminal event`);
|
|
47
|
+
const message = terminal.type === "done" ? terminal.message : terminal.error;
|
|
48
|
+
await store(ledger.end(row, { kind: "message", message }));
|
|
49
|
+
out.push(terminal);
|
|
50
|
+
await channel.ack({ t: "provider.call.ack", callId: row.callId });
|
|
51
|
+
return { kind: "ended", row };
|
|
52
|
+
};
|
|
53
|
+
const onError = async (error) => {
|
|
54
|
+
if (error.code === "definition_mismatch")
|
|
55
|
+
return { kind: "mismatch", row };
|
|
56
|
+
if (error.code === "claim_expired" ||
|
|
57
|
+
error.code === "registry_key_revoked" ||
|
|
58
|
+
error.code === "registry_key_unknown")
|
|
59
|
+
return { kind: "refused", row, code: error.code, message: error.message };
|
|
60
|
+
if (error.code === "aborted" && signal.aborted)
|
|
61
|
+
throw signal.reason;
|
|
62
|
+
const { code, message, retryable, usage } = error;
|
|
63
|
+
await store(ledger.end(row, { kind: "error", code, message, retryable, ...(usage ? { usage } : {}) }));
|
|
64
|
+
const stopReason = code === "aborted" ? "aborted" : "error";
|
|
65
|
+
out.push({ type: "error", reason: stopReason, error: failureMessage(ctx.model, stopReason, message, usage) });
|
|
66
|
+
await channel.ack({ t: "provider.call.ack", callId: row.callId });
|
|
67
|
+
return { kind: "ended", row };
|
|
68
|
+
};
|
|
69
|
+
const onReply = async (reply) => {
|
|
70
|
+
if (reply.state === "unknown")
|
|
71
|
+
return { kind: "unknown", row };
|
|
72
|
+
if (reply.hooked && !row.hooksApplied)
|
|
73
|
+
await store(ledger.recordHooked(row, storedRequestJson(reply.hooked.request)));
|
|
74
|
+
if (reply.state === "running") {
|
|
75
|
+
if (reply.lastSeq > lastSeq) {
|
|
76
|
+
reattach(lastSeq + 1);
|
|
77
|
+
lastProgress = ctx.clock.now();
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
reply.events.forEach((event, index) => onEvent(index + 1, event));
|
|
82
|
+
return settle(reply.end);
|
|
83
|
+
};
|
|
84
|
+
const query = async () => onReply(await Promise.race([
|
|
85
|
+
channel.result({ t: "provider.call.result", callId: row.callId, claim: await ctx.claim() }),
|
|
86
|
+
aborted,
|
|
87
|
+
]));
|
|
88
|
+
const probe = async () => {
|
|
89
|
+
if (ctx.offlineFor(deviceId) >= ctx.bounds.resumeWindowMs) {
|
|
90
|
+
const waitFor = [{ kind: "device-online", deviceId }];
|
|
91
|
+
const steer = `open omo on ${deviceId}`;
|
|
92
|
+
await ctx.waits.hold({ reason: "device-offline", steer, waitFor }, ctx.conditions.until(waitFor, signal));
|
|
93
|
+
lastProgress = ctx.clock.now();
|
|
94
|
+
}
|
|
95
|
+
else if (ctx.clock.now() - lastProgress >= ctx.bounds.idleMs + ctx.bounds.resumeWindowMs) {
|
|
96
|
+
await channel.cancel({ t: "provider.call.cancel", callId: row.callId, reason: "budget" });
|
|
97
|
+
throw new Error(idleTimeoutText(ctx.bounds.idleMs));
|
|
98
|
+
}
|
|
99
|
+
return query();
|
|
100
|
+
};
|
|
101
|
+
const follow = async () => {
|
|
102
|
+
if ("start" in begin)
|
|
103
|
+
await Promise.race([channel.start(begin.start), aborted]);
|
|
104
|
+
else {
|
|
105
|
+
const resumed = await query();
|
|
106
|
+
if (resumed)
|
|
107
|
+
return resumed;
|
|
108
|
+
}
|
|
109
|
+
for (;;) {
|
|
110
|
+
const reading = streamEnded ? parked : (pending ??= frames.next());
|
|
111
|
+
const sleeper = new AbortController();
|
|
112
|
+
const tick = ctx.clock.sleep(ctx.bounds.idleMs / 2, sleeper.signal).then(() => "probe", () => parked);
|
|
113
|
+
const next = await Promise.race([reading, tick, aborted]);
|
|
114
|
+
sleeper.abort();
|
|
115
|
+
if (next === "probe") {
|
|
116
|
+
const outcome = await probe();
|
|
117
|
+
if (outcome)
|
|
118
|
+
return outcome;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
pending = undefined;
|
|
122
|
+
if (next.done) {
|
|
123
|
+
streamEnded = true;
|
|
124
|
+
const outcome = await query();
|
|
125
|
+
if (outcome)
|
|
126
|
+
return outcome;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const frame = next.value;
|
|
130
|
+
lastProgress = ctx.clock.now();
|
|
131
|
+
if (frame.t === "provider.call.hooked") {
|
|
132
|
+
if (!row.hooksApplied && lastSeq === 0)
|
|
133
|
+
await store(ledger.recordHooked(row, storedRequestJson(frame.request)));
|
|
134
|
+
}
|
|
135
|
+
else if (frame.t === "provider.call.event")
|
|
136
|
+
onEvent(frame.seq, frame.event);
|
|
137
|
+
else
|
|
138
|
+
return settle(frame);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
return await follow();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
close(frames);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=device-proxy-call.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pacing of the 4.5 `unknown` reissue (row CK9; security review H1). A hand that has lost a call (`result` answers
|
|
3
|
+
* `unknown`) makes the brain supersede the row and reissue the stored request, and a hand that loses every call would
|
|
4
|
+
* otherwise drive an unbounded supersede/W4 cycle through the session's durable store. Per (step, device): the first
|
|
5
|
+
* lost call is reissued at once, the next one only after the key backoff (5 s doubling to 5 min), and after
|
|
6
|
+
* `MAX_AUTOMATIC_REISSUES` automatic reissues the step waits for the user's choice "re-run this step or select another
|
|
7
|
+
* device" (the cap mirrors the two-mismatch cap of 4.2). The count and the backoff are kept per device, and the
|
|
8
|
+
* user's choice restarts both. Callback-stream steps already wait for that choice before every reissue (4.5), so for
|
|
9
|
+
* them only the backoff applies.
|
|
10
|
+
*/
|
|
11
|
+
import type { DeviceId } from "@earendil-works/pi-agent-core";
|
|
12
|
+
import { type EgressClock, type StepWaits, type WaitConditionSource } from "./device-proxy-waits.ts";
|
|
13
|
+
export interface LostCallPacer {
|
|
14
|
+
/** Called after a lost call was superseded and before `attempt` (the reissue) is dispatched. */
|
|
15
|
+
beforeReissue(deviceId: DeviceId, attempt: number): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface LostCallPacing {
|
|
18
|
+
readonly stepId: string;
|
|
19
|
+
readonly waits: StepWaits;
|
|
20
|
+
readonly conditions: WaitConditionSource;
|
|
21
|
+
readonly clock: EgressClock;
|
|
22
|
+
readonly signal: AbortSignal;
|
|
23
|
+
/** False for callback-stream steps, whose every reissue already waits for the user's choice. */
|
|
24
|
+
readonly askUser: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function lostCallPacer(pacing: LostCallPacing): LostCallPacer;
|
|
27
|
+
//# sourceMappingURL=device-proxy-lost-calls.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { nextKeyBackoffMs } from "./device-proxy-waits.js";
|
|
2
|
+
const MAX_AUTOMATIC_REISSUES = 2;
|
|
3
|
+
export function lostCallPacer(pacing) {
|
|
4
|
+
const { stepId, waits, conditions, clock, signal } = pacing;
|
|
5
|
+
/** Per device: automatic reissues since the last user choice, and the delay of the last paced one. */
|
|
6
|
+
const paced = new Map();
|
|
7
|
+
return {
|
|
8
|
+
async beforeReissue(deviceId, attempt) {
|
|
9
|
+
const { reissues, backoff } = paced.get(deviceId) ?? { reissues: 0 };
|
|
10
|
+
if (pacing.askUser && reissues >= MAX_AUTOMATIC_REISSUES) {
|
|
11
|
+
paced.delete(deviceId);
|
|
12
|
+
const waitFor = [{ kind: "user-choice", choiceId: `${stepId}:${attempt}` }];
|
|
13
|
+
const steer = `${deviceId} lost this step ${reissues + 1} times: re-run it or select another device`;
|
|
14
|
+
await waits.hold({ reason: "result-pending", steer, waitFor }, conditions.until(waitFor, signal));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (reissues === 0) {
|
|
18
|
+
paced.set(deviceId, { reissues: 1 });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const delay = nextKeyBackoffMs(backoff);
|
|
22
|
+
paced.set(deviceId, { reissues: reissues + 1, backoff: delay });
|
|
23
|
+
const steer = `${deviceId} lost this step; reissuing it`;
|
|
24
|
+
await waits.hold({ reason: "result-pending", steer, waitFor: [] }, clock.sleep(delay, signal));
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=device-proxy-lost-calls.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ports the device route of hosted model-key custody runs on (row CK9; custody design 2.3, 4.2-4.6): the hands'
|
|
3
|
+
* channels and presence, the session's route inputs, the placement claim, the request-hook runner, the wait sources
|
|
4
|
+
* and the loop's bounds. The hosted wrapper (V7b) supplies them; tests supply doubles.
|
|
5
|
+
*/
|
|
6
|
+
import type { DeviceId, EgressHostHooks, EgressRouteDecision, ProviderId, StreamFn } from "@earendil-works/pi-agent-core";
|
|
7
|
+
import type { DeviceRouteBounds } from "./device-proxy-call.ts";
|
|
8
|
+
import type { RequestHookRunner } from "./device-proxy-request.ts";
|
|
9
|
+
import type { EgressClock, WaitConditionSource } from "./device-proxy-waits.ts";
|
|
10
|
+
import type { EgressLedger } from "./ledger.ts";
|
|
11
|
+
import type { EgressCallCapability, VariantRecord } from "./ledger-schema.ts";
|
|
12
|
+
import type { SignedPlacementClaim } from "./placement-claim.ts";
|
|
13
|
+
import type { ProviderCallChannel, StepNotice } from "./wire.ts";
|
|
14
|
+
export interface DeviceHands {
|
|
15
|
+
channel(deviceId: DeviceId): ProviderCallChannel;
|
|
16
|
+
/** How long the device has been continuously offline (0 while online); compared with the resume window (4.4). */
|
|
17
|
+
offlineFor(deviceId: DeviceId): number;
|
|
18
|
+
}
|
|
19
|
+
/** The session's route inputs (route.ts `decideRoute` over presence, roster, hooks and variants; CK6/CK25). */
|
|
20
|
+
export interface DeviceRouteSource {
|
|
21
|
+
decide(providerId: ProviderId, variants: ReadonlyMap<DeviceId, VariantRecord>): EgressRouteDecision;
|
|
22
|
+
/** The head entry's `sourceDigest`, sent as `expectedSourceDigest` unless the hand serves its own definition. */
|
|
23
|
+
headDigest(providerId: ProviderId): string | undefined;
|
|
24
|
+
capability(providerId: ProviderId): EgressCallCapability;
|
|
25
|
+
liveDigest(deviceId: DeviceId, providerId: ProviderId): string | undefined;
|
|
26
|
+
}
|
|
27
|
+
export interface PlacementClaimSource {
|
|
28
|
+
current(): Promise<SignedPlacementClaim>;
|
|
29
|
+
/** Re-claims (same epoch on expiry; under the new registry generation on `registry_key_revoked`). */
|
|
30
|
+
renew(code: "claim_expired" | "registry_key_revoked"): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export interface DeviceProxyEgressDeps {
|
|
33
|
+
readonly sessionId: string;
|
|
34
|
+
readonly host: EgressHostHooks;
|
|
35
|
+
readonly ledger: EgressLedger;
|
|
36
|
+
readonly hands: DeviceHands;
|
|
37
|
+
readonly route: DeviceRouteSource;
|
|
38
|
+
readonly claims: PlacementClaimSource;
|
|
39
|
+
readonly requestHooks: RequestHookRunner;
|
|
40
|
+
readonly conditions: WaitConditionSource;
|
|
41
|
+
readonly notify: (notice: StepNotice) => void;
|
|
42
|
+
readonly clock: EgressClock;
|
|
43
|
+
readonly bounds: DeviceRouteBounds;
|
|
44
|
+
readonly cwd?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface DeviceProxyEgress {
|
|
47
|
+
readonly stream: StreamFn;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=device-proxy-ports.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the device route sends (row CK9; custody design 2.3 options hygiene, 4.5 hook rules). The admitted request is
|
|
3
|
+
* built once from the loop's context and the wire allowlist of its options, stored as JSON text, and every attempt
|
|
4
|
+
* sends the parse of that stored text, so a reissue is byte-for-byte the admitted request. Request hooks run once per
|
|
5
|
+
* step: on the hand itself (`hooks: "enabled"`, reported back by `hooked`), or over V5 before a non-hand server. Hook
|
|
6
|
+
* output is filtered through the same allowlist before it is stored, and every parse filters again: a hook that adds
|
|
7
|
+
* `env` or `headers` must not reach another device, where `env` can move the dial under that device's credential.
|
|
8
|
+
*/
|
|
9
|
+
import type { DeviceId, EgressRouteDecision } from "@earendil-works/pi-agent-core";
|
|
10
|
+
import type { Api, AssistantMessage, Model, SimpleStreamOptions, TranscriptContext, Usage } from "@earendil-works/pi-ai";
|
|
11
|
+
import { type ProviderCallRequest } from "./wire.ts";
|
|
12
|
+
export declare const admittedRequestJson: (context: TranscriptContext, options: SimpleStreamOptions | undefined) => string;
|
|
13
|
+
/** The JSON text the ledger stores for a request that came back from a hook (`hooked`, V5). */
|
|
14
|
+
export declare const storedRequestJson: (request: ProviderCallRequest) => string;
|
|
15
|
+
export declare function parseStoredRequest(json: string): ProviderCallRequest;
|
|
16
|
+
export interface StoredRequest {
|
|
17
|
+
readonly requestJson: string;
|
|
18
|
+
readonly hooksApplied: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface DispatchPlan extends StoredRequest {
|
|
21
|
+
readonly hooks: "enabled" | "disabled";
|
|
22
|
+
}
|
|
23
|
+
export type RequestHookRunner = (hand: DeviceId, request: ProviderCallRequest) => Promise<ProviderCallRequest>;
|
|
24
|
+
/**
|
|
25
|
+
* The hook rules of 4.5: to the hand, `hooks` is `enabled` exactly when the stored request does not carry them yet;
|
|
26
|
+
* to any other server, a request-hooked provider without applied hooks runs them on the hand first and stores the
|
|
27
|
+
* post-hook request with `hooksApplied: true`; everything else goes with `hooks: "disabled"`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function planDispatch(decision: EgressRouteDecision, stored: StoredRequest, runRequestHooks: RequestHookRunner): Promise<DispatchPlan>;
|
|
30
|
+
/** The terminal message the loop receives when the hand refused or failed the call, or the step was aborted. */
|
|
31
|
+
export declare function failureMessage(model: Model<Api>, stopReason: "error" | "aborted", errorMessage: string, usage?: Usage): AssistantMessage;
|
|
32
|
+
export declare const ABORT_TEXT = "Request was aborted";
|
|
33
|
+
export declare const idleTimeoutText: (idleMs: number) => string;
|
|
34
|
+
//# sourceMappingURL=device-proxy-request.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { WIRE_STREAM_OPTION_KEYS } from "./wire.js";
|
|
2
|
+
function copyWireKey(from, to, key) {
|
|
3
|
+
const value = from[key];
|
|
4
|
+
if (value !== undefined)
|
|
5
|
+
to[key] = value;
|
|
6
|
+
}
|
|
7
|
+
/** Only allowlisted, serializable request shaping crosses to the hand: never `commitStep`, secrets, headers or callbacks. */
|
|
8
|
+
function toWireOptions(options) {
|
|
9
|
+
const wire = {};
|
|
10
|
+
if (options)
|
|
11
|
+
for (const key of WIRE_STREAM_OPTION_KEYS)
|
|
12
|
+
copyWireKey(options, wire, key);
|
|
13
|
+
return wire;
|
|
14
|
+
}
|
|
15
|
+
export const admittedRequestJson = (context, options) => JSON.stringify({ context, options: toWireOptions(options) });
|
|
16
|
+
const isRequest = (value) => typeof value === "object" &&
|
|
17
|
+
value !== null &&
|
|
18
|
+
(("context" in value && "options" in value) || "compactBody" in value);
|
|
19
|
+
const toWireRequest = (request) => "context" in request
|
|
20
|
+
? { context: request.context, options: toWireOptions(request.options) }
|
|
21
|
+
: { compactBody: request.compactBody };
|
|
22
|
+
/** The JSON text the ledger stores for a request that came back from a hook (`hooked`, V5). */
|
|
23
|
+
export const storedRequestJson = (request) => JSON.stringify(toWireRequest(request));
|
|
24
|
+
export function parseStoredRequest(json) {
|
|
25
|
+
const parsed = JSON.parse(json);
|
|
26
|
+
if (!isRequest(parsed))
|
|
27
|
+
throw new Error("stored egress request is not a provider-call request");
|
|
28
|
+
return toWireRequest(parsed);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The hook rules of 4.5: to the hand, `hooks` is `enabled` exactly when the stored request does not carry them yet;
|
|
32
|
+
* to any other server, a request-hooked provider without applied hooks runs them on the hand first and stores the
|
|
33
|
+
* post-hook request with `hooksApplied: true`; everything else goes with `hooks: "disabled"`.
|
|
34
|
+
*/
|
|
35
|
+
export async function planDispatch(decision, stored, runRequestHooks) {
|
|
36
|
+
if (decision.executorHooks === "enabled")
|
|
37
|
+
return { ...stored, hooks: stored.hooksApplied ? "disabled" : "enabled" };
|
|
38
|
+
if (!stored.hooksApplied && decision.requestHooksOn !== undefined) {
|
|
39
|
+
const hooked = await runRequestHooks(decision.requestHooksOn, parseStoredRequest(stored.requestJson));
|
|
40
|
+
return { requestJson: storedRequestJson(hooked), hooksApplied: true, hooks: "disabled" };
|
|
41
|
+
}
|
|
42
|
+
return { ...stored, hooks: "disabled" };
|
|
43
|
+
}
|
|
44
|
+
/** A fresh object per message: `Usage` is mutable and consumers update it in place. */
|
|
45
|
+
const noUsage = () => ({
|
|
46
|
+
input: 0,
|
|
47
|
+
output: 0,
|
|
48
|
+
cacheRead: 0,
|
|
49
|
+
cacheWrite: 0,
|
|
50
|
+
totalTokens: 0,
|
|
51
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
52
|
+
});
|
|
53
|
+
/** The terminal message the loop receives when the hand refused or failed the call, or the step was aborted. */
|
|
54
|
+
export function failureMessage(model, stopReason, errorMessage, usage = noUsage()) {
|
|
55
|
+
return {
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: [],
|
|
58
|
+
api: model.api,
|
|
59
|
+
provider: model.provider,
|
|
60
|
+
model: model.id,
|
|
61
|
+
usage,
|
|
62
|
+
stopReason,
|
|
63
|
+
errorMessage,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export const ABORT_TEXT = "Request was aborted";
|
|
68
|
+
export const idleTimeoutText = (idleMs) => `Idle timeout waiting for provider stream after ${idleMs}ms`;
|
|
69
|
+
//# sourceMappingURL=device-proxy-request.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits on the device route (row CK9; custody design 2.3 "Pause = tracked local work", 4.2 live digests, 4.4). A wait
|
|
3
|
+
* is never a thrown value: it is bracketed by the host's `onWait`/`onWaitEnd` (W7, `step.waiting`) and held in the
|
|
4
|
+
* stream's `trackLocalWork`, so the loop's idle watchdog re-arms instead of failing the step. A user abort releases the
|
|
5
|
+
* wait at once.
|
|
6
|
+
*/
|
|
7
|
+
import type { DeviceId, EgressHostHooks, EgressWait, ProviderId, StepRef, WaitCondition } from "@earendil-works/pi-agent-core";
|
|
8
|
+
import type { AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { HandPresence } from "./wire.ts";
|
|
10
|
+
export interface EgressClock {
|
|
11
|
+
now(): number;
|
|
12
|
+
/** Resolves after `ms`, or rejects with the signal's reason once it aborts. */
|
|
13
|
+
sleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface WaitConditionSource {
|
|
16
|
+
/** Resolves once any of `waitFor` holds (device online, user choice made, ...), or rejects when `signal` aborts. */
|
|
17
|
+
until(waitFor: readonly WaitCondition[], signal: AbortSignal): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** A promise that rejects with the abort reason as soon as `signal` aborts; it never resolves. */
|
|
20
|
+
export type Abortion = (signal: AbortSignal) => Promise<never>;
|
|
21
|
+
/**
|
|
22
|
+
* One promise (and one listener) per signal, however many waits race it: an indefinite key backoff must not pile up
|
|
23
|
+
* abort listeners. The registry belongs to its owner (one egress, one board), never to the module.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createAbortion(): Abortion;
|
|
26
|
+
export interface StepWaits {
|
|
27
|
+
/** Holds `work` as a tracked wait of the current step until it settles. */
|
|
28
|
+
hold<T>(wait: Omit<EgressWait, "call">, work: Promise<T>): Promise<T>;
|
|
29
|
+
}
|
|
30
|
+
export declare function stepWaits(stream: AssistantMessageEventStream, host: EgressHostHooks, step: StepRef, signal: AbortSignal, abortion: Abortion): StepWaits;
|
|
31
|
+
/** Registry-key backoff while the hand cannot check the key: 5 s doubling to a 5 minute cap, indefinitely (4.6). */
|
|
32
|
+
export declare const nextKeyBackoffMs: (previous: number | undefined) => number;
|
|
33
|
+
export interface LiveDigestBoard {
|
|
34
|
+
report(presence: HandPresence): void;
|
|
35
|
+
digest(deviceId: DeviceId, providerId: ProviderId): string | undefined;
|
|
36
|
+
/** Resolves with the new digest the next time the device reports a value DIFFERENT from its last one (4.2). */
|
|
37
|
+
changed(deviceId: DeviceId, providerId: ProviderId, signal: AbortSignal): Promise<string>;
|
|
38
|
+
}
|
|
39
|
+
export declare function createLiveDigestBoard(): LiveDigestBoard;
|
|
40
|
+
//# sourceMappingURL=device-proxy-waits.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One promise (and one listener) per signal, however many waits race it: an indefinite key backoff must not pile up
|
|
3
|
+
* abort listeners. The registry belongs to its owner (one egress, one board), never to the module.
|
|
4
|
+
*/
|
|
5
|
+
export function createAbortion() {
|
|
6
|
+
const abortions = new WeakMap();
|
|
7
|
+
return (signal) => {
|
|
8
|
+
const known = abortions.get(signal);
|
|
9
|
+
if (known)
|
|
10
|
+
return known;
|
|
11
|
+
const { promise, reject } = Promise.withResolvers();
|
|
12
|
+
if (signal.aborted)
|
|
13
|
+
reject(signal.reason);
|
|
14
|
+
else
|
|
15
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
16
|
+
promise.catch(() => { });
|
|
17
|
+
abortions.set(signal, promise);
|
|
18
|
+
return promise;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function stepWaits(stream, host, step, signal, abortion) {
|
|
22
|
+
return {
|
|
23
|
+
async hold(wait, work) {
|
|
24
|
+
await host.onWait({ ...wait, call: step });
|
|
25
|
+
try {
|
|
26
|
+
return await stream.trackLocalWork(Promise.race([work, abortion(signal)]));
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await host.onWaitEnd(step.stepId);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Registry-key backoff while the hand cannot check the key: 5 s doubling to a 5 minute cap, indefinitely (4.6). */
|
|
35
|
+
export const nextKeyBackoffMs = (previous) => previous === undefined ? 5_000 : Math.min(previous * 2, 300_000);
|
|
36
|
+
export function createLiveDigestBoard() {
|
|
37
|
+
const abortion = createAbortion();
|
|
38
|
+
const digests = new Map();
|
|
39
|
+
const waiters = new Map();
|
|
40
|
+
const keyOf = (deviceId, providerId) => JSON.stringify([deviceId, providerId]);
|
|
41
|
+
return {
|
|
42
|
+
report(presence) {
|
|
43
|
+
for (const [providerId, digest] of Object.entries(presence.liveDigests ?? {})) {
|
|
44
|
+
const key = keyOf(presence.deviceId, providerId);
|
|
45
|
+
const previous = digests.get(key);
|
|
46
|
+
digests.set(key, digest);
|
|
47
|
+
if (previous === digest)
|
|
48
|
+
continue;
|
|
49
|
+
const woken = waiters.get(key);
|
|
50
|
+
waiters.delete(key);
|
|
51
|
+
for (const wake of woken ?? [])
|
|
52
|
+
wake(digest);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
digest: (deviceId, providerId) => digests.get(keyOf(deviceId, providerId)),
|
|
56
|
+
changed(deviceId, providerId, signal) {
|
|
57
|
+
const key = keyOf(deviceId, providerId);
|
|
58
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
59
|
+
const set = waiters.get(key) ?? new Set();
|
|
60
|
+
set.add(resolve);
|
|
61
|
+
waiters.set(key, set);
|
|
62
|
+
signal.addEventListener("abort", () => set.delete(resolve), { once: true });
|
|
63
|
+
return Promise.race([promise, abortion(signal)]);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=device-proxy-waits.js.map
|