dsh-loop-engine 0.1.5-rc2 → 0.1.5-rc4

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 (54) hide show
  1. package/README.md +44 -177
  2. package/README.zh.md +48 -100
  3. package/lib/client.js +887 -266
  4. package/lib/index.js +2212 -914
  5. package/lib/invariant.js +43 -45
  6. package/lib/types/agent-preset-ids.d.ts +303 -0
  7. package/lib/types/client/LoopEngineBadge.d.ts +44 -17
  8. package/lib/types/client/LoopEngineComposerSelect.d.ts +79 -13
  9. package/lib/types/client/LoopEngineSection.d.ts +5 -4
  10. package/lib/types/client/locales.d.ts +133 -7
  11. package/lib/types/client/reload.d.ts +135 -0
  12. package/lib/types/client/session-engine.d.ts +474 -0
  13. package/lib/types/client/store.d.ts +1 -1
  14. package/lib/types/client/turn-status.d.ts +112 -10
  15. package/lib/types/client/use-session-engine.d.ts +66 -0
  16. package/lib/types/commands.d.ts +11 -3
  17. package/lib/types/driver-core/host-servers.d.ts +106 -0
  18. package/lib/types/driver-core/hosted-engine-runtime.d.ts +190 -0
  19. package/lib/types/driver-core/hosted-tool-vocabulary.d.ts +72 -0
  20. package/lib/types/driver-core/model-handover.d.ts +116 -0
  21. package/lib/types/driver-core/ownership.d.ts +6 -5
  22. package/lib/types/driver-core/prompt.d.ts +32 -0
  23. package/lib/types/driver-core/session-lifetime.d.ts +62 -0
  24. package/lib/types/driver-core/session-model.d.ts +82 -0
  25. package/lib/types/engine-claude/agent.d.ts +23 -3
  26. package/lib/types/engine-claude/loop.d.ts +16 -15
  27. package/lib/types/engine-codex/agent.d.ts +22 -3
  28. package/lib/types/engine-codex/appserver/client.d.ts +15 -2
  29. package/lib/types/engine-codex/loop.d.ts +13 -15
  30. package/lib/types/engine-codex/model-handover.d.ts +44 -0
  31. package/lib/types/engine-kimi/acp/client.d.ts +10 -0
  32. package/lib/types/engine-kimi/agent.d.ts +19 -2
  33. package/lib/types/engine-kimi/commands.d.ts +18 -14
  34. package/lib/types/engine-kimi/loop.d.ts +14 -16
  35. package/lib/types/engine-kimi/model-handover.d.ts +32 -0
  36. package/lib/types/engine-kimi/process.d.ts +2 -2
  37. package/lib/types/engine-kimi/types.d.ts +1 -1
  38. package/lib/types/engine-of-session.d.ts +97 -0
  39. package/lib/types/engine-pi/agent.d.ts +25 -23
  40. package/lib/types/engine-pi/loop.d.ts +13 -23
  41. package/lib/types/engine-pi/model-handover.d.ts +35 -0
  42. package/lib/types/engine-pi/types.d.ts +2 -2
  43. package/lib/types/engine-remote.d.ts +192 -0
  44. package/lib/types/engine-surface.d.ts +36 -0
  45. package/lib/types/index.d.ts +51 -50
  46. package/lib/types/invariant.d.ts +8 -5
  47. package/lib/types/model-selection-reset.d.ts +271 -0
  48. package/lib/types/patch-manager.d.ts +57 -39
  49. package/lib/types/preset.d.ts +39 -26
  50. package/lib/types/provider-route.d.ts +83 -36
  51. package/lib/types/router-loop.d.ts +406 -0
  52. package/lib/types/session-engine-store.d.ts +138 -0
  53. package/lib/types/settings.d.ts +12 -11
  54. package/package.json +109 -104
@@ -0,0 +1,406 @@
1
+ /**
2
+ * The process-wide agent factory, dispatching each session to the engine it
3
+ * runs.
4
+ *
5
+ * The harness admits exactly ONE `AgentFactory` per process
6
+ * (`AgentRegistry.setFactory` throws on a second registration) and exposes no
7
+ * per-session way to resolve one, so "session A on Codex while session B runs
8
+ * Kimi" is only expressible if the single factory itself routes. This class is
9
+ * that router: it extends the harness's own `AgentLoop`, inheriting the
10
+ * in-process loop in full (turn boundary projection, prompt variables, the
11
+ * agent-loop settings section, the factory registration), and overrides the two
12
+ * AgentFactory entry points to hand a session to its engine's runtime.
13
+ *
14
+ * Which engine that is comes from {@link engineOfSession} — the plugin's own
15
+ * per-session record first, the recorded agent preset otherwise — with one
16
+ * create-time exception spelled out on {@link RouterLoop.engineFor}:
17
+ * - create: the record, else `CreateAgentOptions.meta.agentPreset` (already
18
+ * resolved by the caller, `packages/api/session-controller/src/agent.ts`
19
+ * `composeAgent`), with a live parent's engine as the fallback for a child;
20
+ * - resume: the record, else the persisted `agentPreset` projection.
21
+ * `ResumeAgentOptions` carries no metadata, so that projection is read
22
+ * through {@link engineOfSession} — the same fold the host itself reads
23
+ * before choosing the composition to mount, and the same one the plugin's
24
+ * own Remote reports to the browser half.
25
+ *
26
+ * The session can also be moved while it is LIVE — that is what
27
+ * {@link RouterLoop.selectEngine} is for, and it is the half of the story the
28
+ * preset channel cannot express (the harness refuses a preset change on a
29
+ * started session, `agent-preset/locked`). Between two hosted engines the move
30
+ * is an IN-PLACE SWAP: the successor is built onto the session's own `Session`
31
+ * object and the outgoing machine is retired around it, so the session — and
32
+ * the page attached to it — never witnesses a lifecycle edge
33
+ * ({@link RouterLoop.hotSwap}). A move that involves the harness loop on either
34
+ * side cannot be done in place at all, so it is made to land the other way:
35
+ * the session's agent is RELEASED, which leaves the session cold with its
36
+ * record already naming the new engine, and the outcome asks the browser half
37
+ * to reload the page ({@link RouterLoop.move}) — the reload is what reopens the
38
+ * session, and the host's next resolve then builds it on the record's engine.
39
+ *
40
+ * `in-process` sessions are served by `super`; every hosted engine gets one
41
+ * {@link HostedEngineRuntime} built on first use, which is also what makes the
42
+ * engines concurrent: their agents, subprocesses, and scopes are all
43
+ * per-session, and each runtime owns only its own live agents.
44
+ *
45
+ * The router also answers what a session is running
46
+ * ({@link RouterLoop.reportEngine}) from the same bookkeeping that routes it,
47
+ * because it is the only thing that knows which engine built a live agent — see
48
+ * {@link SessionEngineReport} for why "what runs now" and "what the record says"
49
+ * are two facts that must travel separately.
50
+ *
51
+ * @module dsh-loop-engine/router-loop
52
+ */
53
+ import type { Context } from '@deepseek-ai/cordis';
54
+ import AgentLoop from '@deepseek-ai/dsh-agent-loop';
55
+ import type { AgentHandle, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent';
56
+ import type { SessionId } from '@deepseek-ai/dsh-session';
57
+ import type { LoopEngineSelectResult, SessionEngineReport } from './agent-preset-ids.ts';
58
+ import type { HostedAgent, HostedEngineRuntime } from './driver-core/hosted-engine-runtime.ts';
59
+ import type { EngineRecordStore } from './session-engine-store.ts';
60
+ import type { HostedEngineId, LoopEngineId } from './settings.ts';
61
+ /**
62
+ * The preset roster announces a committed per-session preset change on the
63
+ * shared event bus so consumers can invalidate state derived from that
64
+ * session's composition. Declared here rather than imported from
65
+ * `@deepseek-ai/dsh-agent-presets`: the plugin consumes the notification but
66
+ * takes no build-time dependency on the roster, which a minimal profile may not
67
+ * compose at all.
68
+ */
69
+ declare module '@deepseek-ai/cordis' {
70
+ interface Events {
71
+ /**
72
+ * One session committed a different agent preset to its durable log.
73
+ * @mode emit
74
+ * @param sessionId - the session whose composition changed.
75
+ * @param agentPreset - the preset recorded by the committed selection.
76
+ */
77
+ 'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void;
78
+ }
79
+ }
80
+ /**
81
+ * Services the router's context must inject.
82
+ *
83
+ * Taken from the harness loop verbatim rather than re-listed: the router IS the
84
+ * harness loop for `in-process` sessions, and cordis refuses a service
85
+ * PROPERTY READ on a context whose fiber did not inject it
86
+ * (`cannot get property "tools" without inject`). The loop's turn machinery
87
+ * reads `ctx.tools` and `ctx.llm` directly, so a shorter gate here compiles and
88
+ * even creates agents, then dies on the first real turn.
89
+ */
90
+ export declare const ROUTER_SERVICES: readonly string[];
91
+ /**
92
+ * Any hosted engine's runtime, as this router holds it. The engines differ only
93
+ * in configuration and driver; the transaction surface the router calls is
94
+ * identical, so the map is type-erased once here.
95
+ */
96
+ export type RouterEngine = HostedEngineRuntime<object, HostedAgent>;
97
+ /** Builds (or returns the memoized) driver runtime of one hosted engine. */
98
+ export type EngineBuilder = (engine: HostedEngineId) => RouterEngine;
99
+ /**
100
+ * The single AgentFactory, routing per session.
101
+ *
102
+ * Constructed by the plugin once the services the harness loop needs are
103
+ * active; its effects belong to the constructing fiber, so unloading the plugin
104
+ * tears every engine down.
105
+ */
106
+ export declare class RouterLoop extends AgentLoop {
107
+ private readonly engines;
108
+ private readonly live;
109
+ private readonly build;
110
+ private readonly records;
111
+ private readonly warn;
112
+ /**
113
+ * The model-selection half of routing: the seat a session's engine owns, moved
114
+ * at a switch ({@link RouterLoop.moveModelSelection}) and written at a build
115
+ * ({@link RouterLoop.engineOptions}). Owned here because each warning it owes
116
+ * is owed once per process, not once per session.
117
+ */
118
+ private readonly selectionReset;
119
+ /**
120
+ * @param ctx - the context the router's effects belong to.
121
+ * @param build - memoized builder for one hosted engine's runtime.
122
+ * @param records - the plugin's own per-session engine record, which outranks
123
+ * every preset-derived answer and is what {@link selectEngine} writes.
124
+ * @param warn - diagnostic sink for a skipped engine command.
125
+ */
126
+ constructor(ctx: Context, build: EngineBuilder, records: EngineRecordStore, warn: (message: string) => void);
127
+ /** The engine that drives one session's agent. */
128
+ private engineFor;
129
+ /** The engine's runtime, built on first use and kept for the plugin's lifetime. */
130
+ private runtimeOf;
131
+ /**
132
+ * Record one published agent so a later engine change can rebuild it, and
133
+ * bridge the engine's command/skill surface into its session.
134
+ * @param engine - the engine that built the handle.
135
+ * @param handle - the published handle.
136
+ * @param recipe - how it was built, for a hot swap's successor.
137
+ * @param handover - a hosted handle's session handover; absent for the
138
+ * harness loop, whose handle carries neither the session's lifetime nor a
139
+ * way to retire the machine without releasing it.
140
+ * @returns the handle the harness tracks.
141
+ */
142
+ private adopt;
143
+ /** Drop one tracked session, if the record still points at that exact agent. */
144
+ private forget;
145
+ /**
146
+ * Create a session's agent on the engine it runs.
147
+ *
148
+ * The plugin's own record is consulted FIRST, ahead of the preset the caller
149
+ * composed with: a session that already has a record must be built on the
150
+ * engine the plugin reports for it, or the router and the Remote would answer
151
+ * differently about one session. Only a session with no record — every session
152
+ * this plugin has never switched, including every brand-new one — falls
153
+ * through to the preset.
154
+ * @param ownerCtx - caller context that owns the lifecycle.
155
+ * @param options - identities, metadata (carrying the preset), and setup.
156
+ * @returns the published handle.
157
+ */
158
+ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;
159
+ /**
160
+ * Resume a persisted session on the engine it runs.
161
+ *
162
+ * The read is {@link engineOfSession}: the plugin's own record first, then the
163
+ * durable `agentPreset` projection, which folds the session header together
164
+ * with every committed `agent-preset/selected`. The session header alone would
165
+ * name the preset the session was CREATED with, which is exactly the wrong
166
+ * answer for a session that switched engine while it was blank — and the
167
+ * record is the only answer for one that switched after it started, which no
168
+ * preset can express.
169
+ *
170
+ * A resume is not a create: the session's seat is one a previous build already
171
+ * put in its log (`model-selection-reset.ts`, the switch and build triggers), so
172
+ * a hosted engine's resume leaves the selection exactly as it finds it. Only
173
+ * the harness loop's own resume is wrapped, with the narrower trigger that
174
+ * applies to it — it makes real model calls, so it must not be left selecting a
175
+ * placeholder route.
176
+ * @param ownerCtx - caller context that owns load, setup, and the lifecycle.
177
+ * @param options - persisted identity, loop options, and setup.
178
+ * @returns the published handle.
179
+ */
180
+ resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;
181
+ /**
182
+ * Wrap the caller's setup so the session's ENGINE gets its model seat written
183
+ * before the host installs the session's selection, and hand a hosted engine
184
+ * the `agentOptions` that seat names.
185
+ *
186
+ * The wrap is what makes the write land at all: the host installs the session's
187
+ * model selection from INSIDE the caller's own setup
188
+ * (`ApiSessionAgentController.selectionFor`), and that read takes the log's
189
+ * pending `model/selection` — so the write has to happen before it, and the
190
+ * caller's setup has to run unchanged afterwards. Both of the router's engines
191
+ * are wrapped, each with its own trigger: the harness loop's build keeps the
192
+ * narrow one (only a session that would otherwise select a placeholder route),
193
+ * while a hosted engine's build is where a NEW session gets its seat. The
194
+ * resume path calls this with `in-process` alone (see {@link resume}).
195
+ *
196
+ * The `agentOptions` half keeps the two answers consistent: a hosted engine is
197
+ * handed the very selection written into the session (`external/default`), not
198
+ * the deployment default that seat replaced. In-process keeps the caller's own
199
+ * options, which ARE its route.
200
+ *
201
+ * The caller's setup is copied, never mutated: the same options object is the
202
+ * router's rebuild recipe, which replays the caller's own closure.
203
+ * @param options - the create or resume options the caller supplied.
204
+ * @param engine - the engine building this session.
205
+ * @returns a copy whose setup writes the seat first, then the caller's own.
206
+ */
207
+ private engineOptions;
208
+ /**
209
+ * Report what one session ACTUALLY runs, and — when they differ — the engine
210
+ * its record names.
211
+ *
212
+ * This is the read the plugin's own Remote publishes, and the reason it is the
213
+ * router's to make is the live bookkeeping below: for a session with an agent
214
+ * this router built, {@link RouterLoop.live} says which engine built it, and
215
+ * that beats every record — it is what is running. A session this router does
216
+ * not drive (cold, or still on the base loop's slot during the mount window)
217
+ * has no live fact to report, so the record answers, exactly as it does for a
218
+ * session about to be built; and since every switch this plugin performs
219
+ * either swaps the agent in place or releases it, the two facts differ only
220
+ * for a release that did not take.
221
+ * @param sessionId - the session to report on.
222
+ * @returns the engine driving it now, plus the engine its record names for its
223
+ * next build when the two differ.
224
+ */
225
+ reportEngine(sessionId: SessionId): Promise<SessionEngineReport>;
226
+ /**
227
+ * Move one live session to another engine.
228
+ *
229
+ * The one entry point that makes "switch this session's engine" possible after
230
+ * a session has started, which the harness's own preset channel refuses
231
+ * (`agent-preset/locked`, `packages/preset/agent-presets/src/index.ts`). The
232
+ * order below is load-bearing:
233
+ *
234
+ * 1. the checks, so a session that cannot be moved is refused with a reason
235
+ * instead of half-moved;
236
+ * 2. the record, because it is the answer the router, the Remote, and the
237
+ * host's next resolve all read — and a record that could not be written
238
+ * must leave the session where it is;
239
+ * 3. the session's model seat, which moves with the choice
240
+ * ({@link moveModelSelection}) — before the move below, because the move is
241
+ * what detaches (or re-installs) the agent that reads it;
242
+ * 4. the move itself: an in-place swap between two hosted engines, or — when
243
+ * the harness loop is on either side — a release of the session's agent
244
+ * plus a request that the page reload (`reload: true`), which is what
245
+ * makes the host build the session again on the recorded engine.
246
+ *
247
+ * A session already on the requested engine is recorded and left running: the
248
+ * record is the user's explicit choice, while tearing an untouched agent down
249
+ * would cost the session's rebuild for no change at all. "Already on" is
250
+ * judged on the LIVE AGENT's engine, not on the record: the two can differ
251
+ * while a released agent is still in flight, and then re-selecting the engine
252
+ * the session is actually running is how a user takes that record back — the
253
+ * record follows the agent back, and nothing is torn down.
254
+ * @param sessionId - the session to move.
255
+ * @param engine - the engine it should run.
256
+ * @returns the engine now recorded for it, whether the page must reload for it
257
+ * to be built, or — as `ok: false` — the {@link LoopEngineRefusalCode} of the
258
+ * branch that refused it plus its own sentence about this session. Every
259
+ * refusal this method produces carries a code; the browser half localizes
260
+ * from it and keeps the sentence as detail.
261
+ */
262
+ selectEngine(sessionId: SessionId, engine: LoopEngineId): Promise<LoopEngineSelectResult>;
263
+ /**
264
+ * Make a just-recorded engine change take effect on the live session.
265
+ *
266
+ * Between two hosted engines the change is an IN-PLACE SWAP
267
+ * ({@link hotSwap}): the session's `Session` object is kept, its agent is
268
+ * replaced, and the browser half attached to that session sees no lifecycle
269
+ * edge at all.
270
+ *
271
+ * A change with the harness loop on EITHER side cannot be done in place, and
272
+ * this is a limit of the harness rather than a choice: `AgentLoop` neither
273
+ * hands a live session over nor accepts one it did not create. Its factory
274
+ * holds the session's store entry and write handle in private closure state
275
+ * (`agent-loop/src/index.ts` `prepare`), its publication enters the session
276
+ * (`sessions.enter`, which refuses an id already in the store), and its own
277
+ * agent class is not exported. So the session's agent is RELEASED instead
278
+ * ({@link release}) and the change lands on the session's next build, which
279
+ * the returned `reload: true` puts in the user's hands: the session goes cold
280
+ * with the record already naming its engine, and the page — reloaded, and
281
+ * reopening that session — is what makes the host resolve it again, on the
282
+ * engine the record names.
283
+ *
284
+ * The page really does have to reload, and that is the one cost of this path:
285
+ * a release emits `session/disposed`, and the browser half reads that as this
286
+ * session being gone, with no way back in that page's lifetime
287
+ * (`session.ts` `handleRemoved` sets a `removed` flag nothing ever clears), so
288
+ * a page that stayed put would show a session that can no longer be typed
289
+ * into. The alternative — leaving the live agent alone and letting the record
290
+ * wait for a process restart — is what this plugin used to do, and it cost the
291
+ * user a restart for a switch the host can perform itself.
292
+ * @param entry - the live session's record; the caller verified it is movable.
293
+ * @param engine - the engine just recorded for it.
294
+ * @returns the switch outcome, asking for the page reload that builds the
295
+ * session again when its agent had to be released.
296
+ */
297
+ private move;
298
+ /**
299
+ * Replace one live session's agent with a machine the incoming engine builds
300
+ * onto the SAME `Session`.
301
+ *
302
+ * The order is load-bearing. The outgoing machine is retired FIRST and
303
+ * completely: only one agent may be registered per session id (`agents.enter`
304
+ * refuses a duplicate), and two machines folded over one session's inbox would
305
+ * splice each other's queued messages, so the outgoing machine must be settled
306
+ * before any input can reach the successor. The SESSION is never released:
307
+ * its store entry and write handle travel in the lifetime the outgoing machine
308
+ * hands over, which is exactly why the page attached to this session stays
309
+ * usable across the swap.
310
+ *
311
+ * A failure after the handover cannot be undone — the outgoing machine is
312
+ * gone and the successor never published. The lifetime is then released, so
313
+ * the session goes cold and reopens on the recorded engine the way any session
314
+ * the host has not loaded does, and the refusal says what happened.
315
+ * @param entry - the live session's record; the caller verified it is movable.
316
+ * @param handover - the outgoing machine's session handover.
317
+ * @param engine - the hosted engine to build the successor on.
318
+ * @returns the switch outcome.
319
+ */
320
+ private hotSwap;
321
+ /** Read the host session-projection registry, structurally. */
322
+ private projections;
323
+ /**
324
+ * Drop one tracked session's agent so the host's next resolve rebuilds it.
325
+ *
326
+ * Two callers, and they both want the engine change to land at the session's
327
+ * next build rather than in place:
328
+ *
329
+ * - {@link move}, the engine picker's path involving the harness loop, which
330
+ * WAITS for the teardown (the outcome it is about to return says the
331
+ * session is cold, and that must be true when it says so);
332
+ * - the harness's own preset switch on a blank session, which fire-and-forgets
333
+ * it because it runs inside a synchronous event handler. It is the one
334
+ * engine change this plugin still makes by releasing the session rather
335
+ * than by moving it: the successor's composition is the NEW preset's, which
336
+ * only the API layer composes (a preset the harness mounted itself reaches
337
+ * the plugin as an id, not as a composition callback), so the plugin cannot
338
+ * build a correctly-composed successor in place.
339
+ *
340
+ * The record is forgotten before the teardown runs, so a teardown-driven event
341
+ * can never find the entry it is releasing and start a second teardown of the
342
+ * same agent; a failure is reported rather than thrown, and the returned
343
+ * promise settles either way.
344
+ * @param entry - the live session record to release.
345
+ * @returns the teardown, once it settled.
346
+ */
347
+ private release;
348
+ /**
349
+ * Follow the harness's own preset switch, for a BLANK session.
350
+ *
351
+ * The harness's preset switch on a blank session re-parents the live agent's
352
+ * scope and records the choice; it does not rebuild the agent, so the engine
353
+ * would keep running the one the session was created with. Dropping the agent
354
+ * instead lets the host's own next resolve fall back to a resume
355
+ * (`ApiSessionAgentController.resolve`), which composes from the recorded
356
+ * preset and lands on the new engine — the same path a reopened session
357
+ * takes, with the durable log as the only carry-over.
358
+ *
359
+ * LAST USER ACTION WINS: for a session this plugin already holds a record
360
+ * for, the harness's picker is an engine choice too, so the record follows it
361
+ * before the agent is released. Without that, the two entry points would
362
+ * contradict each other — the picker would rebuild the session on the preset's
363
+ * engine, while the very next resume would read the plugin's record and move
364
+ * it back. A session with NO record keeps reading its engine off the preset,
365
+ * exactly as before, and gets none: only an explicit choice is recorded.
366
+ *
367
+ * A session that has already run a turn is left alone — its live engine and
368
+ * its record both stay where they are, so the router and the Remote keep
369
+ * agreeing about it. (A started session cannot reach here through the
370
+ * harness's picker anyway: the roster refuses with `agent-preset/locked`.)
371
+ *
372
+ * The session's model seat moves with the choice, before the release
373
+ * ({@link moveModelSelection}) — the picker is an engine change like the
374
+ * picker's own switcher, and this is the one place the harness can make it.
375
+ */
376
+ private rebuildOnEngineChange;
377
+ /**
378
+ * Hand one session's model seat to the engine it is being switched to.
379
+ *
380
+ * A session's selection follows its engine: a hosted engine owns its model
381
+ * natively and the shared provider label it logs is served only by this
382
+ * plugin's placeholder route (which fails loud, `HOSTED_ENGINE_ROUTE`, when a
383
+ * real model call reaches it), while the harness loop DOES make real calls and therefore needs
384
+ * a real model selected. So a switch moves the seat — the shared
385
+ * `external/default` for a hosted engine, the deployment default for the
386
+ * harness loop. A selection that
387
+ * names a real model of its own is never touched: the model notice the browser
388
+ * half shows beside a hosted engine says that selection is inert there, not
389
+ * that it is gone. The write itself, its judgements, and why an engine change
390
+ * with no logged selection writes nothing are in `model-selection-reset.ts`;
391
+ * this method is only the router's half.
392
+ *
393
+ * The selection is written at the moment of the switch, not at the moment the
394
+ * engine changes. Between two hosted engines the move is an in-place swap whose
395
+ * `setup` installs the selection onto the successor, and for anything involving
396
+ * the harness loop the switch releases the session's agent and the page reloads
397
+ * ({@link move}) — either way the write has to be in the log before the move
398
+ * runs, which is what this ordering buys.
399
+ * @param entry - the live session being switched.
400
+ * @param engine - the engine it is being switched to.
401
+ * @returns nothing; a selection this process cannot name is skipped with one
402
+ * warning, and the session keeps what its log records.
403
+ */
404
+ private moveModelSelection;
405
+ }
406
+ //# sourceMappingURL=router-loop.d.ts.map
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The plugin's own per-session engine record: one sidecar file beside the
3
+ * harness home, deliberately NOT an event in the session log.
4
+ *
5
+ * The engine a session runs is a per-session host-plane fact, and the session
6
+ * log is where this project records such facts — so the obvious home for it is
7
+ * a plugin-authored session event. It cannot be done:
8
+ *
9
+ * - `Session.append` builds the event envelope itself and offers no way to set
10
+ * the envelope's `ignorable?: true` marker (the only options it takes are
11
+ * surface metadata);
12
+ * - the persistence read path refuses a stored event whose type is outside the
13
+ * harness's generated `KNOWN_SESSION_EVENT_TYPES` unless the envelope carries
14
+ * that marker;
15
+ * - so the append succeeds in memory, the write succeeds on disk, and the NEXT
16
+ * cold read of that session refuses the whole log:
17
+ * `SessionFormatUnsupportedError: … contains event type "…" unknown to this
18
+ * harness and not marked ignorable; refusing to interpret the log`.
19
+ *
20
+ * That is a destroyed session per engine switch, so this plugin keeps the fact
21
+ * out of the log instead. `docs/proposals/append-ignorable-events.md` asks the
22
+ * harness for the missing seam; if it lands, this record can move into the log
23
+ * with the file kept as the fallback for sessions recorded before the change.
24
+ *
25
+ * Storage semantics (all of them deliberate):
26
+ *
27
+ * - one small JSON document, `{ version, engines: { <sessionId>: <engine> } }`,
28
+ * written whole through a same-directory temp file + rename, so a crash
29
+ * mid-write can never leave a truncated record behind;
30
+ * - the in-memory view advances only after the write committed, so a failed
31
+ * write leaves both the file and the view exactly as they were;
32
+ * - a missing file is the normal first-run state (no diagnostic); an
33
+ * unreadable or unrecognizable file degrades to "no record" with ONE warn
34
+ * (the document is read at most once per process, so no lookup repeats a
35
+ * failure), because a broken record must cost a session its remembered
36
+ * engine, never its ability to open: the routing read falls back to the
37
+ * preset mapping;
38
+ * - entries are added, never removed: one line per session ever switched, which
39
+ * a deployment cannot notice, and a stale entry for a deleted session is
40
+ * simply never asked about.
41
+ *
42
+ * @module dsh-loop-engine/session-engine-store
43
+ */
44
+ import type { SessionId } from '@deepseek-ai/dsh-session';
45
+ import { type LoopEngineId } from './agent-preset-ids.ts';
46
+ /** Harness-home-relative directory holding the plugin's own records. */
47
+ export declare const ENGINE_RECORD_DIR = ".loop-engine";
48
+ /** File name of the per-session engine record document. */
49
+ export declare const ENGINE_RECORD_FILE = "engines.json";
50
+ /** Format version of the record document; a mismatch is treated as no record. */
51
+ export declare const ENGINE_RECORD_VERSION = 1;
52
+ /**
53
+ * The record file's path under the harness home (`$DSH_HOME/.loop-engine/engines.json`).
54
+ * @returns the absolute path of the engine record document.
55
+ */
56
+ export declare function resolveEngineRecordPath(): string;
57
+ /**
58
+ * Read one session's engine off the plugin's own record.
59
+ *
60
+ * The narrow face the routing read depends on, so a test can hand the router an
61
+ * exact set of records instead of a file.
62
+ */
63
+ export interface EngineRecordSource {
64
+ /**
65
+ * The engine this plugin's record names for one session.
66
+ * @param sessionId - the session to look up.
67
+ * @returns the recorded engine, or undefined when this session has no record.
68
+ */
69
+ engineOf(sessionId: SessionId): LoopEngineId | undefined;
70
+ }
71
+ /**
72
+ * The record as the ROUTER uses it: the read every routing decision makes, plus
73
+ * the write an engine switch commits.
74
+ */
75
+ export interface EngineRecordStore extends EngineRecordSource {
76
+ /**
77
+ * Record one session's engine, replacing any earlier record for it.
78
+ * @param sessionId - the session whose engine is recorded.
79
+ * @param engine - the engine that session now runs.
80
+ */
81
+ record(sessionId: SessionId, engine: LoopEngineId): void;
82
+ }
83
+ /**
84
+ * Replace one file atomically: same-directory temp file, then rename over the
85
+ * target, so a reader never sees a partial document. The temp name is unique
86
+ * per write, so two writers cannot collide on it.
87
+ * @param path - the file to replace (its directory is created as needed).
88
+ * @param text - the next file content.
89
+ */
90
+ export declare function writeFileAtomicSync(path: string, text: string): void;
91
+ /**
92
+ * The plugin's per-session engine record, held in memory and mirrored to one
93
+ * atomically written JSON file.
94
+ *
95
+ * The file is read once, lazily, on the first lookup; every later lookup is a
96
+ * map read, so routing never pays for filesystem I/O.
97
+ */
98
+ export declare class SessionEngineStore implements EngineRecordSource {
99
+ private readonly path;
100
+ private readonly warn;
101
+ private entries;
102
+ /**
103
+ * @param path - the record document's absolute path.
104
+ * @param warn - diagnostic sink for a record this build cannot read.
105
+ */
106
+ constructor(path: string, warn: (message: string) => void);
107
+ /**
108
+ * The engine this plugin's record names for one session.
109
+ * @param sessionId - the session to look up.
110
+ * @returns the recorded engine, or undefined when there is no usable record.
111
+ */
112
+ engineOf(sessionId: SessionId): LoopEngineId | undefined;
113
+ /**
114
+ * Record one session's engine, replacing any earlier record for it.
115
+ *
116
+ * The write commits before the in-memory view moves, so a caller that sees a
117
+ * rejection knows the store still answers what it answered before.
118
+ * @param sessionId - the session whose engine is recorded.
119
+ * @param engine - the engine that session now runs.
120
+ * @throws when the document could not be written; the previous file is intact.
121
+ */
122
+ record(sessionId: SessionId, engine: LoopEngineId): void;
123
+ /** The in-memory record, read from disk on first use. */
124
+ private load;
125
+ /** Read the document once, degrading to an empty record. */
126
+ private read;
127
+ /**
128
+ * Report one unusable record and answer "no record".
129
+ *
130
+ * The load is memoized, so this runs at most once per store — once per process
131
+ * for a path — rather than once per lookup: a broken record costs one line, and
132
+ * the degraded view serves every later read.
133
+ * @param problem - what is wrong with the record, with the path.
134
+ * @returns the empty record.
135
+ */
136
+ private degrade;
137
+ }
138
+ //# sourceMappingURL=session-engine-store.d.ts.map
@@ -1,25 +1,26 @@
1
1
  /**
2
2
  * Shared loop-engine identity, namespace, and schema.
3
3
  *
4
- * The namespace literal lives in the zero-import `./namespace.ts` so both
5
- * halves agree on the section name: the node half brands it as a
6
- * `SettingsNamespace`, while the browser half imports the same literal
7
- * without pulling the host-side `dsh-settings` service into the client
8
- * bundle (cross-plugin value imports go through cordis services, and
9
- * `settings-scope.ts` follows the same discipline).
4
+ * Both the namespace literal and the engine/preset-id mapping live in
5
+ * zero-import modules (`./namespace.ts`, `./agent-preset-ids.ts`) so both halves
6
+ * agree on them: the node half brands the literal as a `SettingsNamespace` and
7
+ * builds this schema, while the browser half imports the same literals without
8
+ * pulling host-side packages (`dsh-settings`, `schemastery`, `node:fs`) into the
9
+ * client bundle (cross-plugin value imports go through cordis services, and
10
+ * `settings-scope.ts` follows the same discipline). This module re-exports them
11
+ * so node-side importers keep their paths.
10
12
  *
11
13
  * @module dsh-loop-engine/settings
12
14
  */
13
15
  import z from '@deepseek-ai/schemastery';
14
16
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
17
+ import type { LoopEngineId } from './agent-preset-ids.ts';
15
18
  export { LOOP_ENGINE_SETTINGS_NAMESPACE_LITERAL } from './namespace.ts';
16
- /** The installed engine driving new Agent turns. */
17
- export declare const LOOP_ENGINE_IDS: readonly ["in-process", "claude-code", "codex", "pi", "kimi"];
18
- /** Installed agent loop engine id. */
19
- export type LoopEngineId = (typeof LOOP_ENGINE_IDS)[number];
19
+ export { HOSTED_ENGINE_IDS, HOSTED_PRESET_PREFIX, LOOP_ENGINE_IDS, SOURCE_PRESET_ID, engineOfPreset, enginePresetId, } from './agent-preset-ids.ts';
20
+ export type { HostedEngineId, LoopEngineId } from './agent-preset-ids.ts';
20
21
  /** Stored and composed loop engine selection. */
21
22
  export interface LoopEngineSettings {
22
- /** The engine future Agents are created on. */
23
+ /** The engine NEW sessions are created on; the engine a given session runs is the plugin's own per-session record. */
23
24
  engine: LoopEngineId;
24
25
  /** Whether the composer's loop engine picker is shown on the chat page. */
25
26
  showInComposer: boolean;