super-dsh 0.1.0 → 0.1.2
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/agent-agy/bridge/agy_bridge.py +250 -0
- package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
- package/agent-agy/cordis.patch.yml +49 -0
- package/agent-agy/dist/adapter.d.ts +15 -0
- package/agent-agy/dist/adapter.js +42 -0
- package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
- package/agent-agy/dist/agent-preset-agy.js +169 -0
- package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
- package/agent-agy/dist/agent-preset-projection.js +33 -0
- package/agent-agy/dist/agent.d.ts +137 -0
- package/agent-agy/dist/agent.js +493 -0
- package/agent-agy/dist/agy-cli-client.d.ts +137 -0
- package/agent-agy/dist/agy-cli-client.js +301 -0
- package/agent-agy/dist/agy-client.d.ts +160 -0
- package/agent-agy/dist/agy-client.js +252 -0
- package/agent-agy/dist/agy-sessions.d.ts +24 -0
- package/agent-agy/dist/agy-sessions.js +116 -0
- package/agent-agy/dist/agy-store.d.ts +39 -0
- package/agent-agy/dist/agy-store.js +83 -0
- package/agent-agy/dist/inbox.d.ts +26 -0
- package/agent-agy/dist/inbox.js +53 -0
- package/agent-agy/dist/index.d.ts +80 -0
- package/agent-agy/dist/index.js +505 -0
- package/agent-agy/dist/models.d.ts +41 -0
- package/agent-agy/dist/models.js +74 -0
- package/agent-agy/dist/world-plugin.d.ts +9 -0
- package/agent-agy/dist/world-plugin.js +97 -0
- package/agent-agy/package.json +64 -0
- package/agent-claude/cordis.patch.yml +42 -0
- package/agent-claude/dist/adapter.js +62 -0
- package/agent-claude/dist/agent-preset-claude.js +169 -0
- package/agent-claude/dist/agent-preset-projection.js +30 -0
- package/agent-claude/dist/agent.js +1271 -0
- package/agent-claude/dist/claude-client.js +357 -0
- package/agent-claude/dist/claude-events.js +223 -0
- package/agent-claude/dist/claude-home.js +29 -0
- package/agent-claude/dist/commands.js +182 -0
- package/agent-claude/dist/content.js +134 -0
- package/agent-claude/dist/inbox.js +53 -0
- package/agent-claude/dist/index.js +555 -0
- package/agent-claude/dist/input-queue.js +64 -0
- package/agent-claude/dist/interaction.js +271 -0
- package/agent-claude/dist/models.js +158 -0
- package/agent-claude/dist/permission.js +120 -0
- package/agent-claude/dist/session-id.js +20 -0
- package/agent-claude/dist/session-map.js +165 -0
- package/agent-claude/dist/world-plugin.js +14 -0
- package/agent-claude/package.json +74 -0
- package/agent-codex/README.md +74 -0
- package/agent-codex/cordis.patch.yml +77 -0
- package/agent-codex/dist/adapter.js +55 -0
- package/agent-codex/dist/agent-preset-codex.js +171 -0
- package/agent-codex/dist/agent-preset-projection.js +37 -0
- package/agent-codex/dist/agent.js +1056 -0
- package/agent-codex/dist/app-home.js +65 -0
- package/agent-codex/dist/codex-client.js +469 -0
- package/agent-codex/dist/codex-events.js +297 -0
- package/agent-codex/dist/codex-store.js +190 -0
- package/agent-codex/dist/inbox.js +53 -0
- package/agent-codex/dist/index.js +605 -0
- package/agent-codex/dist/models.js +152 -0
- package/agent-codex/dist/permission.js +71 -0
- package/agent-codex/dist/session-map.js +170 -0
- package/agent-codex/dist/spike.js +46 -0
- package/agent-codex/dist/world-plugin.js +96 -0
- package/agent-codex/package.json +78 -0
- package/agent-hermes/README.md +144 -0
- package/agent-hermes/cordis.patch.yml +83 -0
- package/agent-hermes/dist/adapter.js +83 -0
- package/agent-hermes/dist/agent-preset-hermes.js +171 -0
- package/agent-hermes/dist/agent-preset-projection.js +34 -0
- package/agent-hermes/dist/agent.js +1068 -0
- package/agent-hermes/dist/hermes-client.js +953 -0
- package/agent-hermes/dist/hermes-events.js +289 -0
- package/agent-hermes/dist/hermes-store.js +158 -0
- package/agent-hermes/dist/inbox.js +53 -0
- package/agent-hermes/dist/index.js +541 -0
- package/agent-hermes/dist/models.js +324 -0
- package/agent-hermes/dist/permission.js +60 -0
- package/agent-hermes/dist/world-plugin.js +101 -0
- package/agent-hermes/package.json +76 -0
- package/agent-hub/cordis.patch.yml +12 -0
- package/agent-hub/dist/agent-roster.d.ts +34 -0
- package/agent-hub/dist/agent-roster.js +27 -0
- package/agent-hub/dist/carrier.d.ts +43 -0
- package/agent-hub/dist/carrier.js +241 -0
- package/agent-hub/dist/client-shim.d.ts +28 -0
- package/agent-hub/dist/client-shim.js +251 -0
- package/agent-hub/dist/envelope.d.ts +54 -0
- package/agent-hub/dist/envelope.js +92 -0
- package/agent-hub/dist/gateway.d.ts +28 -0
- package/agent-hub/dist/gateway.js +18 -0
- package/agent-hub/dist/index-pass.d.ts +22 -0
- package/agent-hub/dist/index-pass.js +47 -0
- package/agent-hub/dist/index.d.ts +29 -0
- package/agent-hub/dist/index.js +29 -0
- package/agent-hub/dist/labels.d.ts +45 -0
- package/agent-hub/dist/labels.js +83 -0
- package/agent-hub/dist/ownership.d.ts +37 -0
- package/agent-hub/dist/ownership.js +54 -0
- package/agent-hub/dist/roster.d.ts +28 -0
- package/agent-hub/dist/roster.js +38 -0
- package/agent-hub/dist/rpc.d.ts +38 -0
- package/agent-hub/dist/rpc.js +52 -0
- package/agent-hub/dist/spawn-world.d.ts +46 -0
- package/agent-hub/dist/spawn-world.js +72 -0
- package/agent-hub/dist/targets.d.ts +33 -0
- package/agent-hub/dist/targets.js +30 -0
- package/agent-hub/dist/world-entry.d.ts +31 -0
- package/agent-hub/dist/world-entry.js +51 -0
- package/agent-hub/dist/world-host.d.ts +69 -0
- package/agent-hub/dist/world-host.js +44 -0
- package/agent-hub/dist/world-join.d.ts +16 -0
- package/agent-hub/dist/world-join.js +122 -0
- package/agent-hub/dist/world-mount.d.ts +52 -0
- package/agent-hub/dist/world-mount.js +52 -0
- package/agent-hub/dist/world-mux.d.ts +72 -0
- package/agent-hub/dist/world-mux.js +224 -0
- package/agent-hub/dist/world-provision.d.ts +20 -0
- package/agent-hub/dist/world-provision.js +122 -0
- package/agent-hub/dist/world-web-server.d.ts +162 -0
- package/agent-hub/dist/world-web-server.js +248 -0
- package/agent-hub/lib/client/index.js +242 -0
- package/agent-hub/package.json +65 -0
- package/agent-omp/README.md +77 -0
- package/agent-omp/cordis.patch.yml +92 -0
- package/agent-omp/dist/adapter.js +36 -0
- package/agent-omp/dist/agent-preset-omp.js +171 -0
- package/agent-omp/dist/agent-preset-projection.js +36 -0
- package/agent-omp/dist/agent.js +1145 -0
- package/agent-omp/dist/app-home.js +86 -0
- package/agent-omp/dist/discovery.js +116 -0
- package/agent-omp/dist/inbox.js +53 -0
- package/agent-omp/dist/index.js +574 -0
- package/agent-omp/dist/knobs.js +50 -0
- package/agent-omp/dist/lazy-rpc.js +161 -0
- package/agent-omp/dist/mobile/gesture.js +148 -0
- package/agent-omp/dist/mobile/zoom-guard.js +294 -0
- package/agent-omp/dist/mobile-boot.js +38 -0
- package/agent-omp/dist/models.js +299 -0
- package/agent-omp/dist/omp-cli.js +147 -0
- package/agent-omp/dist/omp-disk-discovery.js +178 -0
- package/agent-omp/dist/omp-store.js +518 -0
- package/agent-omp/dist/pairing.js +45 -0
- package/agent-omp/dist/permission.js +96 -0
- package/agent-omp/dist/protocol.js +13 -0
- package/agent-omp/dist/rpc-types.js +1 -0
- package/agent-omp/dist/sdk-client.js +378 -0
- package/agent-omp/dist/sidecar-client.js +195 -0
- package/agent-omp/dist/store/adapter.js +47 -0
- package/agent-omp/dist/store/db.js +210 -0
- package/agent-omp/dist/store/index.js +59 -0
- package/agent-omp/dist/store/reconcile.js +51 -0
- package/agent-omp/dist/store/schema.js +40 -0
- package/agent-omp/dist/world-plugin.js +96 -0
- package/agent-omp/package.json +97 -0
- package/agent-omp/sidecar/main.ts +481 -0
- package/agent-pi/README.md +67 -0
- package/agent-pi/cordis.patch.yml +80 -0
- package/agent-pi/dist/adapter.js +53 -0
- package/agent-pi/dist/agent-preset-pi.js +169 -0
- package/agent-pi/dist/agent-preset-projection.js +29 -0
- package/agent-pi/dist/agent.js +949 -0
- package/agent-pi/dist/inbox.js +53 -0
- package/agent-pi/dist/index.js +595 -0
- package/agent-pi/dist/knobs.js +24 -0
- package/agent-pi/dist/models.js +200 -0
- package/agent-pi/dist/permission.js +66 -0
- package/agent-pi/dist/pi-client.js +224 -0
- package/agent-pi/dist/pi-events.js +155 -0
- package/agent-pi/dist/pi-home.js +55 -0
- package/agent-pi/dist/safe-json.js +25 -0
- package/agent-pi/dist/session-map.js +119 -0
- package/agent-pi/dist/world-plugin.js +97 -0
- package/agent-pi/package.json +77 -0
- package/cordis.patch.yml +22 -19
- package/package.json +88 -25
- package/client.js +0 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class Inbox {
|
|
2
|
+
#nextTurn = [];
|
|
3
|
+
#nextStep = [];
|
|
4
|
+
get nextTurn() {
|
|
5
|
+
return this.#nextTurn;
|
|
6
|
+
}
|
|
7
|
+
get nextStep() {
|
|
8
|
+
return this.#nextStep;
|
|
9
|
+
}
|
|
10
|
+
clear() {
|
|
11
|
+
this.#nextTurn = [];
|
|
12
|
+
this.#nextStep = [];
|
|
13
|
+
}
|
|
14
|
+
append(target, message) {
|
|
15
|
+
this.#list(target).push(message);
|
|
16
|
+
}
|
|
17
|
+
prepend(target, message) {
|
|
18
|
+
this.#list(target).unshift(message);
|
|
19
|
+
}
|
|
20
|
+
replace(messageId, newMessage) {
|
|
21
|
+
const list = this.#find(messageId);
|
|
22
|
+
if (list === undefined)
|
|
23
|
+
return false;
|
|
24
|
+
const index = list.findIndex((message) => message.id === messageId);
|
|
25
|
+
if (index < 0)
|
|
26
|
+
return false;
|
|
27
|
+
list.splice(index, 1, newMessage);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
remove(messageId) {
|
|
31
|
+
const list = this.#find(messageId);
|
|
32
|
+
if (list === undefined)
|
|
33
|
+
return false;
|
|
34
|
+
const index = list.findIndex((message) => message.id === messageId);
|
|
35
|
+
if (index < 0)
|
|
36
|
+
return false;
|
|
37
|
+
list.splice(index, 1);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
splice(target, start, deleteCount, inserted) {
|
|
41
|
+
return this.#list(target).splice(start, deleteCount, ...inserted);
|
|
42
|
+
}
|
|
43
|
+
#list(target) {
|
|
44
|
+
return target === "next-turn" ? this.#nextTurn : this.#nextStep;
|
|
45
|
+
}
|
|
46
|
+
#find(messageId) {
|
|
47
|
+
if (this.#nextTurn.some((message) => message.id === messageId))
|
|
48
|
+
return this.#nextTurn;
|
|
49
|
+
if (this.#nextStep.some((message) => message.id === messageId))
|
|
50
|
+
return this.#nextStep;
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-pi provider — Pi provider plugin for DeepSeek Harness
|
|
3
|
+
* (Agent Worlds AW-F, SDK line).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the built-in agent loop with an `AgentFactory` that drives pi
|
|
6
|
+
* `AgentSession`s in-process (one `PiSessionClient` per session, through the
|
|
7
|
+
* `@earendil-works/pi-coding-agent` SDK) and bridges the projected wire
|
|
8
|
+
* stream into the Dash Agent/Session contracts. Mirrors
|
|
9
|
+
* `@deepseek-ai/dsh-agent-loop`'s creation transaction (prepare → owned
|
|
10
|
+
* storage handle → setup → publish) so the session + agent publish as one
|
|
11
|
+
* ordered lifecycle AND the DSH session log is written through the upstream
|
|
12
|
+
* `sessionPersistence` service — list/read/replay/workspace grouping are then
|
|
13
|
+
* ordinary DSH services over that log.
|
|
14
|
+
*
|
|
15
|
+
* Session identity ("mapping only", codex model): the DSH session id is the
|
|
16
|
+
* authority — supplied by the harness at create (`options.sessionId`) and
|
|
17
|
+
* resume (`options.resumeSessionId`). pi's session file only materializes at
|
|
18
|
+
* the first prompt, so the pairing is persisted in the adapter's DSH-side
|
|
19
|
+
* state dir (`<dshHome>/agents/pi/dsh-sessions.json`, see session-map.ts).
|
|
20
|
+
* Resume resolves that record and fails closed when the session is unknown.
|
|
21
|
+
*
|
|
22
|
+
* HOME RULING (2026-09-17 user): pi's runtime home is the NATIVE `~/.pi` —
|
|
23
|
+
* this adapter never redirects `PI_CODING_AGENT_DIR`, seeds no app home, and
|
|
24
|
+
* copies no config (the user's pi CLI state IS our state). Session storage is
|
|
25
|
+
* duplicated by design: pi's native JSONL stays pi's authority; the DSH log
|
|
26
|
+
* is the WebUI copy.
|
|
27
|
+
*/
|
|
28
|
+
import { realpathSync, statSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
31
|
+
import { emitAgentEvent, installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
32
|
+
import { SessionLogOffset, SessionPreparation, interruptedTurnClosers, } from "@deepseek-ai/dsh-session";
|
|
33
|
+
import { PiSessionClient } from "./pi-client.js";
|
|
34
|
+
import { PiAgent } from "./agent.js";
|
|
35
|
+
import { PiLlmAdapter } from "./adapter.js";
|
|
36
|
+
import { SinglePiPresetRoster } from "./agent-preset-pi.js";
|
|
37
|
+
import { piAgentPresetProjection } from "./agent-preset-projection.js";
|
|
38
|
+
import { resolvePiStateDir } from "./pi-home.js";
|
|
39
|
+
import { sessionRecord, upsertSession } from "./session-map.js";
|
|
40
|
+
import { warmPiCatalog, readPiModelCatalog, piProviderIds, resolvePiSelection, PI_PROVIDER_ID } from "./models.js";
|
|
41
|
+
import { defaultPermissionPreset, envApprovalMode, piToolset, presetFromEvents } from "./permission.js";
|
|
42
|
+
import { trace } from "./knobs.js";
|
|
43
|
+
/** Realpath of a recorded cwd, accepted only when it names an existing directory. */
|
|
44
|
+
function validatedCwd(cwd) {
|
|
45
|
+
if (cwd === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const canonical = realpathSync(cwd);
|
|
49
|
+
return statSync(canonical).isDirectory() ? canonical : undefined;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Open a read handle, pull the complete stored log, close. */
|
|
56
|
+
async function readStoredEvents(persistence, id, signal) {
|
|
57
|
+
const handle = await persistence.open(id, "read", signal === undefined ? {} : { signal });
|
|
58
|
+
try {
|
|
59
|
+
return (await handle.read(0, undefined, signal === undefined ? {} : { signal })).events;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Whether the stored log carries real conversation content (any turn,
|
|
66
|
+
* user-authored message, or assistant message) — the orphan discriminator for
|
|
67
|
+
* blank-tolerant resume (2026-09-18: blank = resumable-as-fresh; content
|
|
68
|
+
* without a pairing = genuine unknown, fail loud). */
|
|
69
|
+
function hasConversationContent(events) {
|
|
70
|
+
return events.some((event) => {
|
|
71
|
+
if (event.type === "turn/start" || event.type === "assistant/message")
|
|
72
|
+
return true;
|
|
73
|
+
if (event.type === "user/message") {
|
|
74
|
+
const data = event.data;
|
|
75
|
+
return data?.source?.kind === "user";
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export class PiProvider extends Service {
|
|
81
|
+
/**
|
|
82
|
+
* `agentDefaultModel` and `settings` are load-bearing, not decorative (omp
|
|
83
|
+
* / codex twin, same boot-order trap): the harness owns the "default for
|
|
84
|
+
* new sessions" selection there, and this bridge is the only thing that
|
|
85
|
+
* feeds it pi's settings.json default. Without the inject the context
|
|
86
|
+
* cannot resolve the service at apply time and the push never fires — the
|
|
87
|
+
* picker keeps advertising a model no adapter in this world serves.
|
|
88
|
+
*/
|
|
89
|
+
static inject = ["agents", "sessions", "llm", "agentDefaultModel", "settings"];
|
|
90
|
+
/** Plain holder — prevents Cordis re-tracing the factory's ctx through a caller shadow. */
|
|
91
|
+
runtime;
|
|
92
|
+
/**
|
|
93
|
+
* The DSH home (captured once from the world's own `dshHomePath` — never
|
|
94
|
+
* ambient env inside plugins) and the adapter's DSH-side state dir derived
|
|
95
|
+
* from it: `<home>/agents/pi`. NOT pi's runtime home — pi keeps its native
|
|
96
|
+
* `~/.pi` (2026-09-17 ruling).
|
|
97
|
+
*/
|
|
98
|
+
home;
|
|
99
|
+
stateDir;
|
|
100
|
+
/** Echo/staleness guard for the app-wide default-model push. */
|
|
101
|
+
defaultModelKey = "";
|
|
102
|
+
constructor(ctx) {
|
|
103
|
+
super(ctx, "piProvider");
|
|
104
|
+
this.runtime = { ctx };
|
|
105
|
+
this.home = this.#resolveHome();
|
|
106
|
+
this.stateDir = resolvePiStateDir(this.home);
|
|
107
|
+
trace(`home: dshHome=${this.home} stateDir=${this.stateDir} (pi runtime home = native ~/.pi)`);
|
|
108
|
+
ctx.effect(() => ctx.agents.setFactory(this), "piProvider.setFactory()");
|
|
109
|
+
this.#registerModelCatalog();
|
|
110
|
+
// The roster must be visible to the API gateway's root-level remote
|
|
111
|
+
// enumeration (dsh-host-apiproxy read it from the root service table);
|
|
112
|
+
// nesting it under a child fiber via ctx.plugin hides its @Remote routes
|
|
113
|
+
// from the gateway and every agentPresets/* call 404s. Register it on
|
|
114
|
+
// this plugin's own (top-level) fiber instead.
|
|
115
|
+
new SinglePiPresetRoster(ctx);
|
|
116
|
+
// Drive the `agentPreset` session projection ourselves: the upstream
|
|
117
|
+
// registrant lives in the (disabled) dsh-agent-presets package, but the
|
|
118
|
+
// Web UI gates the preset chip and header label on
|
|
119
|
+
// `projectionValues.agentPreset`.
|
|
120
|
+
ctx.inject(["sessionProjections"], (scoped) => {
|
|
121
|
+
scoped.sessionProjections.register(piAgentPresetProjection);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The factory owns its sessions' route (codex directive parity): a session
|
|
126
|
+
* created by this factory always runs on a pi-served route, with pi's
|
|
127
|
+
* settings default model unless the caller asked for a served model. The
|
|
128
|
+
* requested selection resolves through the catalog — a real slug pair
|
|
129
|
+
* (picker route) or a legacy composite under the umbrella `pi` route — and
|
|
130
|
+
* rides the session only when the catalog actually serves it; an
|
|
131
|
+
* unresolved choice is omitted so pi's own default applies (placeholder
|
|
132
|
+
* values never flow into options).
|
|
133
|
+
*/
|
|
134
|
+
sessionAgentOptions(requested) {
|
|
135
|
+
const resolved = resolvePiSelection(requested?.provider, requested?.model) ?? readPiModelCatalog().defaultSelection;
|
|
136
|
+
return {
|
|
137
|
+
...(requested ?? {}),
|
|
138
|
+
provider: resolved?.provider ?? PI_PROVIDER_ID,
|
|
139
|
+
...(resolved === undefined ? {} : { model: resolved.model }),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The write path is mandatory: this factory owns the DSH session log
|
|
144
|
+
* through the upstream persistence service — a session created or resumed
|
|
145
|
+
* without a backend would persist nothing.
|
|
146
|
+
*/
|
|
147
|
+
requirePersistence(op) {
|
|
148
|
+
const persistence = this.runtime.ctx.get("sessionPersistence");
|
|
149
|
+
if (persistence === undefined) {
|
|
150
|
+
throw new Error(`cannot ${op} a pi session: session persistence is not configured`);
|
|
151
|
+
}
|
|
152
|
+
return persistence;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Take a fresh session's write ownership. Nothing is appended here: the
|
|
156
|
+
* constructor seed (which never re-emits through `session/event`) is stored
|
|
157
|
+
* by `appendUnstoredSuffix` at the publication commit point, so a failed or
|
|
158
|
+
* cancelled setup closes an unmaterialized handle and leaves no residue.
|
|
159
|
+
*/
|
|
160
|
+
async createStoredSession(persistence, session, signal) {
|
|
161
|
+
const handle = await persistence.create(session.header, {
|
|
162
|
+
inheritedEventCount: session.inheritedEventCount,
|
|
163
|
+
...(signal === undefined ? {} : { signal }),
|
|
164
|
+
});
|
|
165
|
+
return { handle, storedCount: 0 };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Runtime facts the agent stamps into the log: pi's own system prompt
|
|
169
|
+
* (live on the SDK session once started) and the effective route metadata
|
|
170
|
+
* (the selected model's real provider slug + bare id, else the catalog
|
|
171
|
+
* default, plus the model's context window).
|
|
172
|
+
*/
|
|
173
|
+
agentRuntime(client) {
|
|
174
|
+
return {
|
|
175
|
+
systemPrompt: () => client.systemPrompt(),
|
|
176
|
+
routeContext: (preferred) => {
|
|
177
|
+
const catalog = readPiModelCatalog();
|
|
178
|
+
const resolved = resolvePiSelection(preferred?.provider, preferred?.model) ?? catalog.defaultSelection;
|
|
179
|
+
if (resolved === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const entry = catalog.models.find((candidate) => candidate.provider === resolved.provider && candidate.id === resolved.model);
|
|
182
|
+
return {
|
|
183
|
+
provider: resolved.provider,
|
|
184
|
+
model: resolved.model,
|
|
185
|
+
...(entry?.contextWindow !== undefined && entry.contextWindow > 0 ? { contextWindow: entry.contextWindow } : {}),
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** $DSH_HOME as the input: boot-provided home first, env last. */
|
|
191
|
+
#resolveHome() {
|
|
192
|
+
const fromBoot = this.runtime.ctx.get("dshHomePath");
|
|
193
|
+
if (typeof fromBoot === "string" && fromBoot !== "")
|
|
194
|
+
return fromBoot;
|
|
195
|
+
if (typeof fromBoot === "function") {
|
|
196
|
+
const resolved = fromBoot();
|
|
197
|
+
if (typeof resolved === "string" && resolved !== "")
|
|
198
|
+
return resolved;
|
|
199
|
+
}
|
|
200
|
+
return process.env.DSH_HOME ?? join(process.cwd(), ".tests", "aw");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* App-wide wiring the adapter owns: register the adapter on a boot
|
|
204
|
+
* placeholder route, warm the model catalog once, swap the registration
|
|
205
|
+
* to the real per-provider slug routes (omp parity — the picker groups by
|
|
206
|
+
* real provider), then push pi's settings.json default into the world's
|
|
207
|
+
* `agentDefaultModel` (codex `#registerDefaultModel` verbatim modulo the
|
|
208
|
+
* source). One-directional on purpose — pi's settings are read-only for
|
|
209
|
+
* us.
|
|
210
|
+
*/
|
|
211
|
+
#registerModelCatalog() {
|
|
212
|
+
const llm = this.runtime.ctx.get("llm");
|
|
213
|
+
if (llm === undefined)
|
|
214
|
+
return;
|
|
215
|
+
// registerAdapter must not be empty and the selector must never see a
|
|
216
|
+
// routeless window: boot under the placeholder `pi` route, then swap
|
|
217
|
+
// atomically to the real slugs once the catalog is warm.
|
|
218
|
+
const handle = llm.registerAdapter([PI_PROVIDER_ID], new PiLlmAdapter());
|
|
219
|
+
void warmPiCatalog()
|
|
220
|
+
.then(() => {
|
|
221
|
+
this.#replaceRoutes(handle);
|
|
222
|
+
this.#pushDefaultModel();
|
|
223
|
+
})
|
|
224
|
+
.catch((error) => {
|
|
225
|
+
trace(`catalog warm failed (selector serves nothing until retry): ${String(error)}`);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Swap the boot placeholder route for the real per-provider slug routes.
|
|
230
|
+
* Atomic (`handle.replace`): no request observes a gap, and a failure
|
|
231
|
+
* keeps the placeholder — which serves nothing from a cold memo and is
|
|
232
|
+
* logged, never fatal (fail-soft with a trace).
|
|
233
|
+
*/
|
|
234
|
+
#replaceRoutes(handle) {
|
|
235
|
+
const slugs = piProviderIds();
|
|
236
|
+
try {
|
|
237
|
+
handle.replace(slugs);
|
|
238
|
+
trace(`provider routes: ${slugs.length === 0 ? "(empty catalog — zero routes)" : slugs.join(", ")}`);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
this.runtime.ctx.logger.warn(`pi-provider: provider-route swap failed (placeholder stays): ${String(error)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
#pushDefaultModel() {
|
|
245
|
+
this.runtime.ctx.inject(["agentDefaultModel", "settings"], (mCtx) => {
|
|
246
|
+
const service = mCtx.get("agentDefaultModel");
|
|
247
|
+
if (service === undefined)
|
|
248
|
+
return;
|
|
249
|
+
const target = readPiModelCatalog().defaultSelection;
|
|
250
|
+
if (target === undefined) {
|
|
251
|
+
trace("default model push skipped: catalog cold or pi has no default");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const targetKey = `${target.provider}/${target.model}`;
|
|
255
|
+
const current = service.currentSelection?.();
|
|
256
|
+
const currentKey = current === undefined ? undefined : `${current.provider}/${current.model}`;
|
|
257
|
+
if (currentKey === targetKey || currentKey === this.defaultModelKey)
|
|
258
|
+
return;
|
|
259
|
+
trace(`default model: ${currentKey ?? "none"} → ${targetKey}`);
|
|
260
|
+
const settings = mCtx.get("settings");
|
|
261
|
+
trace(`default model write: settings=${settings === undefined ? "MISSING" : typeof settings.replace}; current=${JSON.stringify(current)}`);
|
|
262
|
+
void Promise.resolve((async () => {
|
|
263
|
+
if (settings?.replace === undefined)
|
|
264
|
+
throw new Error("settings.replace is unavailable on the injected context");
|
|
265
|
+
// Lock-timeout is TRANSIENT (live-found 2026-09-18: a killed boot can
|
|
266
|
+
// leave a stale settings.yaml.lock and every later write times out —
|
|
267
|
+
// retry per the bounded-retry ruling before giving up with a trace).
|
|
268
|
+
for (let attempt = 1;; attempt++) {
|
|
269
|
+
try {
|
|
270
|
+
await settings.replace("agent-default-model", target);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (attempt >= 3 || !/writer lock/.test(String(error)))
|
|
275
|
+
throw error;
|
|
276
|
+
trace(`default model: writer-lock timeout (attempt ${attempt}/3) — retrying in 2s`);
|
|
277
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
trace("default model: settings.replace resolved");
|
|
281
|
+
await service.saveSelection?.(target);
|
|
282
|
+
trace("default model: saveSelection resolved");
|
|
283
|
+
})())
|
|
284
|
+
.then(() => {
|
|
285
|
+
this.defaultModelKey = targetKey;
|
|
286
|
+
this.runtime.ctx.logger.info(`pi-provider: default model ← pi settings: ${targetKey}`);
|
|
287
|
+
})
|
|
288
|
+
.catch((error) => {
|
|
289
|
+
trace(`default model WRITE FAILED: ${String(error)}`);
|
|
290
|
+
this.runtime.ctx.logger.warn(`pi-provider: default model save failed: ${String(error)}`);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async createAgent(ownerCtx, options) {
|
|
295
|
+
const loopCtx = this.runtime.ctx;
|
|
296
|
+
const id = options.sessionId;
|
|
297
|
+
const meta = options.meta ?? {};
|
|
298
|
+
const cwd = meta.cwd;
|
|
299
|
+
// Fork seeds a copied-turn prefix but pi has no native fork: fail the
|
|
300
|
+
// fork cleanly at the factory boundary. `parentSession` in create
|
|
301
|
+
// metadata is set only by the fork path (subagents go through
|
|
302
|
+
// `ctx.subagents` instead).
|
|
303
|
+
if (meta.parentSession !== undefined) {
|
|
304
|
+
throw new Error(`cannot fork session "${meta.parentSession}" onto the Pi provider: pi has no native session fork`);
|
|
305
|
+
}
|
|
306
|
+
const persistence = this.requirePersistence("create");
|
|
307
|
+
// The toolset is pinned at session creation and the Dash session does
|
|
308
|
+
// not exist yet at this point, so the effective preset cannot come from
|
|
309
|
+
// session events: the env override (PI_APPROVAL_MODE, headless runs)
|
|
310
|
+
// wins, else the permission service's default.
|
|
311
|
+
const preset = envApprovalMode() ?? defaultPermissionPreset(loopCtx);
|
|
312
|
+
const toolset = piToolset(preset);
|
|
313
|
+
const spawnCwd = validatedCwd(cwd) ?? process.cwd();
|
|
314
|
+
trace(`create id=${id} preset=${preset ?? "none"} tools=${toolset === undefined ? "default" : toolset.join(",")} spawnCwd=${spawnCwd}${envApprovalMode() === undefined ? "" : " (env override)"}`);
|
|
315
|
+
// Backend takes ZERO part in the UI's browser-local new-session draft
|
|
316
|
+
// beyond the (zero-IO) client shell: no pi session, no session events —
|
|
317
|
+
// the session announces blank. pi materializes on the first prompt.
|
|
318
|
+
const client = await PiSessionClient.spawn([], spawnCwd, {
|
|
319
|
+
kind: "create",
|
|
320
|
+
...(toolset === undefined ? {} : { tools: toolset }),
|
|
321
|
+
});
|
|
322
|
+
const preparation = SessionPreparation.create(loopCtx.sessions.prepare(id, {
|
|
323
|
+
...(options.seed === undefined ? {} : { seed: options.seed }),
|
|
324
|
+
...(meta === undefined ? {} : { meta }),
|
|
325
|
+
...(options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount }),
|
|
326
|
+
}));
|
|
327
|
+
let stored;
|
|
328
|
+
let handedOff = false;
|
|
329
|
+
try {
|
|
330
|
+
stored = await this.createStoredSession(persistence, preparation.session, options.signal);
|
|
331
|
+
// Identity record BEFORE publication: the DSH id → pi session file
|
|
332
|
+
// pairing (sessionFile null until the first prompt) is what resume
|
|
333
|
+
// resolves.
|
|
334
|
+
upsertSession(this.home, String(id), {
|
|
335
|
+
sessionFile: null,
|
|
336
|
+
cwd: spawnCwd,
|
|
337
|
+
createdAt: Date.now(),
|
|
338
|
+
preset: preset ?? null,
|
|
339
|
+
});
|
|
340
|
+
this.followSessionFile(id, client);
|
|
341
|
+
handedOff = true;
|
|
342
|
+
return await setupAndPublish(loopCtx, ownerCtx, id, this.sessionAgentOptions(options.agentOptions), options.setup, preparation.session, client, options.parentAgent, "startup", { enterSession: true, preparation }, stored, this.agentRuntime(client));
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
trace(`create ${id} THREW: ${String(error)}`);
|
|
346
|
+
client.close();
|
|
347
|
+
if (!handedOff)
|
|
348
|
+
await stored?.handle.close().catch(() => { });
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
if (!handedOff)
|
|
353
|
+
preparation[Symbol.dispose]();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async resume(ownerCtx, options) {
|
|
357
|
+
const loopCtx = this.runtime.ctx;
|
|
358
|
+
const id = options.resumeSessionId;
|
|
359
|
+
// Identity ("mapping only") with BLANK-SESSION TOLERANCE (2026-09-18,
|
|
360
|
+
// dev-rules §14 i / omp-web doctrine): a session whose pi side never
|
|
361
|
+
// materialized (no record, or `sessionFile: null`) resumes AS FRESH —
|
|
362
|
+
// the lazy shell waits for the first prompt, exactly like create (the
|
|
363
|
+
// Dash host eagerly resumes on view/model-change, so failing here broke
|
|
364
|
+
// every blank new session's composer AND permission selector). A MISSING
|
|
365
|
+
// record stays intolerable when the DSH log carries conversation
|
|
366
|
+
// content — that orphan is a genuine unknown and fails loud.
|
|
367
|
+
const record = sessionRecord(this.home, String(id));
|
|
368
|
+
const blank = record === undefined || record.sessionFile === null;
|
|
369
|
+
trace(`resume id=${id} record=${record === undefined ? "MISSING" : record.sessionFile ?? "NO-FILE"}${blank ? " (blank-tolerant)" : ""}`);
|
|
370
|
+
const persistence = this.requirePersistence("resume");
|
|
371
|
+
let orphanCheck;
|
|
372
|
+
if (record === undefined) {
|
|
373
|
+
orphanCheck = await readStoredEvents(persistence, id, options.signal);
|
|
374
|
+
if (hasConversationContent(orphanCheck)) {
|
|
375
|
+
throw new Error(`cannot resume session "${id}": no pi session is recorded for this Dash session id`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (!blank) {
|
|
379
|
+
// Resume cwd comes from the recorded pairing — re-realpath and
|
|
380
|
+
// re-verify so a moved/removed directory cannot aim a resume outside
|
|
381
|
+
// a live path.
|
|
382
|
+
const verified = validatedCwd(record.cwd);
|
|
383
|
+
if (verified === undefined) {
|
|
384
|
+
throw new Error(`cannot resume session "${id}": its recorded working directory no longer exists`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const envMode = envApprovalMode();
|
|
388
|
+
const preset = envMode !== undefined
|
|
389
|
+
? envMode
|
|
390
|
+
: record?.preset ??
|
|
391
|
+
presetFromEvents(orphanCheck ?? await readStoredEvents(persistence, id, options.signal)) ??
|
|
392
|
+
defaultPermissionPreset(loopCtx);
|
|
393
|
+
const toolset = piToolset(preset);
|
|
394
|
+
const spawnCwd = blank
|
|
395
|
+
? (record !== undefined ? validatedCwd(record.cwd) ?? process.cwd() : process.cwd())
|
|
396
|
+
: validatedCwd(record.cwd);
|
|
397
|
+
trace(`resume id=${id}${blank ? " (blank → fresh lazy shell)" : ` file=${record.sessionFile}`} spawnCwd=${spawnCwd} preset=${preset ?? "none"}`);
|
|
398
|
+
// Re-attach to the pi session by its native file (pi owns the live agent
|
|
399
|
+
// context and keeps generating it from here on; the model context is
|
|
400
|
+
// restored by pi itself from its transcript). A blank session gets the
|
|
401
|
+
// CREATE-shaped lazy shell — zero pi objects until the first prompt.
|
|
402
|
+
const client = await PiSessionClient.spawn([], spawnCwd, blank
|
|
403
|
+
? { kind: "create", ...(toolset === undefined ? {} : { tools: toolset }) }
|
|
404
|
+
: { kind: "resume", sessionFile: record.sessionFile });
|
|
405
|
+
let handle;
|
|
406
|
+
let stored;
|
|
407
|
+
let preparation;
|
|
408
|
+
let handedOff = false;
|
|
409
|
+
try {
|
|
410
|
+
// Taking write ownership FIRST excludes a concurrent resume of the
|
|
411
|
+
// same id (a live agent's handle holds the claim in this process).
|
|
412
|
+
handle = await persistence.open(id, "write", options.signal === undefined ? {} : { signal: options.signal });
|
|
413
|
+
// Crash repair belongs to the agent layer: an interrupted final turn
|
|
414
|
+
// receives synthetic closers appended through the same handle.
|
|
415
|
+
const cold = await handle.read(0, undefined, options.signal === undefined ? {} : { signal: options.signal });
|
|
416
|
+
const closers = interruptedTurnClosers(cold.events);
|
|
417
|
+
if (closers.length > 0)
|
|
418
|
+
await handle.append(closers);
|
|
419
|
+
preparation = SessionPreparation.create(loopCtx.sessions.prepare(id, {
|
|
420
|
+
seed: [...cold.events, ...closers],
|
|
421
|
+
meta: structuredClone(handle.header),
|
|
422
|
+
inheritedEventCount: handle.inheritedEventCount,
|
|
423
|
+
eventState: cold.eventState,
|
|
424
|
+
}));
|
|
425
|
+
stored = { handle, storedCount: cold.events.length + closers.length };
|
|
426
|
+
trace(`resume id=${id} seeded ${cold.events.length} events (+${closers.length} closers)`);
|
|
427
|
+
handle = undefined; // ownership passes to setupAndPublish
|
|
428
|
+
this.followSessionFile(id, client);
|
|
429
|
+
handedOff = true;
|
|
430
|
+
return await setupAndPublish(loopCtx, ownerCtx, id, this.sessionAgentOptions(options.agentOptions), options.setup, preparation.session, client, options.parentAgent, "resume", { enterSession: true, preparation }, stored, this.agentRuntime(client));
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
trace(`resume ${id} THREW: ${String(error)} | stack=${error instanceof Error ? error.stack?.slice(0, 400) : "n/a"}`);
|
|
434
|
+
client.close();
|
|
435
|
+
if (!handedOff)
|
|
436
|
+
await handle?.close().catch(() => { });
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
if (!handedOff)
|
|
441
|
+
preparation?.[Symbol.dispose]();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Watch the client for its first materialized session file and persist the
|
|
446
|
+
* Dash↔pi pairing once (the record is the resume authority; the
|
|
447
|
+
* create-time entry starts with `sessionFile: null`).
|
|
448
|
+
* Arrow FIELD (not a `#` method and not a true-private field): the provider
|
|
449
|
+
* is exposed through a Cordis tracing proxy, and hard-private members fail
|
|
450
|
+
* their brand check on the proxy receiver (the same reason setupAndPublish
|
|
451
|
+
* is module-level). A persistence failure here is logged, never thrown
|
|
452
|
+
* into the client's event stream.
|
|
453
|
+
*/
|
|
454
|
+
followSessionFile = (id, client) => {
|
|
455
|
+
const observe = (sessionFile) => {
|
|
456
|
+
try {
|
|
457
|
+
upsertSession(this.home, String(id), { sessionFile });
|
|
458
|
+
trace(`session file recorded for ${String(id)}: ${sessionFile}`);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
this.runtime.ctx.logger.warn(`pi-provider: failed to persist the pi session file for ${String(id)}: ${String(error)}`);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
const existing = client.sessionFile;
|
|
465
|
+
if (existing !== null) {
|
|
466
|
+
observe(existing);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const off = client.on(() => {
|
|
470
|
+
const observed = client.sessionFile;
|
|
471
|
+
if (observed === null)
|
|
472
|
+
return;
|
|
473
|
+
observe(observed);
|
|
474
|
+
off();
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Durable flush of the session's pre-publication suffix. Constructor seed
|
|
480
|
+
* markers and setup-window events never re-emit through `session/event`, so
|
|
481
|
+
* publication must push them through the handle before live events start
|
|
482
|
+
* routing into it. Advances by what was stored.
|
|
483
|
+
*/
|
|
484
|
+
async function appendUnstoredSuffix(stored, session) {
|
|
485
|
+
const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount));
|
|
486
|
+
if (suffix.length > 0)
|
|
487
|
+
await stored.handle.append(suffix);
|
|
488
|
+
stored.storedCount += suffix.length;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Shared creation/resume transaction: build the agent over the prepared
|
|
492
|
+
* session, run unpublished setup, flush the unstored suffix, and publish both
|
|
493
|
+
* in order; the owned storage handle is drained (closed) on teardown. Kept as
|
|
494
|
+
* a module-level function (not a private method) because Cordis exposes the
|
|
495
|
+
* provider through a tracing proxy, which breaks hard-private (`#`) receivers.
|
|
496
|
+
*/
|
|
497
|
+
async function setupAndPublish(loopCtx, ownerCtx, id, agentOptions, setup, session, client, parentAgent, source, opts, stored, runtimeInfo) {
|
|
498
|
+
let detachSession;
|
|
499
|
+
let detachAgent;
|
|
500
|
+
let agent;
|
|
501
|
+
// Late-bound teardown the agent triggers itself after its idle window
|
|
502
|
+
// expires — defined only after publication.
|
|
503
|
+
let idleExit;
|
|
504
|
+
try {
|
|
505
|
+
// Build the agent shim over the session and the live client.
|
|
506
|
+
agent = new PiAgent(loopCtx, id, agentOptions, session, client, () => idleExit?.(), runtimeInfo);
|
|
507
|
+
// Pin THIS session to the pi route. The global default-model setting is
|
|
508
|
+
// a best-effort push (the settings service may flush late, and a shared
|
|
509
|
+
// test home can hold another line's default), so the factory must not
|
|
510
|
+
// let the first prompt depend on it: `selectForNextRequest` is the same
|
|
511
|
+
// per-session mechanism `session/selectModel` uses.
|
|
512
|
+
try {
|
|
513
|
+
const route = runtimeInfo?.routeContext(agentOptions);
|
|
514
|
+
if (route !== undefined) {
|
|
515
|
+
const selection = { provider: route.provider, model: route.model };
|
|
516
|
+
let picked = selection;
|
|
517
|
+
installModelSelection(agent.ctx, {
|
|
518
|
+
get current() { return picked; },
|
|
519
|
+
set current(next) { picked = next; },
|
|
520
|
+
assembled: undefined,
|
|
521
|
+
});
|
|
522
|
+
// `model/selection` is contributed to `SessionEventMap` by the upstream
|
|
523
|
+
// session-controller package (declaration merging); this adapter does
|
|
524
|
+
// not depend on it, so the append goes through a narrow local cast.
|
|
525
|
+
session.append("model/selection", selection);
|
|
526
|
+
trace(`session route pinned: ${route.provider}/${route.model}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
// Fail-soft: the boot-time default-model push remains the fallback.
|
|
531
|
+
trace(`session route pin failed: ${String(error)}`);
|
|
532
|
+
}
|
|
533
|
+
// Session identity (the preset stamp) and pi's system prompt commit on
|
|
534
|
+
// the first turn via PiAgent's #bootstrapSessionIdentity /
|
|
535
|
+
// #emitSystemMessage: a freshly created session announces completely
|
|
536
|
+
// blank, so the backend takes zero part in the UI's new-session draft.
|
|
537
|
+
// Composition-only setup on the unpublished agent scope.
|
|
538
|
+
const commit = await setup?.(agent.ctx, agent);
|
|
539
|
+
// Publish: flush the unstored suffix (seed + setup-window events) through
|
|
540
|
+
// the owned handle, then enter both session and agent, announce in order,
|
|
541
|
+
// and signal session-start. The commit runs immediately before
|
|
542
|
+
// publication.
|
|
543
|
+
commit?.commit();
|
|
544
|
+
await appendUnstoredSuffix(stored, session);
|
|
545
|
+
if (opts.enterSession) {
|
|
546
|
+
detachSession = agent.ctx.sessions.enter(session);
|
|
547
|
+
agent.ctx.sessions.announce(session);
|
|
548
|
+
}
|
|
549
|
+
detachAgent = loopCtx.agents.enter(agent, parentAgent);
|
|
550
|
+
loopCtx.agents.announce(agent);
|
|
551
|
+
emitAgentEvent(loopCtx, agent, "agent/session-start", { source });
|
|
552
|
+
let disposed = false;
|
|
553
|
+
let unfollowOwner;
|
|
554
|
+
const dispose = async () => {
|
|
555
|
+
if (disposed)
|
|
556
|
+
return;
|
|
557
|
+
disposed = true;
|
|
558
|
+
trace(`dispose() called for agent ${id} (source=${source})`);
|
|
559
|
+
unfollowOwner?.();
|
|
560
|
+
await agent?.dispose();
|
|
561
|
+
detachAgent?.();
|
|
562
|
+
detachSession?.();
|
|
563
|
+
// Drain queued appends; a durability failure surfaces to the caller.
|
|
564
|
+
await stored.handle.close();
|
|
565
|
+
};
|
|
566
|
+
idleExit = () => {
|
|
567
|
+
void dispose().catch((error) => {
|
|
568
|
+
loopCtx.logger.warn(`pi-provider: teardown of ${id} failed: ${String(error)}`);
|
|
569
|
+
});
|
|
570
|
+
};
|
|
571
|
+
// Follow the owner: a caller-fiber unload tears this agent down.
|
|
572
|
+
unfollowOwner = ownerCtx.effect(() => () => {
|
|
573
|
+
void dispose().catch((error) => {
|
|
574
|
+
loopCtx.logger.warn(`pi-provider: owner-unload teardown of ${id} failed: ${String(error)}`);
|
|
575
|
+
});
|
|
576
|
+
}, `piProvider.lifecycle(${id})`);
|
|
577
|
+
return { agent, dispose };
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
// Unwind the half-published transaction. A failure between `enter` and
|
|
581
|
+
// the announcements must not leave a live-but-dead entry the API
|
|
582
|
+
// resolver would serve.
|
|
583
|
+
trace(`setupAndPublish ${id} FAILED: ${String(error)}`);
|
|
584
|
+
detachAgent?.();
|
|
585
|
+
detachSession?.();
|
|
586
|
+
void agent?.dispose().catch(() => { });
|
|
587
|
+
client.close();
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
// Release the preparation's per-id reservation on every path.
|
|
592
|
+
opts.preparation?.[Symbol.dispose]();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
export default PiProvider;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env-tunable knobs (PI_TRACE / idle exit), mirroring the codex line's knobs.
|
|
3
|
+
* pi's own process knobs (PI_CODING_AGENT_DIR etc.) are read by the pi SDK
|
|
4
|
+
* itself — see pi-home.ts; this module only owns adapter-side behavior.
|
|
5
|
+
*/
|
|
6
|
+
export const PI_TRACE = process.env.PI_TRACE === "1";
|
|
7
|
+
/** Trace helper: `PI_TRACE=1` on the dsh process enables stderr tracing. */
|
|
8
|
+
export function trace(...parts) {
|
|
9
|
+
if (PI_TRACE)
|
|
10
|
+
process.stderr.write(`[pi-provider ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
|
|
11
|
+
}
|
|
12
|
+
function parseMs(raw, fallback) {
|
|
13
|
+
if (raw === undefined || raw.trim() === "")
|
|
14
|
+
return fallback;
|
|
15
|
+
const value = Number(raw);
|
|
16
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* How long an idle agent keeps its live pi AgentSession before teardown
|
|
20
|
+
* (`PI_IDLE_EXIT_MS`). The next prompt cold-resumes through the recorded
|
|
21
|
+
* native session file, so tearing down at idle costs nothing observable.
|
|
22
|
+
* `0` disables the exit.
|
|
23
|
+
*/
|
|
24
|
+
export const PI_IDLE_EXIT_MS = parseMs(process.env.PI_IDLE_EXIT_MS, 600_000);
|