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.
Files changed (178) hide show
  1. package/agent-agy/bridge/agy_bridge.py +250 -0
  2. package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
  3. package/agent-agy/cordis.patch.yml +49 -0
  4. package/agent-agy/dist/adapter.d.ts +15 -0
  5. package/agent-agy/dist/adapter.js +42 -0
  6. package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
  7. package/agent-agy/dist/agent-preset-agy.js +169 -0
  8. package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
  9. package/agent-agy/dist/agent-preset-projection.js +33 -0
  10. package/agent-agy/dist/agent.d.ts +137 -0
  11. package/agent-agy/dist/agent.js +493 -0
  12. package/agent-agy/dist/agy-cli-client.d.ts +137 -0
  13. package/agent-agy/dist/agy-cli-client.js +301 -0
  14. package/agent-agy/dist/agy-client.d.ts +160 -0
  15. package/agent-agy/dist/agy-client.js +252 -0
  16. package/agent-agy/dist/agy-sessions.d.ts +24 -0
  17. package/agent-agy/dist/agy-sessions.js +116 -0
  18. package/agent-agy/dist/agy-store.d.ts +39 -0
  19. package/agent-agy/dist/agy-store.js +83 -0
  20. package/agent-agy/dist/inbox.d.ts +26 -0
  21. package/agent-agy/dist/inbox.js +53 -0
  22. package/agent-agy/dist/index.d.ts +80 -0
  23. package/agent-agy/dist/index.js +505 -0
  24. package/agent-agy/dist/models.d.ts +41 -0
  25. package/agent-agy/dist/models.js +74 -0
  26. package/agent-agy/dist/world-plugin.d.ts +9 -0
  27. package/agent-agy/dist/world-plugin.js +97 -0
  28. package/agent-agy/package.json +64 -0
  29. package/agent-claude/cordis.patch.yml +42 -0
  30. package/agent-claude/dist/adapter.js +62 -0
  31. package/agent-claude/dist/agent-preset-claude.js +169 -0
  32. package/agent-claude/dist/agent-preset-projection.js +30 -0
  33. package/agent-claude/dist/agent.js +1271 -0
  34. package/agent-claude/dist/claude-client.js +357 -0
  35. package/agent-claude/dist/claude-events.js +223 -0
  36. package/agent-claude/dist/claude-home.js +29 -0
  37. package/agent-claude/dist/commands.js +182 -0
  38. package/agent-claude/dist/content.js +134 -0
  39. package/agent-claude/dist/inbox.js +53 -0
  40. package/agent-claude/dist/index.js +555 -0
  41. package/agent-claude/dist/input-queue.js +64 -0
  42. package/agent-claude/dist/interaction.js +271 -0
  43. package/agent-claude/dist/models.js +158 -0
  44. package/agent-claude/dist/permission.js +120 -0
  45. package/agent-claude/dist/session-id.js +20 -0
  46. package/agent-claude/dist/session-map.js +165 -0
  47. package/agent-claude/dist/world-plugin.js +14 -0
  48. package/agent-claude/package.json +74 -0
  49. package/agent-codex/README.md +74 -0
  50. package/agent-codex/cordis.patch.yml +77 -0
  51. package/agent-codex/dist/adapter.js +55 -0
  52. package/agent-codex/dist/agent-preset-codex.js +171 -0
  53. package/agent-codex/dist/agent-preset-projection.js +37 -0
  54. package/agent-codex/dist/agent.js +1056 -0
  55. package/agent-codex/dist/app-home.js +65 -0
  56. package/agent-codex/dist/codex-client.js +469 -0
  57. package/agent-codex/dist/codex-events.js +297 -0
  58. package/agent-codex/dist/codex-store.js +190 -0
  59. package/agent-codex/dist/inbox.js +53 -0
  60. package/agent-codex/dist/index.js +605 -0
  61. package/agent-codex/dist/models.js +152 -0
  62. package/agent-codex/dist/permission.js +71 -0
  63. package/agent-codex/dist/session-map.js +170 -0
  64. package/agent-codex/dist/spike.js +46 -0
  65. package/agent-codex/dist/world-plugin.js +96 -0
  66. package/agent-codex/package.json +78 -0
  67. package/agent-hermes/README.md +144 -0
  68. package/agent-hermes/cordis.patch.yml +83 -0
  69. package/agent-hermes/dist/adapter.js +83 -0
  70. package/agent-hermes/dist/agent-preset-hermes.js +171 -0
  71. package/agent-hermes/dist/agent-preset-projection.js +34 -0
  72. package/agent-hermes/dist/agent.js +1068 -0
  73. package/agent-hermes/dist/hermes-client.js +953 -0
  74. package/agent-hermes/dist/hermes-events.js +289 -0
  75. package/agent-hermes/dist/hermes-store.js +158 -0
  76. package/agent-hermes/dist/inbox.js +53 -0
  77. package/agent-hermes/dist/index.js +541 -0
  78. package/agent-hermes/dist/models.js +324 -0
  79. package/agent-hermes/dist/permission.js +60 -0
  80. package/agent-hermes/dist/world-plugin.js +101 -0
  81. package/agent-hermes/package.json +76 -0
  82. package/agent-hub/cordis.patch.yml +12 -0
  83. package/agent-hub/dist/agent-roster.d.ts +34 -0
  84. package/agent-hub/dist/agent-roster.js +27 -0
  85. package/agent-hub/dist/carrier.d.ts +43 -0
  86. package/agent-hub/dist/carrier.js +241 -0
  87. package/agent-hub/dist/client-shim.d.ts +28 -0
  88. package/agent-hub/dist/client-shim.js +251 -0
  89. package/agent-hub/dist/envelope.d.ts +54 -0
  90. package/agent-hub/dist/envelope.js +92 -0
  91. package/agent-hub/dist/gateway.d.ts +28 -0
  92. package/agent-hub/dist/gateway.js +18 -0
  93. package/agent-hub/dist/index-pass.d.ts +22 -0
  94. package/agent-hub/dist/index-pass.js +47 -0
  95. package/agent-hub/dist/index.d.ts +29 -0
  96. package/agent-hub/dist/index.js +29 -0
  97. package/agent-hub/dist/labels.d.ts +45 -0
  98. package/agent-hub/dist/labels.js +83 -0
  99. package/agent-hub/dist/ownership.d.ts +37 -0
  100. package/agent-hub/dist/ownership.js +54 -0
  101. package/agent-hub/dist/roster.d.ts +28 -0
  102. package/agent-hub/dist/roster.js +38 -0
  103. package/agent-hub/dist/rpc.d.ts +38 -0
  104. package/agent-hub/dist/rpc.js +52 -0
  105. package/agent-hub/dist/spawn-world.d.ts +46 -0
  106. package/agent-hub/dist/spawn-world.js +72 -0
  107. package/agent-hub/dist/targets.d.ts +33 -0
  108. package/agent-hub/dist/targets.js +30 -0
  109. package/agent-hub/dist/world-entry.d.ts +31 -0
  110. package/agent-hub/dist/world-entry.js +51 -0
  111. package/agent-hub/dist/world-host.d.ts +69 -0
  112. package/agent-hub/dist/world-host.js +44 -0
  113. package/agent-hub/dist/world-join.d.ts +16 -0
  114. package/agent-hub/dist/world-join.js +122 -0
  115. package/agent-hub/dist/world-mount.d.ts +52 -0
  116. package/agent-hub/dist/world-mount.js +52 -0
  117. package/agent-hub/dist/world-mux.d.ts +72 -0
  118. package/agent-hub/dist/world-mux.js +224 -0
  119. package/agent-hub/dist/world-provision.d.ts +20 -0
  120. package/agent-hub/dist/world-provision.js +122 -0
  121. package/agent-hub/dist/world-web-server.d.ts +162 -0
  122. package/agent-hub/dist/world-web-server.js +248 -0
  123. package/agent-hub/lib/client/index.js +242 -0
  124. package/agent-hub/package.json +65 -0
  125. package/agent-omp/README.md +77 -0
  126. package/agent-omp/cordis.patch.yml +92 -0
  127. package/agent-omp/dist/adapter.js +36 -0
  128. package/agent-omp/dist/agent-preset-omp.js +171 -0
  129. package/agent-omp/dist/agent-preset-projection.js +36 -0
  130. package/agent-omp/dist/agent.js +1145 -0
  131. package/agent-omp/dist/app-home.js +86 -0
  132. package/agent-omp/dist/discovery.js +116 -0
  133. package/agent-omp/dist/inbox.js +53 -0
  134. package/agent-omp/dist/index.js +574 -0
  135. package/agent-omp/dist/knobs.js +50 -0
  136. package/agent-omp/dist/lazy-rpc.js +161 -0
  137. package/agent-omp/dist/mobile/gesture.js +148 -0
  138. package/agent-omp/dist/mobile/zoom-guard.js +294 -0
  139. package/agent-omp/dist/mobile-boot.js +38 -0
  140. package/agent-omp/dist/models.js +299 -0
  141. package/agent-omp/dist/omp-cli.js +147 -0
  142. package/agent-omp/dist/omp-disk-discovery.js +178 -0
  143. package/agent-omp/dist/omp-store.js +518 -0
  144. package/agent-omp/dist/pairing.js +45 -0
  145. package/agent-omp/dist/permission.js +96 -0
  146. package/agent-omp/dist/protocol.js +13 -0
  147. package/agent-omp/dist/rpc-types.js +1 -0
  148. package/agent-omp/dist/sdk-client.js +378 -0
  149. package/agent-omp/dist/sidecar-client.js +195 -0
  150. package/agent-omp/dist/store/adapter.js +47 -0
  151. package/agent-omp/dist/store/db.js +210 -0
  152. package/agent-omp/dist/store/index.js +59 -0
  153. package/agent-omp/dist/store/reconcile.js +51 -0
  154. package/agent-omp/dist/store/schema.js +40 -0
  155. package/agent-omp/dist/world-plugin.js +96 -0
  156. package/agent-omp/package.json +97 -0
  157. package/agent-omp/sidecar/main.ts +481 -0
  158. package/agent-pi/README.md +67 -0
  159. package/agent-pi/cordis.patch.yml +80 -0
  160. package/agent-pi/dist/adapter.js +53 -0
  161. package/agent-pi/dist/agent-preset-pi.js +169 -0
  162. package/agent-pi/dist/agent-preset-projection.js +29 -0
  163. package/agent-pi/dist/agent.js +949 -0
  164. package/agent-pi/dist/inbox.js +53 -0
  165. package/agent-pi/dist/index.js +595 -0
  166. package/agent-pi/dist/knobs.js +24 -0
  167. package/agent-pi/dist/models.js +200 -0
  168. package/agent-pi/dist/permission.js +66 -0
  169. package/agent-pi/dist/pi-client.js +224 -0
  170. package/agent-pi/dist/pi-events.js +155 -0
  171. package/agent-pi/dist/pi-home.js +55 -0
  172. package/agent-pi/dist/safe-json.js +25 -0
  173. package/agent-pi/dist/session-map.js +119 -0
  174. package/agent-pi/dist/world-plugin.js +97 -0
  175. package/agent-pi/package.json +77 -0
  176. package/cordis.patch.yml +22 -19
  177. package/package.json +88 -25
  178. 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);