dsh-loop-engine 1.0.0-rc8 → 1.0.0-rc9

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/README.md CHANGED
@@ -26,6 +26,26 @@ Restart `dsh web`, then open **Settings → Loop engine**.
26
26
  > your project root) and retry. Only the installing project can grant this;
27
27
  > the plugin cannot pre-approve its own dependencies.
28
28
 
29
+ ## Version compatibility
30
+
31
+ dsh-loop-engine is versioned **independently** of the harness (`1.0.0-rcN`) but
32
+ is bound to a specific harness release via `peerDependencies`. The two must be
33
+ matched — a mismatch fails loudly at boot or session resume:
34
+
35
+ | dsh-loop-engine | Requires harness |
36
+ |---|---|
37
+ | 1.0.0-rc9 | **0.1.2-rc.1** |
38
+ | 1.0.0-rc8 | **0.1.2-rc.1** |
39
+ | 1.0.0-rc7 | 0.1.1-rc.2 |
40
+
41
+ - **1.0.0-rc9 (and 1.0.0-rc8) is not compatible with harness 0.1.1-rc.2 or earlier.** It uses
42
+ the 0.1.2 persistence seam (`SessionPersistence.create` / `open` +
43
+ `SessionHandle`), the `installSection` settings API, `ToolCallId`, and
44
+ `Session.snapshotEvents()` — none of which exist in older harnesses.
45
+ - To use the plugin with an older harness, install the loop-engine release that
46
+ matches it (e.g. `npm i dsh-loop-engine@1.0.0-rc7` for harness 0.1.1-rc.2).
47
+ - The GitHub Release body of each tag states the harness version it targets.
48
+
29
49
  ### Requirements
30
50
 
31
51
  - For the Claude Code engine: the Claude Code CLI installed and logged in on
package/lib/index.js CHANGED
@@ -1019,11 +1019,15 @@ var ClaudeCodeAgent = class {
1019
1019
  };
1020
1020
 
1021
1021
  // src/driver-core/ownership.ts
1022
- import { FiberState } from "@deepseek-ai/cordis";
1022
+ var INACTIVE_FIBER_VALUES = {
1023
+ FAILED: 3,
1024
+ DISPOSED: 4,
1025
+ UNLOADING: 5
1026
+ };
1023
1027
  var INACTIVE_STATES = /* @__PURE__ */ new Set([
1024
- FiberState.UNLOADING,
1025
- FiberState.DISPOSED,
1026
- FiberState.FAILED
1028
+ INACTIVE_FIBER_VALUES.UNLOADING,
1029
+ INACTIVE_FIBER_VALUES.DISPOSED,
1030
+ INACTIVE_FIBER_VALUES.FAILED
1027
1031
  ]);
1028
1032
  var FactoryOwnership = class {
1029
1033
  constructor(fiber) {
@@ -1530,7 +1534,7 @@ var AppServerClient = class _AppServerClient {
1530
1534
  clientInfo: {
1531
1535
  name: "dsh-loop-engine",
1532
1536
  title: null,
1533
- version: "1.0.0-rc8"
1537
+ version: "1.0.0-rc9"
1534
1538
  },
1535
1539
  capabilities: { experimentalApi: true, requestAttestation: false }
1536
1540
  };
@@ -3281,14 +3285,32 @@ var PiAgent = class {
3281
3285
  });
3282
3286
  this.requestHeaderLogged = true;
3283
3287
  }
3288
+ /**
3289
+ * The harness Session's web-side model selection, if any was stored. The
3290
+ * durable `model/selection` event carries `{ provider, model, ... }`; when a
3291
+ * user picked a model via `/model`, this is the newest pick, and it overrides
3292
+ * the deployment config (which stays the fallback). Returns `undefined` when
3293
+ * no selection was stored, so the deployment config governs.
3294
+ */
3295
+ dynamicModel() {
3296
+ for (const event of [...this.session.snapshotEvents()].reverse()) {
3297
+ const type = event.type;
3298
+ if (type !== "model/selection") continue;
3299
+ const data = event.data;
3300
+ const model = data?.model;
3301
+ if (typeof model === "string" && model.length > 0) return model;
3302
+ }
3303
+ return void 0;
3304
+ }
3284
3305
  /** Build the `pi --mode rpc` argv/cwd/env for one step's child process. */
3285
3306
  spawnSpec(cwd) {
3286
3307
  const argv = [];
3308
+ const model = this.dynamicModel() ?? this.config.model;
3287
3309
  if (this.config.provider !== void 0) argv.push("--provider", this.config.provider);
3288
- if (this.config.model !== void 0 && this.config.thinkingLevel !== void 0) {
3289
- argv.push("--model", `${this.config.model}:${this.config.thinkingLevel}`);
3290
- } else if (this.config.model !== void 0) {
3291
- argv.push("--model", this.config.model);
3310
+ if (model !== void 0 && this.config.thinkingLevel !== void 0) {
3311
+ argv.push("--model", `${model}:${this.config.thinkingLevel}`);
3312
+ } else if (model !== void 0) {
3313
+ argv.push("--model", model);
3292
3314
  } else if (this.config.thinkingLevel !== void 0) {
3293
3315
  argv.push("--model", `:${this.config.thinkingLevel}`);
3294
3316
  }
@@ -3543,6 +3565,67 @@ var PiAgent = class {
3543
3565
  }
3544
3566
  };
3545
3567
 
3568
+ // src/engine-pi/probe.ts
3569
+ function waitForExit(child) {
3570
+ return new Promise((resolve5) => {
3571
+ ;
3572
+ child.onExit(
3573
+ (code) => resolve5(code ?? 0)
3574
+ );
3575
+ });
3576
+ }
3577
+ function collectStdout(child) {
3578
+ return new Promise((resolve5) => {
3579
+ let text = "";
3580
+ if (child.stdout.on === void 0) {
3581
+ resolve5("");
3582
+ return;
3583
+ }
3584
+ const out = child.stdout;
3585
+ out.setEncoding("utf8");
3586
+ out.on("data", (data) => {
3587
+ text += String(data);
3588
+ });
3589
+ out.on("end", () => {
3590
+ resolve5(text);
3591
+ });
3592
+ out.on("close", () => {
3593
+ resolve5(text);
3594
+ });
3595
+ });
3596
+ }
3597
+ function parsePiModelList(output) {
3598
+ const entries = [];
3599
+ for (const line of output.split("\n")) {
3600
+ const trimmed = line.trim();
3601
+ if (trimmed.length === 0) continue;
3602
+ const match = /^(\S+)\s{2,}(\S+)/.exec(trimmed);
3603
+ if (match === null) continue;
3604
+ const provider = match[1];
3605
+ if (provider === "provider") continue;
3606
+ entries.push({ provider, model: match[2] });
3607
+ }
3608
+ return entries;
3609
+ }
3610
+ async function probePiModels(bin, spawn4) {
3611
+ const spec = {
3612
+ argv: [bin, "--mode", "rpc", "--list-models"],
3613
+ cwd: process.cwd(),
3614
+ env: {}
3615
+ };
3616
+ let child;
3617
+ try {
3618
+ child = spawn4(spec);
3619
+ } catch {
3620
+ return [];
3621
+ }
3622
+ const stdoutPromise = collectStdout(child);
3623
+ const exitCode = await waitForExit(child);
3624
+ if (exitCode !== 0) return [];
3625
+ const stdout = await stdoutPromise;
3626
+ return parsePiModelList(stdout);
3627
+ }
3628
+
3546
3629
  // src/engine-pi/loop.ts
3547
3630
  var PI_SANDBOX_MODES = [
3548
3631
  "read-only",
@@ -3555,7 +3638,8 @@ var Config3 = z3.object({
3555
3638
  provider: z3.string(),
3556
3639
  model: z3.string(),
3557
3640
  thinkingLevel: z3.string(),
3558
- env: z3.dict(z3.string()).default({})
3641
+ env: z3.dict(z3.string()).default({}),
3642
+ piCatalogHolder: z3.any()
3559
3643
  });
3560
3644
  function resolveConfig3(config) {
3561
3645
  return {
@@ -3594,7 +3678,8 @@ function fromSubprocess(handle) {
3594
3678
  stdout,
3595
3679
  stderr,
3596
3680
  onExit: (handler) => {
3597
- void handle.done.then(handler, handler);
3681
+ const onExit = handler;
3682
+ void handle.done.then((outcome) => onExit(outcome.exitCode), handler);
3598
3683
  },
3599
3684
  terminate: () => handle.terminate()
3600
3685
  };
@@ -3618,6 +3703,14 @@ var PiLoop = class extends Service3 {
3618
3703
  this.runtime = { ctx };
3619
3704
  this.bin = piCliEntrypoint();
3620
3705
  this.spawn = (spec) => fromSubprocess(this.runtime.ctx.subprocess.spawn(piSubprocessSpec(spec, PI_DISPOSE_GRACE_MS)));
3706
+ const holder = config.piCatalogHolder;
3707
+ if (holder !== void 0) {
3708
+ void probePiModels(this.bin, (spec) => this.spawn(spec)).then((models) => {
3709
+ holder.entries = [...models];
3710
+ }).catch(() => {
3711
+ holder.entries = [];
3712
+ });
3713
+ }
3621
3714
  ctx.effect(() => () => this.ownership.dispose(), "agentLoopPi.transactions()");
3622
3715
  ctx.effect(() => ctx.agents.setFactory(this), "agentLoopPi.setFactory()");
3623
3716
  ctx.systemPrompt.variable("provider", (context) => context.agent?.options.provider);
@@ -5646,12 +5739,24 @@ var HOSTED_PROVIDER_ROUTES = {
5646
5739
  var HostedEngineRouteAdapter = class extends LlmAdapter {
5647
5740
  /**
5648
5741
  * @param label - the provider route label this placeholder serves.
5742
+ * @param options - optional catalog source; omit for an empty catalog.
5649
5743
  */
5650
- constructor(label) {
5744
+ constructor(label, options = {}) {
5651
5745
  super();
5652
5746
  this.label = label;
5747
+ this.options = options;
5653
5748
  }
5654
5749
  label;
5750
+ options;
5751
+ /** Advertise the injected Pi models (if any) under this route's provider label. */
5752
+ async listModels(_provider) {
5753
+ const catalog = this.options.listModels?.() ?? [];
5754
+ return catalog.map((entry) => ({
5755
+ provider: this.label,
5756
+ id: `${entry.provider}/${entry.model}`,
5757
+ name: `${entry.provider}/${entry.model}`
5758
+ }));
5759
+ }
5655
5760
  stream(_options) {
5656
5761
  throw new LlmError5(
5657
5762
  `provider "${this.label}" is a hosted loop engine route, not a model endpoint`,
@@ -6047,6 +6152,7 @@ function apply(ctx, config) {
6047
6152
  let mountedEngine;
6048
6153
  let commandDisposers;
6049
6154
  let skillDisposer;
6155
+ const piCatalogHolder = { entries: [] };
6050
6156
  let mountAttempts = 0;
6051
6157
  let mountRetry;
6052
6158
  const CLEAR_RETRY = () => {
@@ -6096,7 +6202,8 @@ function apply(ctx, config) {
6096
6202
  return;
6097
6203
  }
6098
6204
  try {
6099
- routeHandle = llm.registerAdapter([label], new HostedEngineRouteAdapter(label));
6205
+ const options = engine === "pi" ? { listModels: () => piCatalogHolder.entries } : void 0;
6206
+ routeHandle = llm.registerAdapter([label], new HostedEngineRouteAdapter(label, options));
6100
6207
  routeEngine = engine;
6101
6208
  } catch (error) {
6102
6209
  if (error instanceof Error && error.message.includes("already registered")) {
@@ -6215,7 +6322,10 @@ function apply(ctx, config) {
6215
6322
  if (skills !== void 0) {
6216
6323
  skillDisposer = skills.registerProvider((control) => new PiSkillProvider(control));
6217
6324
  }
6218
- hostFactory("pi", () => ctx.plugin(PiLoop, piConfig(config)));
6325
+ hostFactory("pi", () => ctx.plugin(PiLoop, {
6326
+ ...piConfig(config),
6327
+ piCatalogHolder
6328
+ }));
6219
6329
  };
6220
6330
  const mountKimi = () => {
6221
6331
  const commands = ctx.get("commands");
@@ -9,11 +9,10 @@
9
9
  *
10
10
  * @module dsh-loop-engine/driver-core/ownership
11
11
  */
12
- import { FiberState } from '@deepseek-ai/cordis';
13
12
  import type { Context } from '@deepseek-ai/cordis';
14
13
  import type { SessionId } from '@deepseek-ai/dsh-session';
15
14
  /** Fiber states that cannot own or serve a new lifecycle. */
16
- export declare const INACTIVE_STATES: ReadonlySet<FiberState>;
15
+ export declare const INACTIVE_STATES: ReadonlySet<number>;
17
16
  /** Factory-level ownership: live agent teardowns plus load-time tracking. */
18
17
  export declare class FactoryOwnership {
19
18
  private readonly fiber;
@@ -109,6 +109,14 @@ export declare class PiAgent implements Agent {
109
109
  private modelLabel;
110
110
  /** Append the request header snapshot once per loop instance. */
111
111
  private assertRequestHeader;
112
+ /**
113
+ * The harness Session's web-side model selection, if any was stored. The
114
+ * durable `model/selection` event carries `{ provider, model, ... }`; when a
115
+ * user picked a model via `/model`, this is the newest pick, and it overrides
116
+ * the deployment config (which stays the fallback). Returns `undefined` when
117
+ * no selection was stored, so the deployment config governs.
118
+ */
119
+ private dynamicModel;
112
120
  /** Build the `pi --mode rpc` argv/cwd/env for one step's child process. */
113
121
  private spawnSpec;
114
122
  /**
@@ -14,6 +14,7 @@ import { Service } from '@deepseek-ai/cordis';
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  import z from '@deepseek-ai/schemastery';
16
16
  import type { AgentFactory, AgentHandle, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent';
17
+ import type { PiModelEntry } from './probe.ts';
17
18
  import type { PiProcess, PiSpawnSpec } from './rpc/client.ts';
18
19
  import type { PiSandboxMode, ResolvedConfig } from './types.ts';
19
20
  /** Pi CLI sandbox modes a deployment may pin. */
@@ -38,6 +39,10 @@ export interface Config {
38
39
  thinkingLevel?: string;
39
40
  /** Explicit environment entries passed to the `pi` child. */
40
41
  env?: Record<string, string>;
42
+ /** Shared Pi model catalog holder; the loop writes its `pi --list-models` probe result here. */
43
+ piCatalogHolder?: {
44
+ entries: readonly PiModelEntry[];
45
+ };
41
46
  }
42
47
  /** Schema of the Pi loop plugin configuration. */
43
48
  export declare const Config: z<Config>;
@@ -0,0 +1,23 @@
1
+ import type { PiProcess, PiSpawnSpec } from './rpc/client.ts';
2
+ /** One discoverable Pi model: the raw two-part identity `pi` exposes. */
3
+ export interface PiModelEntry {
4
+ readonly provider: string;
5
+ readonly model: string;
6
+ }
7
+ /**
8
+ * Parse the output of `pi --list-models`: a column-aligned table whose first
9
+ * two columns are `provider` and `model`. The header row and blank lines are
10
+ * skipped; long model ids simply occupy more columns. Splitting on 2+ spaces
11
+ * yields provider and model in the first two fields regardless of alignment.
12
+ */
13
+ export declare function parsePiModelList(output: string): PiModelEntry[];
14
+ /**
15
+ * Spawn `pi --list-models` and return the parsed model entries. Failures
16
+ * (non-zero exit, no stdout, spawn throw) resolve to an empty array so the
17
+ * catalog stays advisory and a probe glitch never breaks the engine mount.
18
+ * @param bin - the Pi CLI entrypoint (from `piCliEntrypoint()`); becomes spec.argv[0].
19
+ * @param spawn - the process-spawn adapter; `piSubprocessSpec` prepends the node
20
+ * prefix, so spec.argv must NOT carry it.
21
+ */
22
+ export declare function probePiModels(bin: string, spawn: (spec: PiSpawnSpec) => PiProcess): Promise<readonly PiModelEntry[]>;
23
+ //# sourceMappingURL=probe.d.ts.map
@@ -12,11 +12,21 @@
12
12
  *
13
13
  * @module dsh-loop-engine/provider-route
14
14
  */
15
- import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
15
+ import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
16
16
  import { LlmAdapter } from '@deepseek-ai/dsh-llm';
17
17
  import type { LoopEngineId } from './settings.ts';
18
+ import type { PiModelEntry } from './engine-pi/probe.ts';
18
19
  /** Provider route label each hosted engine logs into its sessions' request/header. */
19
20
  export declare const HOSTED_PROVIDER_ROUTES: Readonly<Record<Exclude<LoopEngineId, 'in-process'>, string>>;
21
+ /** Injectable catalog source a hosted engine route can advertise over the placeholder. */
22
+ export interface HostedEngineRouteAdapterOptions {
23
+ /**
24
+ * Optional model catalog generator. When present, `listModels` advertises
25
+ * these entries under this route's provider label; when absent, the catalog
26
+ * stays empty (the default, "engine owns its models" behavior).
27
+ */
28
+ readonly listModels?: () => readonly PiModelEntry[];
29
+ }
20
30
  /**
21
31
  * Placeholder adapter serving one hosted engine's provider route label. It
22
32
  * inherits the empty catalog and default metadata (the engine's model is not a
@@ -26,10 +36,14 @@ export declare const HOSTED_PROVIDER_ROUTES: Readonly<Record<Exclude<LoopEngineI
26
36
  */
27
37
  export declare class HostedEngineRouteAdapter extends LlmAdapter {
28
38
  private readonly label;
39
+ private readonly options;
29
40
  /**
30
41
  * @param label - the provider route label this placeholder serves.
42
+ * @param options - optional catalog source; omit for an empty catalog.
31
43
  */
32
- constructor(label: string);
44
+ constructor(label: string, options?: HostedEngineRouteAdapterOptions);
45
+ /** Advertise the injected Pi models (if any) under this route's provider label. */
46
+ listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
33
47
  stream(_options: GenerateOptions): AsyncIterable<StreamChunk>;
34
48
  }
35
49
  //# sourceMappingURL=provider-route.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-loop-engine",
3
3
  "description": "Web-switchable agent loop engine selection for the DeepSeek Harness - out-of-tree plugin (Claude Code / Codex / Pi / Kimi Code drivers) maintained by @kuun993, zero main-repo changes",
4
- "version": "1.0.0-rc8",
4
+ "version": "1.0.0-rc9",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },