pi2dsh 0.12.0 → 0.12.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.
@@ -1,6 +1,6 @@
1
1
 
2
- import { F as AssistantMessageEventStream, a as builtinProviders, h as getSupportedThinkingLevels, i as __setPiAiLlmBridge } from "./pi-ai-Dyg4zyLZ.mjs";
3
- import { P as generateBranchSummary, Tn as PiCapabilityError, b as Theme, d as ExtensionRunner, w as __setSubagentSessionFactory, wn as CapabilityLedger } from "./pi-coding-agent-Ctw-BhoY.mjs";
2
+ import { F as AssistantMessageEventStream, I as isContextOverflow, a as builtinProviders, h as getSupportedThinkingLevels, i as __setPiAiLlmBridge } from "./pi-ai-Dyg4zyLZ.mjs";
3
+ import { P as generateBranchSummary, Tn as PiCapabilityError, b as Theme, d as ExtensionRunner, w as __setSubagentSessionFactory, wn as CapabilityLedger } from "./pi-coding-agent-CYnmeJEx.mjs";
4
4
  import { t as getAgentDir } from "./pi-config-shim-CZ1wFzqM.mjs";
5
5
  import { a as resolvePiProviderAuth, n as loginPiProvider, r as providerSupportsOAuth, t as FileCredentialStore } from "./oauth-bridge-CfyImWx4.mjs";
6
6
  import { createRequire } from "node:module";
@@ -12,10 +12,18 @@ import { tmpdir } from "node:os";
12
12
  import { EventEmitter } from "node:events";
13
13
  import { AsyncLocalStorage } from "node:async_hooks";
14
14
  import { createJiti } from "jiti";
15
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
15
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, QUOTA_EXCEEDED_CODE, createUserMessage, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
16
16
  import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
17
17
  import { randomUUID } from "node:crypto";
18
+ import { foldSurface } from "@deepseek-ai/dsh-session";
19
+ import { Compile } from "typebox/compile";
20
+ import { Value } from "typebox/value";
18
21
  //#region src/session-bridge.ts
22
+ /** The durable seq behind a projected entry id (`dsh-<seq>`), when it has one. */
23
+ function entrySeq(id) {
24
+ const match = /^dsh-(\d+)$/.exec(id);
25
+ return match === null ? void 0 : Number(match[1]);
26
+ }
19
27
  function sidecarDir() {
20
28
  return join(getAgentDir(), "session-entries");
21
29
  }
@@ -143,6 +151,20 @@ var PiSessionBridge = class {
143
151
  * shape. DSH history is linear, so the projection is a single-branch tree:
144
152
  * every entry's parent is its predecessor.
145
153
  */
154
+ /**
155
+ * The seqs still on the model-visible surface, via DSH's own canonical fold.
156
+ * @param session - the session to project.
157
+ * @returns the visible seqs, or undefined when the fold cannot run (a
158
+ * projection built from a partial event list, e.g. in a test double) — in
159
+ * which case nothing is filtered out rather than everything.
160
+ */
161
+ visibleSeqs(session) {
162
+ try {
163
+ return new Set(foldSurface(sessionEvents(session)).nodes);
164
+ } catch {
165
+ return;
166
+ }
167
+ }
146
168
  projectEntries(session) {
147
169
  this.load(session.id);
148
170
  const merged = [];
@@ -270,7 +292,14 @@ var PiSessionBridge = class {
270
292
  const index = entries.findIndex((entry) => entry.id === fromId);
271
293
  return index === -1 ? [] : entries.slice(0, index + 1);
272
294
  },
273
- buildContextEntries: () => entriesOf().filter((entry) => entry.type === "message" || entry.type === "compaction" || entry.type === "branch_summary" || entry.type === "custom_message"),
295
+ buildContextEntries: () => {
296
+ const visible = this.visibleSeqs(session);
297
+ return entriesOf().filter((entry) => {
298
+ if (entry.type !== "message" && entry.type !== "compaction" && entry.type !== "branch_summary" && entry.type !== "custom_message") return false;
299
+ const seq = entrySeq(entry.id);
300
+ return seq === void 0 || visible === void 0 || visible.has(seq);
301
+ });
302
+ },
274
303
  getHeader: () => ({
275
304
  type: "session",
276
305
  version: 3,
@@ -332,6 +361,8 @@ var PiBridgedAgentSession = class {
332
361
  #pendingToolCalls = /* @__PURE__ */ new Set();
333
362
  #sessionName = "";
334
363
  #turns = 0;
364
+ /** Keeps message projections (which await attachment reads) in log order. */
365
+ #messageProjection = Promise.resolve();
335
366
  #aborted = false;
336
367
  constructor(host, handle, tools) {
337
368
  this.#host = host;
@@ -464,8 +495,9 @@ var PiBridgedAgentSession = class {
464
495
  }
465
496
  });
466
497
  }
467
- const message = this.#host.messageFromSessionEvent(event);
468
- if (message !== void 0) {
498
+ this.#messageProjection = this.#messageProjection.then(async () => {
499
+ const message = await this.#host.messageFromSessionEvent(event);
500
+ if (message === void 0) return;
469
501
  this.messages.push(message);
470
502
  emit({
471
503
  type: "message_start",
@@ -475,19 +507,21 @@ var PiBridgedAgentSession = class {
475
507
  type: "message_end",
476
508
  message
477
509
  });
478
- }
510
+ });
479
511
  if (type === "turn/end") {
480
512
  this.#turns += 1;
481
513
  this.#streaming = false;
482
514
  this.#pendingToolCalls.clear();
483
- emit({
484
- type: "turn_end",
485
- turnIndex: Number(event.data.turn ?? 1) - 1,
486
- message: {
487
- role: "assistant",
488
- content: []
489
- },
490
- toolResults: []
515
+ this.#messageProjection = this.#messageProjection.then(() => {
516
+ emit({
517
+ type: "turn_end",
518
+ turnIndex: Number(event.data.turn ?? 1) - 1,
519
+ message: {
520
+ role: "assistant",
521
+ content: []
522
+ },
523
+ toolResults: []
524
+ });
491
525
  });
492
526
  }
493
527
  }
@@ -541,6 +575,7 @@ var PiBridgedAgentSession = class {
541
575
  }
542
576
  }), "followup");
543
577
  await completed;
578
+ await this.#messageProjection;
544
579
  }
545
580
  steer(text) {
546
581
  this.#host.piContentToDsh([{
@@ -663,6 +698,184 @@ async function createBridgedAgentSession(host, options) {
663
698
  return { session: new PiBridgedAgentSession(host, handle, tools) };
664
699
  }
665
700
  //#endregion
701
+ //#region src/compat/vendor/pi-tool-validation.ts
702
+ const validatorCache = /* @__PURE__ */ new WeakMap();
703
+ const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
704
+ function getSchemaTypes(schema) {
705
+ if (typeof schema.type === "string") return [schema.type];
706
+ if (Array.isArray(schema.type)) return schema.type.filter((type) => typeof type === "string");
707
+ return [];
708
+ }
709
+ function matchesJsonType(value, type) {
710
+ switch (type) {
711
+ case "number": return typeof value === "number";
712
+ case "integer": return typeof value === "number" && Number.isInteger(value);
713
+ case "boolean": return typeof value === "boolean";
714
+ case "string": return typeof value === "string";
715
+ case "null": return value === null;
716
+ case "array": return Array.isArray(value);
717
+ case "object": return typeof value === "object" && value !== null && !Array.isArray(value);
718
+ default: return false;
719
+ }
720
+ }
721
+ function getSubSchemaValidator(schema) {
722
+ try {
723
+ return getValidator(schema);
724
+ } catch {
725
+ return;
726
+ }
727
+ }
728
+ function coercePrimitiveByType(value, type) {
729
+ switch (type) {
730
+ case "number":
731
+ if (value === null) return 0;
732
+ if (typeof value === "string" && value.trim() !== "") {
733
+ const parsed = Number(value);
734
+ if (Number.isFinite(parsed)) return parsed;
735
+ }
736
+ if (typeof value === "boolean") return value ? 1 : 0;
737
+ return value;
738
+ case "integer":
739
+ if (value === null) return 0;
740
+ if (typeof value === "string" && value.trim() !== "") {
741
+ const parsed = Number(value);
742
+ if (Number.isInteger(parsed)) return parsed;
743
+ }
744
+ if (typeof value === "boolean") return value ? 1 : 0;
745
+ return value;
746
+ case "boolean":
747
+ if (value === null) return false;
748
+ if (typeof value === "string") {
749
+ if (value === "true") return true;
750
+ if (value === "false") return false;
751
+ }
752
+ if (typeof value === "number") {
753
+ if (value === 1) return true;
754
+ if (value === 0) return false;
755
+ }
756
+ return value;
757
+ case "string":
758
+ if (value === null) return "";
759
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
760
+ return value;
761
+ case "null":
762
+ if (value === "" || value === 0 || value === false) return null;
763
+ return value;
764
+ default: return value;
765
+ }
766
+ }
767
+ function applySchemaObjectCoercion(value, schema) {
768
+ const properties = schema.properties;
769
+ const definedKeys = new Set(properties ? Object.keys(properties) : []);
770
+ if (properties) for (const [key, propertySchema] of Object.entries(properties)) {
771
+ if (!(key in value)) continue;
772
+ value[key] = coerceWithJsonSchema(value[key], propertySchema);
773
+ }
774
+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") for (const [key, propertyValue] of Object.entries(value)) {
775
+ if (definedKeys.has(key)) continue;
776
+ value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
777
+ }
778
+ }
779
+ function applySchemaArrayCoercion(value, schema) {
780
+ if (Array.isArray(schema.items)) {
781
+ for (let index = 0; index < value.length; index++) {
782
+ const itemSchema = schema.items[index];
783
+ if (!itemSchema) continue;
784
+ value[index] = coerceWithJsonSchema(value[index], itemSchema);
785
+ }
786
+ return;
787
+ }
788
+ if (schema.items && typeof schema.items === "object") for (let index = 0; index < value.length; index++) value[index] = coerceWithJsonSchema(value[index], schema.items);
789
+ }
790
+ function coerceWithUnionSchema(value, schemas) {
791
+ for (const schema of schemas) if (getSubSchemaValidator(schema)?.Check(value)) return value;
792
+ for (const schema of schemas) {
793
+ const coerced = coerceWithJsonSchema(structuredClone(value), schema);
794
+ if (getSubSchemaValidator(schema)?.Check(coerced)) return coerced;
795
+ }
796
+ return value;
797
+ }
798
+ function coerceWithJsonSchema(value, schema) {
799
+ let nextValue = value;
800
+ if (Array.isArray(schema.allOf)) for (const nested of schema.allOf) nextValue = coerceWithJsonSchema(nextValue, nested);
801
+ if (Array.isArray(schema.anyOf)) nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
802
+ if (Array.isArray(schema.oneOf)) nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
803
+ const schemaTypes = getSchemaTypes(schema);
804
+ const matchesUnionMember = schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
805
+ if (schemaTypes.length > 0 && !matchesUnionMember) for (const schemaType of schemaTypes) {
806
+ const candidate = coercePrimitiveByType(nextValue, schemaType);
807
+ if (candidate !== nextValue) {
808
+ nextValue = candidate;
809
+ break;
810
+ }
811
+ }
812
+ if (schemaTypes.includes("object") && typeof nextValue === "object" && nextValue !== null && !Array.isArray(nextValue)) applySchemaObjectCoercion(nextValue, schema);
813
+ if (schemaTypes.includes("array") && Array.isArray(nextValue)) applySchemaArrayCoercion(nextValue, schema);
814
+ return nextValue;
815
+ }
816
+ function normalizeOptionalNulls(value, schema) {
817
+ if (Array.isArray(value)) {
818
+ if (Array.isArray(schema.items)) for (let index = 0; index < value.length; index++) {
819
+ const itemSchema = schema.items[index];
820
+ if (itemSchema) normalizeOptionalNulls(value[index], itemSchema);
821
+ }
822
+ else if (schema.items) for (const item of value) normalizeOptionalNulls(item, schema.items);
823
+ return;
824
+ }
825
+ if (typeof value !== "object" || value === null || !schema.properties) return;
826
+ const object = value;
827
+ const required = new Set(schema.required ?? []);
828
+ for (const [key, propertySchema] of Object.entries(schema.properties)) {
829
+ if (!(key in object)) continue;
830
+ if (object[key] === null && !required.has(key) && typeof propertySchema.$ref !== "string" && getSubSchemaValidator(propertySchema)?.Check(null) === false) delete object[key];
831
+ else normalizeOptionalNulls(object[key], propertySchema);
832
+ }
833
+ }
834
+ function getValidator(schema) {
835
+ const key = schema;
836
+ const cached = validatorCache.get(key);
837
+ if (cached) return cached;
838
+ const validator = Compile(schema);
839
+ validatorCache.set(key, validator);
840
+ return validator;
841
+ }
842
+ function formatValidationPath(error) {
843
+ if (error.keyword === "required") {
844
+ const requiredProperty = error.params.requiredProperties?.[0];
845
+ if (requiredProperty) {
846
+ const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
847
+ return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
848
+ }
849
+ }
850
+ return error.instancePath.replace(/^\//, "").replace(/\//g, ".") || "root";
851
+ }
852
+ /**
853
+ * Validates tool call arguments against the tool's TypeBox schema
854
+ * @param tool The tool definition with TypeBox schema
855
+ * @param toolCall The tool call from the LLM
856
+ * @returns The validated (and potentially coerced) arguments
857
+ * @throws Error with formatted message if validation fails
858
+ */
859
+ function validateToolArguments(tool, toolCall) {
860
+ const args = structuredClone(toolCall.arguments);
861
+ normalizeOptionalNulls(args, tool.parameters);
862
+ Value.Convert(tool.parameters, args);
863
+ const validator = getValidator(tool.parameters);
864
+ if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
865
+ const coerced = coerceWithJsonSchema(args, tool.parameters);
866
+ if (coerced !== args) {
867
+ if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {
868
+ for (const key of Object.keys(args)) delete args[key];
869
+ Object.assign(args, coerced);
870
+ } else return validator.Check(coerced) ? coerced : args;
871
+ }
872
+ }
873
+ if (validator.Check(args)) return args;
874
+ const errors = validator.Errors(args).map((error) => ` - ${formatValidationPath(error)}: ${error.message}`).join("\n") || "Unknown validation error";
875
+ const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
876
+ throw new Error(errorMessage);
877
+ }
878
+ //#endregion
666
879
  //#region src/model-bridge.ts
667
880
  function llmOf(ctx) {
668
881
  try {
@@ -736,14 +949,30 @@ var ModelCatalog = class {
736
949
  provider: provider.id
737
950
  }));
738
951
  } catch {}
952
+ const resolved = /* @__PURE__ */ new Map();
953
+ await Promise.all(next.map(async (model) => {
954
+ const exact = await this.#resolveExact(model.provider, model.id);
955
+ if (exact === void 0) return;
956
+ if (exact.contextWindow !== void 0) model.contextWindow = exact.contextWindow;
957
+ if (exact.maxTokens !== void 0) model.maxTokens = exact.maxTokens;
958
+ model.reasoning = exact.reasoning;
959
+ resolved.set(`${model.provider}${model.id}`, model);
960
+ }));
739
961
  this.#models = next;
962
+ this.#resolved = resolved;
740
963
  }
741
964
  /** Exact-route metadata (context window, reasoning efforts), cached per route. */
742
965
  async resolve(provider, modelId) {
743
- if (this.#llm === void 0) return void 0;
744
966
  const key = `${provider}${modelId}`;
745
967
  const cached = this.#resolved.get(key);
746
968
  if (cached !== void 0) return cached;
969
+ const model = await this.#resolveExact(provider, modelId);
970
+ if (model !== void 0) this.#resolved.set(key, model);
971
+ return model;
972
+ }
973
+ /** One exact resolve, uncached: the two callers own their own caching. */
974
+ async #resolveExact(provider, modelId) {
975
+ if (this.#llm === void 0) return void 0;
747
976
  try {
748
977
  const info = await this.#llm.resolveModelInfo(provider, modelId);
749
978
  const model = piModelFromInfo(info);
@@ -753,7 +982,6 @@ var ModelCatalog = class {
753
982
  if (typeof defaultMaxTokens === "number") model.maxTokens = defaultMaxTokens;
754
983
  const reasoning = info.reasoning;
755
984
  model.reasoning = Array.isArray(reasoning?.efforts) && reasoning.efforts.length > 0;
756
- this.#resolved.set(key, model);
757
985
  return model;
758
986
  } catch {
759
987
  return;
@@ -1083,26 +1311,34 @@ function normalizeToolSchemas(tools) {
1083
1311
  }));
1084
1312
  }
1085
1313
  /** DSH GenerateOptions parts → the Pi request context a provider.stream expects. */
1086
- function dshRequestToPiContext(options) {
1314
+ async function dshRequestToPiContext(options, attachments) {
1087
1315
  const piMessages = [];
1316
+ const toolNamesById = /* @__PURE__ */ new Map();
1317
+ for (const message of Array.isArray(options.messages) ? options.messages : []) {
1318
+ if (message.role !== "assistant") continue;
1319
+ for (const block of Array.isArray(message.content) ? message.content : []) if (block.type === "tool-call" && typeof block.id === "string" && typeof block.name === "string") toolNamesById.set(block.id, block.name);
1320
+ }
1088
1321
  for (const message of Array.isArray(options.messages) ? options.messages : []) {
1089
1322
  const content = Array.isArray(message.content) ? message.content : [];
1090
1323
  const source = message.source;
1091
1324
  if (message.role === "user" && source?.kind === "tool") {
1092
1325
  const result = content[0];
1326
+ const toolCallId = String(result?.toolCallId ?? "");
1327
+ const toolName = toolNamesById.get(toolCallId);
1093
1328
  piMessages.push({
1094
1329
  role: "toolResult",
1095
- toolCallId: String(result?.toolCallId ?? ""),
1096
- content: piContentFromDshBlocks(Array.isArray(result?.content) ? result.content : []),
1330
+ toolCallId,
1331
+ ...toolName === void 0 ? {} : { toolName },
1332
+ content: await piContentFromDshBlocks(Array.isArray(result?.content) ? result.content : [], attachments),
1097
1333
  isError: result?.isError === true
1098
1334
  });
1099
1335
  } else if (message.role === "user") piMessages.push({
1100
1336
  role: "user",
1101
- content: piContentFromDshBlocks(content)
1337
+ content: await piContentFromDshBlocks(content, attachments)
1102
1338
  });
1103
1339
  else if (message.role === "assistant") piMessages.push({
1104
1340
  role: "assistant",
1105
- content: piContentFromDshBlocks(content)
1341
+ content: await piContentFromDshBlocks(content, attachments)
1106
1342
  });
1107
1343
  }
1108
1344
  return {
@@ -1111,7 +1347,12 @@ function dshRequestToPiContext(options) {
1111
1347
  ...Array.isArray(options.tools) ? { tools: options.tools } : {}
1112
1348
  };
1113
1349
  }
1114
- function piContentFromDshBlocks(blocks) {
1350
+ /** Whether any block in this content — including inside a tool result — is an image. */
1351
+ function blocksContainImage(blocks) {
1352
+ if (!Array.isArray(blocks)) return false;
1353
+ return blocks.some((block) => block?.type === "image" || block?.type === "tool-result" && blocksContainImage(block.content));
1354
+ }
1355
+ async function piContentFromDshBlocks(blocks, attachments) {
1115
1356
  const out = [];
1116
1357
  for (const block of blocks) if (block.type === "text") out.push({
1117
1358
  type: "text",
@@ -1134,15 +1375,187 @@ function piContentFromDshBlocks(blocks) {
1134
1375
  name: String(block.name ?? ""),
1135
1376
  arguments: parsed
1136
1377
  });
1378
+ } else if (block.type === "image") {
1379
+ if (attachments === void 0) throw new LlmError("pi2dsh: image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1380
+ const stored = await attachments.readImage(block.attachment);
1381
+ out.push({
1382
+ type: "image",
1383
+ data: Buffer.from(stored.data).toString("base64"),
1384
+ mimeType: String(stored.ref?.mediaType ?? block.attachment?.mediaType ?? "image/png")
1385
+ });
1137
1386
  }
1138
1387
  return out;
1139
1388
  }
1389
+ /**
1390
+ * Classify a failure into the code DSH keys retries and error surfaces off.
1391
+ *
1392
+ * Mirrored from `classifyPiAiError` in DSH's own pi-ai adapter
1393
+ * (`packages/llm/llm-pi-ai/src/stream.ts`). That symbol is private to a
1394
+ * package whose published tarball ships only `lib/`, so it cannot be imported
1395
+ * — but it must not be diverged from either. `RATE_LIMIT`, `SERVER`,
1396
+ * `TIMEOUT` and `TRANSPORT` are four of the five codes in DSH's default
1397
+ * retryable set, so a code invented here would make every rate limit, every
1398
+ * dropped socket and every timeout a Pi provider ever reports permanently
1399
+ * non-retryable — dsh-llm-retry would simply not recognize it. The quota and
1400
+ * context-window predicates it defers to are DSH's own, imported above.
1401
+ * @param message - the provider's failure text.
1402
+ * @returns the harness failure code.
1403
+ */
1404
+ function classifyPiFailure(message) {
1405
+ if (/\b(?:401|403)\b/.test(message)) return "AUTH";
1406
+ if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE;
1407
+ if (/\b429\b|rate.?limit/i.test(message)) return "RATE_LIMIT";
1408
+ if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST";
1409
+ if (/\b5\d\d\b/.test(message)) return "SERVER";
1410
+ if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return "TIMEOUT";
1411
+ if (/stream ended (?:before|without)\b/i.test(message)) return "TRANSPORT";
1412
+ if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message) || /\bterminated\b|premature close/i.test(message)) return "TRANSPORT";
1413
+ return "PI_AI_ERROR";
1414
+ }
1415
+ /**
1416
+ * Pi usage counts → DSH's TokenUsage, mirroring the official adapter's
1417
+ * `mapUsage`: Pi reports zeros rather than absence, so the cache fields appear
1418
+ * only when non-zero.
1419
+ * @param usage - the usage record on a terminal Pi event, if the package sent one.
1420
+ * @returns the harness counts.
1421
+ */
1422
+ function piUsageToDsh(usage) {
1423
+ const cacheRead = Number(usage?.cacheRead ?? 0);
1424
+ const cacheWrite = Number(usage?.cacheWrite ?? 0);
1425
+ return {
1426
+ inputTokens: Number(usage?.input ?? 0),
1427
+ outputTokens: Number(usage?.output ?? 0),
1428
+ ...cacheRead > 0 ? { cacheReadTokens: cacheRead } : {},
1429
+ ...cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}
1430
+ };
1431
+ }
1432
+ /**
1433
+ * The assistant message Pi's terminal `error` event carries.
1434
+ *
1435
+ * Pi delivers failures in-stream as `{ type: 'error', reason, error }` where
1436
+ * `error` is an **AssistantMessage**, not an Error — its text lives on
1437
+ * `errorMessage`. A package that hands back a bare Error instead is met
1438
+ * halfway rather than stringified into "[object Object]".
1439
+ * @param error - the event's payload.
1440
+ * @param fallbackStopReason - the event's own `reason`, used when the payload states none.
1441
+ * @returns a message shape the finish-reason mapping can read.
1442
+ */
1443
+ function piErrorPayload(error, fallbackStopReason) {
1444
+ if (error instanceof Error) return {
1445
+ stopReason: fallbackStopReason,
1446
+ errorMessage: error.message
1447
+ };
1448
+ if (typeof error === "object" && error !== null) {
1449
+ const record = error;
1450
+ if (typeof record.stopReason === "string" || typeof record.errorMessage === "string") return {
1451
+ stopReason: fallbackStopReason,
1452
+ ...record
1453
+ };
1454
+ return {
1455
+ stopReason: fallbackStopReason,
1456
+ errorMessage: JSON.stringify(error)
1457
+ };
1458
+ }
1459
+ return {
1460
+ stopReason: fallbackStopReason,
1461
+ errorMessage: String(error ?? "pi2dsh: the package reported a stream failure with no detail")
1462
+ };
1463
+ }
1464
+ /**
1465
+ * A terminal Pi assistant message → DSH's finish reason, mirroring the
1466
+ * official adapter's `mapStopReason`, including its context-overflow and
1467
+ * empty-response detection (the overflow predicate is Pi's own, vendored).
1468
+ *
1469
+ * One documented deviation: DSH's adapter can trust pi-ai to populate
1470
+ * `content`, and reads an empty array as a degenerate empty completion. This
1471
+ * bridge takes its events from an arbitrary third-party package, so it also
1472
+ * accepts the blocks it actually saw stream past as proof of content. That
1473
+ * only ever narrows the error — a stream that produced output is never
1474
+ * mislabelled EMPTY_RESPONSE because its terminal event omitted a field.
1475
+ * @param message - the assistant message on the `done` or `error` event.
1476
+ * @param contextWindow - the model's resolved capacity, for usage-based overflow detection.
1477
+ * @param sawContent - whether any content block streamed before the terminal event.
1478
+ * @returns the harness finish reason.
1479
+ */
1480
+ function piFinishReason(message, contextWindow, sawContent) {
1481
+ const stopReason = String(message?.stopReason ?? "stop");
1482
+ const errorMessage = typeof message?.errorMessage === "string" ? message.errorMessage : void 0;
1483
+ const model = String(message?.model ?? "unknown");
1484
+ const usage = message?.usage;
1485
+ const probe = {
1486
+ stopReason,
1487
+ errorMessage,
1488
+ usage: {
1489
+ input: Number(usage?.input ?? 0),
1490
+ output: Number(usage?.output ?? 0),
1491
+ cacheRead: Number(usage?.cacheRead ?? 0),
1492
+ cacheWrite: Number(usage?.cacheWrite ?? 0)
1493
+ }
1494
+ };
1495
+ if (isContextOverflow(probe, contextWindow) || stopReason === "error" && errorMessage !== void 0 && isContextWindowExceededError(errorMessage)) return {
1496
+ kind: "error",
1497
+ failure: {
1498
+ message: errorMessage ?? `pi2dsh: the package's provider reported context overflow for model "${model}"`,
1499
+ code: CONTEXT_WINDOW_EXCEEDED_CODE
1500
+ }
1501
+ };
1502
+ switch (stopReason) {
1503
+ case "length": return { kind: "max-tokens" };
1504
+ case "toolUse": return { kind: "tool-calls" };
1505
+ case "aborted": return {
1506
+ kind: "aborted",
1507
+ failure: {
1508
+ message: errorMessage ?? "pi2dsh: the package aborted the stream",
1509
+ code: "ABORTED"
1510
+ }
1511
+ };
1512
+ case "error": {
1513
+ const text = errorMessage ?? "pi2dsh: the package reported a stream failure";
1514
+ return {
1515
+ kind: "error",
1516
+ failure: {
1517
+ message: text,
1518
+ code: classifyPiFailure(text)
1519
+ }
1520
+ };
1521
+ }
1522
+ default:
1523
+ if ((Array.isArray(message?.content) ? message.content : []).length === 0 && !sawContent) return {
1524
+ kind: "error",
1525
+ failure: {
1526
+ message: `model "${model}" returned a completed response with no content`,
1527
+ code: EMPTY_RESPONSE_CODE
1528
+ }
1529
+ };
1530
+ return { kind: "stop" };
1531
+ }
1532
+ }
1533
+ /**
1534
+ * The id and name of the tool call an event is opening or extending. Pi states
1535
+ * them on the partial message's content at the event's index, never on the
1536
+ * event itself.
1537
+ * @param event - a `toolcall_start` or `toolcall_delta` event.
1538
+ * @param index - the event's content index.
1539
+ * @returns the call's identity, or undefined when the partial states none.
1540
+ */
1541
+ function partialToolCall(event, index) {
1542
+ const partial = event.partial;
1543
+ const block = (Array.isArray(partial?.content) ? partial.content : [])[index];
1544
+ if (block === void 0 || block.type !== "toolCall") return void 0;
1545
+ const name = typeof block.name === "string" && block.name.length > 0 ? block.name : void 0;
1546
+ return {
1547
+ id: typeof block.id === "string" ? block.id : "",
1548
+ ...name === void 0 ? {} : { name }
1549
+ };
1550
+ }
1140
1551
  /** A Pi AssistantMessageEventStream → DSH StreamChunk sequence. */
1141
- async function* piEventsToDshChunks(events) {
1142
- let usage;
1552
+ async function* piEventsToDshChunks(events, contextWindow) {
1553
+ const openToolCalls = /* @__PURE__ */ new Map();
1554
+ let sawContent = false;
1143
1555
  for await (const event of events) {
1144
1556
  const type = String(event.type ?? "");
1145
1557
  const index = Number(event.contentIndex ?? 0);
1558
+ if (type === "text_start" || type === "thinking_start" || type === "toolcall_start") sawContent = true;
1146
1559
  if (type === "text_start") yield {
1147
1560
  type: "block-start",
1148
1561
  index,
@@ -1179,18 +1592,24 @@ async function* piEventsToDshChunks(events) {
1179
1592
  text: String(event.content ?? "")
1180
1593
  }
1181
1594
  };
1182
- else if (type === "toolcall_start") yield {
1183
- type: "block-start",
1184
- index,
1185
- blockType: "tool-call"
1186
- };
1187
- else if (type === "toolcall_delta") yield {
1188
- type: "tool-call-delta",
1189
- index,
1190
- id: "",
1191
- argumentsDelta: String(event.delta ?? "")
1192
- };
1193
- else if (type === "toolcall_end") {
1595
+ else if (type === "toolcall_start") {
1596
+ const opened = partialToolCall(event, index);
1597
+ if (opened !== void 0) openToolCalls.set(index, opened);
1598
+ yield {
1599
+ type: "block-start",
1600
+ index,
1601
+ blockType: "tool-call"
1602
+ };
1603
+ } else if (type === "toolcall_delta") {
1604
+ const open = openToolCalls.get(index) ?? partialToolCall(event, index);
1605
+ yield {
1606
+ type: "tool-call-delta",
1607
+ index,
1608
+ id: open?.id ?? "",
1609
+ ...open?.name === void 0 ? {} : { name: open.name },
1610
+ argumentsDelta: String(event.delta ?? "")
1611
+ };
1612
+ } else if (type === "toolcall_end") {
1194
1613
  const call = event.toolCall;
1195
1614
  yield {
1196
1615
  type: "block-end",
@@ -1204,61 +1623,34 @@ async function* piEventsToDshChunks(events) {
1204
1623
  };
1205
1624
  } else if (type === "done") {
1206
1625
  const message = event.message;
1207
- const piUsage = message?.usage;
1208
- if (piUsage !== void 0) {
1209
- usage = {
1210
- inputTokens: Number(piUsage.input ?? 0),
1211
- outputTokens: Number(piUsage.output ?? 0),
1212
- ...piUsage.cacheRead === void 0 ? {} : { cacheReadTokens: Number(piUsage.cacheRead) },
1213
- ...piUsage.cacheWrite === void 0 ? {} : { cacheWriteTokens: Number(piUsage.cacheWrite) }
1214
- };
1215
- yield {
1216
- type: "usage",
1217
- usage
1218
- };
1219
- }
1220
- const stop = String(message?.stopReason ?? "stop");
1626
+ yield {
1627
+ type: "usage",
1628
+ usage: piUsageToDsh(message?.usage)
1629
+ };
1221
1630
  yield {
1222
1631
  type: "finish",
1223
- reason: stop === "length" ? { kind: "max-tokens" } : stop === "toolUse" ? { kind: "tool-calls" } : stop === "aborted" ? {
1224
- kind: "aborted",
1225
- failure: {
1226
- message: "aborted by caller",
1227
- code: "ABORTED"
1228
- }
1229
- } : { kind: "stop" }
1632
+ reason: piFinishReason(message, contextWindow, sawContent)
1230
1633
  };
1231
1634
  return;
1232
1635
  } else if (type === "error") {
1233
- const error = event.error;
1636
+ const message = piErrorPayload(event.error, String(event.reason ?? "error"));
1637
+ yield {
1638
+ type: "usage",
1639
+ usage: piUsageToDsh(message.usage)
1640
+ };
1234
1641
  yield {
1235
1642
  type: "finish",
1236
- reason: {
1237
- kind: String(event.reason ?? "") === "aborted" ? "aborted" : "error",
1238
- failure: {
1239
- message: error instanceof Error ? error.message : String(error ?? "provider stream error"),
1240
- code: "PI_PROVIDER"
1241
- }
1242
- }
1643
+ reason: piFinishReason(message, contextWindow, sawContent)
1243
1644
  };
1244
1645
  return;
1245
1646
  }
1246
1647
  }
1247
- yield {
1248
- type: "finish",
1249
- reason: {
1250
- kind: "error",
1251
- failure: {
1252
- message: "pi2dsh: Pi provider stream ended without done/error",
1253
- code: "PI_PROVIDER"
1254
- }
1255
- }
1256
- };
1648
+ throw new LlmError("pi2dsh: the package's provider stream ended without a done or error event", "STREAM_CLOSED");
1257
1649
  }
1258
1650
  //#endregion
1259
1651
  //#region src/provider-adapter.ts
1260
1652
  function providerCarriesTransport(value) {
1261
- return typeof value?.stream === "function";
1653
+ return typeof value?.streamSimple === "function" || typeof value?.stream === "function";
1262
1654
  }
1263
1655
  function providerModels(provider) {
1264
1656
  try {
@@ -1280,6 +1672,19 @@ const PI_CARRIED_FIELDS = [
1280
1672
  "headers"
1281
1673
  ];
1282
1674
  /**
1675
+ * Model capabilities DSH asks about by its own names. Shared by the catalog
1676
+ * listing and the exact-route resolve deliberately: a capability present in
1677
+ * one and absent from the other is how a model silently loses an ability —
1678
+ * the host consults the resolve before a request, so a modality declared only
1679
+ * in the listing reads as "text only" at the moment it matters.
1680
+ * @param model - the Pi model descriptor as its package declared it.
1681
+ */
1682
+ function capabilityProjection(model) {
1683
+ if (!Array.isArray(model.input)) return {};
1684
+ const modalities = model.input.filter((entry) => entry === "text" || entry === "image");
1685
+ return modalities.length === 0 ? {} : { inputModalities: modalities };
1686
+ }
1687
+ /**
1283
1688
  * Pi's reasoning capability, in DSH's shape.
1284
1689
  *
1285
1690
  * The two sides say the same thing differently: Pi carries a `reasoning`
@@ -1316,6 +1721,12 @@ function piCarriedFields(model) {
1316
1721
  * Structural typing carries it through `llm.registerAdapter`.
1317
1722
  */
1318
1723
  function piProviderDshAdapter(providerId, provider, host) {
1724
+ let warnedStop = false;
1725
+ const warnStopUnsupported = () => {
1726
+ if (warnedStop) return;
1727
+ warnedStop = true;
1728
+ host.warn(`[pi2dsh] a request to Pi provider ${JSON.stringify(providerId)} carries stop sequences, which Pi models at no layer — they cannot be forwarded, so the model will not halt on them`);
1729
+ };
1319
1730
  return {
1320
1731
  providerInfo: (id) => ({
1321
1732
  id,
@@ -1324,15 +1735,16 @@ function piProviderDshAdapter(providerId, provider, host) {
1324
1735
  providerRetryPolicy: () => void 0,
1325
1736
  listModels: async (id) => providerModels(provider).map((model) => ({
1326
1737
  ...piCarriedFields(model),
1738
+ ...capabilityProjection(model),
1327
1739
  provider: id,
1328
1740
  id: String(model.id ?? ""),
1329
- name: String(model.name ?? model.id ?? ""),
1330
- ...Array.isArray(model.input) ? { inputModalities: model.input.filter((m) => m === "text" || m === "image") } : {}
1741
+ name: String(model.name ?? model.id ?? "")
1331
1742
  })),
1332
1743
  resolveModel: async (id, modelId) => {
1333
1744
  const known = providerModels(provider).find((model) => model.id === modelId);
1334
1745
  return {
1335
1746
  ...known === void 0 ? {} : piCarriedFields(known),
1747
+ ...known === void 0 ? {} : capabilityProjection(known),
1336
1748
  ...known === void 0 ? {} : reasoningProjection(known),
1337
1749
  provider: id,
1338
1750
  id: modelId,
@@ -1348,7 +1760,14 @@ function piProviderDshAdapter(providerId, provider, host) {
1348
1760
  id: modelId,
1349
1761
  provider: providerId
1350
1762
  };
1351
- const piContext = dshRequestToPiContext(options);
1763
+ const carriesImage = (Array.isArray(options.messages) ? options.messages : []).some((message) => blocksContainImage(message.content));
1764
+ if (carriesImage) {
1765
+ if (!(Array.isArray(model.input) ? model.input : []).includes("image")) throw new LlmError(`pi2dsh: model "${modelId}" does not declare image input`, "UNSUPPORTED_CONTENT");
1766
+ }
1767
+ const attachments = carriesImage ? host.resolveAttachments?.() : void 0;
1768
+ if (carriesImage && attachments === void 0) throw new LlmError("pi2dsh: image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1769
+ const piContext = await dshRequestToPiContext(options, attachments);
1770
+ const effort = typeof options.reasoningEffort === "string" ? options.reasoningEffort : void 0;
1352
1771
  const piOptions = {
1353
1772
  ...auth?.apiKey === void 0 ? {} : { apiKey: auth.apiKey },
1354
1773
  ...auth?.baseUrl === void 0 ? {} : { baseUrl: auth.baseUrl },
@@ -1356,9 +1775,20 @@ function piProviderDshAdapter(providerId, provider, host) {
1356
1775
  ...options.signal instanceof AbortSignal ? { signal: options.signal } : {},
1357
1776
  ...typeof options.maxTokens === "number" ? { maxTokens: options.maxTokens } : {},
1358
1777
  ...typeof options.temperature === "number" ? { temperature: options.temperature } : {},
1359
- ...typeof options.reasoningEffort === "string" ? { reasoningEffort: options.reasoningEffort } : {}
1778
+ ...typeof options.sessionId === "string" ? { sessionId: options.sessionId } : {},
1779
+ ...effort === void 0 || effort === "off" ? {} : { reasoning: effort }
1360
1780
  };
1361
- yield* piEventsToDshChunks(provider.stream(model, piContext, piOptions));
1781
+ const contextWindow = typeof model.contextWindow === "number" ? model.contextWindow : void 0;
1782
+ if (Array.isArray(options.stop) && options.stop.length > 0) warnStopUnsupported();
1783
+ if (typeof provider.streamSimple === "function") {
1784
+ yield* piEventsToDshChunks(provider.streamSimple(model, piContext, piOptions), contextWindow);
1785
+ return;
1786
+ }
1787
+ const { reasoning, ...rest } = piOptions;
1788
+ yield* piEventsToDshChunks(provider.stream(model, piContext, reasoning === void 0 ? rest : {
1789
+ ...rest,
1790
+ reasoningEffort: reasoning
1791
+ }), contextWindow);
1362
1792
  }
1363
1793
  };
1364
1794
  }
@@ -1772,11 +2202,12 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
1772
2202
  const notices = [];
1773
2203
  const userQuestions = optionalService(ctx, "userQuestions");
1774
2204
  const ui = {
1775
- notify(message) {
2205
+ notify(message, type) {
1776
2206
  const text = String(message);
1777
2207
  notices.push(text);
1778
2208
  state.notifications.push(text);
1779
- logger(ctx).info(`[pi2dsh] ${text}`);
2209
+ const level = type === "warning" || type === "error" ? "warn" : "info";
2210
+ logger(ctx)[level](`[pi2dsh] ${text}`);
1780
2211
  },
1781
2212
  select: (title, options) => askOne(ctx, agent, signal, {
1782
2213
  id: "pi2dsh-select",
@@ -1989,9 +2420,18 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
1989
2420
  };
1990
2421
  const contextCwd = typeof (sessionOverride?.header)?.cwd === "string" ? sessionOverride.header.cwd : cwdOf(agent);
1991
2422
  const base = {
2423
+ ...sessionOverride === void 0 ? {} : {
2424
+ sendMessage: (message, options = {}) => sendPiMessage(ctx, state, message.content, deliveryMode(options), sessionOverride, typeof message.customType === "string" ? message.customType : void 0),
2425
+ sendUserMessage: (content) => sendPiMessage(ctx, state, content, "followup", sessionOverride),
2426
+ appendEntry: (customType, data) => {
2427
+ state.bridge.appendCustomEntry(String(sessionOverride.id ?? ""), customType, data);
2428
+ }
2429
+ },
1992
2430
  ui,
1993
2431
  mode: "rpc",
1994
- hasUI: userQuestions !== void 0,
2432
+ get hasUI() {
2433
+ return humanAnswererAvailable(userQuestions, agent);
2434
+ },
1995
2435
  cwd: contextCwd,
1996
2436
  sessionManager: session === void 0 ? state.bridge.readonlySessionManager({
1997
2437
  id: "pi2dsh-detached",
@@ -1999,7 +2439,7 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
1999
2439
  }, contextCwd) : state.bridge.readonlySessionManager(session, contextCwd),
2000
2440
  modelRegistry,
2001
2441
  model: agent === void 0 ? void 0 : currentPiModel(state, agent),
2002
- scopedModels: catalog?.all() ?? [],
2442
+ scopedModels: [],
2003
2443
  thinkingLevel: thinkingLevelOf(state, agent),
2004
2444
  isIdle: () => command,
2005
2445
  isProjectTrusted: () => false,
@@ -2012,7 +2452,7 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
2012
2452
  reason: "pi2dsh: aborted by migrated Pi extension"
2013
2453
  });
2014
2454
  },
2015
- hasPendingMessages: () => false,
2455
+ hasPendingMessages: () => agent?.inbox?.hasPending === true,
2016
2456
  shutdown: () => {
2017
2457
  capabilityLedgerOf(ctx, state).reportHostDecision({
2018
2458
  capability: "ctx.shutdown",
@@ -2214,39 +2654,93 @@ async function dispatch(state, eventName, event, eventContext) {
2214
2654
  for (const handler of state.handlers.get(eventName) ?? []) results.push(await state.agentScope.run(agent, () => handler(event, eventContext)));
2215
2655
  return results;
2216
2656
  }
2217
- function dshToPiContent(content) {
2218
- return content.map((block) => {
2219
- if (block.type === "text") return {
2220
- type: "text",
2221
- text: block.text
2222
- };
2223
- if (block.type === "reasoning") return {
2224
- type: "thinking",
2225
- thinking: block.text
2226
- };
2227
- if (block.type === "tool-call") return {
2228
- type: "toolCall",
2229
- id: block.id,
2230
- name: block.name,
2231
- arguments: block.arguments
2232
- };
2233
- return { type: block.type };
2657
+ /**
2658
+ * What triggered a compaction, in Pi's vocabulary.
2659
+ *
2660
+ * DSH's durable lifecycle events say two things about the trigger: a manual
2661
+ * compaction runs with no open turn (`turn: null`), and one a command drove
2662
+ * cites that command. Both are Pi's "manual".
2663
+ *
2664
+ * The limit, stated rather than papered over: DSH's automatic trigger is
2665
+ * `'pressure' | 'context-overflow'` at the call site but is NOT written to the
2666
+ * log, so Pi's `threshold` and `overflow` cannot be told apart after the fact.
2667
+ * Automatic compactions report `threshold`, the far more common of the two —
2668
+ * and `willRetry` stays false for the same reason. A package keying behavior
2669
+ * on `overflow` specifically will not see it.
2670
+ * @param data - the `compaction/start` or `compaction/summary` event data.
2671
+ */
2672
+ function compactionReason(data) {
2673
+ if (data.sourceCommandId !== void 0) return "manual";
2674
+ return data.turn === null ? "manual" : "threshold";
2675
+ }
2676
+ /**
2677
+ * DSH content → the Pi content blocks packages read.
2678
+ *
2679
+ * Asynchronous because Pi's image block carries the bytes inline while DSH's
2680
+ * carries only an attachment reference, and the bytes come from the attachment
2681
+ * service. The sync version this replaced projected an image as a bare
2682
+ * `{type:'image'}` — a block that announces an image and contains none, which
2683
+ * is the exact shape a package cannot tell from a real one.
2684
+ * @param ctx - context used to reach the attachment service.
2685
+ * @param content - the DSH blocks to project.
2686
+ */
2687
+ async function dshToPiContent(ctx, content) {
2688
+ const out = [];
2689
+ for (const block of content) if (block.type === "text") out.push({
2690
+ type: "text",
2691
+ text: block.text
2692
+ });
2693
+ else if (block.type === "reasoning") out.push({
2694
+ type: "thinking",
2695
+ thinking: block.text
2696
+ });
2697
+ else if (block.type === "tool-call") out.push({
2698
+ type: "toolCall",
2699
+ id: block.id,
2700
+ name: block.name,
2701
+ arguments: block.arguments
2234
2702
  });
2703
+ else if (block.type === "image") {
2704
+ const image = await piImageBlock(ctx, block.attachment);
2705
+ if (image !== void 0) out.push(image);
2706
+ } else out.push({ type: block.type });
2707
+ return out;
2708
+ }
2709
+ /**
2710
+ * One DSH image attachment as Pi's inline image block.
2711
+ * @param ctx - context used to reach the attachment service.
2712
+ * @param attachment - the durable reference on an image block.
2713
+ * @returns the Pi block, or undefined when the bytes cannot be read.
2714
+ */
2715
+ async function piImageBlock(ctx, attachment) {
2716
+ if (attachment === void 0 || attachment === null) return void 0;
2717
+ const attachments = optionalService(ctx, "attachments");
2718
+ if (attachments === void 0) return void 0;
2719
+ try {
2720
+ const stored = await attachments.readImage(attachment);
2721
+ return {
2722
+ type: "image",
2723
+ data: Buffer.from(stored.data).toString("base64"),
2724
+ mimeType: String(attachment.mediaType ?? "image/png")
2725
+ };
2726
+ } catch {
2727
+ return;
2728
+ }
2235
2729
  }
2236
- function messageFromSessionEvent(event) {
2730
+ async function messageFromSessionEvent(ctx, event) {
2237
2731
  const type = event.type;
2238
2732
  const data = event.data;
2239
2733
  if (typeof data !== "object" || data === null) return void 0;
2240
2734
  const record = data;
2241
2735
  if (type === "user/message") return {
2242
2736
  role: "user",
2243
- content: dshToPiContent(record.content ?? [])
2737
+ content: await dshToPiContent(ctx, record.content ?? [])
2244
2738
  };
2245
2739
  if (type === "assistant/message") {
2246
2740
  const message = record.message;
2247
2741
  return {
2248
2742
  role: "assistant",
2249
- content: dshToPiContent(message?.content ?? [])
2743
+ content: await dshToPiContent(ctx, message?.content ?? [])
2250
2744
  };
2251
2745
  }
2252
2746
  if (type === "tool/result") {
@@ -2254,7 +2748,7 @@ function messageFromSessionEvent(event) {
2254
2748
  return {
2255
2749
  role: "toolResult",
2256
2750
  toolCallId: tool?.toolCallId,
2257
- content: dshToPiContent(tool?.content ?? []),
2751
+ content: await dshToPiContent(ctx, tool?.content ?? []),
2258
2752
  isError: tool?.isError === true
2259
2753
  };
2260
2754
  }
@@ -2301,13 +2795,42 @@ function subscribeLifecycle(ctx, state) {
2301
2795
  const eventContext = contextFor(ctx, state, agent, void 0);
2302
2796
  const type = event.type;
2303
2797
  if (type === "turn/start") {
2304
- const turn = Number(event.data.turn ?? 1);
2798
+ if (session !== void 0) state.piTurnIndex.set(session, 0);
2305
2799
  dispatch(state, "agent_start", { type: "agent_start" }, eventContext).catch((error) => warn("agent_start", error));
2306
- dispatch(state, "turn_start", {
2307
- type: "turn_start",
2308
- turnIndex: turn - 1,
2309
- timestamp: event.time ?? Date.now()
2310
- }, eventContext).catch((error) => warn("turn_start", error));
2800
+ }
2801
+ if (type === "step/start") dispatch(state, "turn_start", {
2802
+ type: "turn_start",
2803
+ turnIndex: state.piTurnIndex.get(session) ?? 0,
2804
+ timestamp: event.time ?? Date.now()
2805
+ }, eventContext).catch((error) => warn("turn_start", error));
2806
+ if (type === "step/end") {
2807
+ const data = event.data;
2808
+ const turn = Number(data.turn ?? 1);
2809
+ const step = Number(data.step ?? 1);
2810
+ const key = session;
2811
+ const index = state.piTurnIndex.get(key) ?? 0;
2812
+ state.piTurnIndex.set(key, index + 1);
2813
+ state.projection = state.projection.then(async () => {
2814
+ const stepEvents = (session.events ?? []).filter((entry) => {
2815
+ const entryData = entry.data;
2816
+ return Number(entryData?.turn ?? -1) === turn && Number(entryData?.step ?? -1) === step;
2817
+ });
2818
+ const toolResults = (await Promise.all(stepEvents.filter((entry) => entry.type === "tool/result").map((entry) => messageFromSessionEvent(ctx, entry)))).filter((message) => message !== void 0);
2819
+ const assistant = stepEvents.findLast((entry) => entry.type === "assistant/message");
2820
+ const message = assistant === void 0 ? {
2821
+ role: "assistant",
2822
+ content: []
2823
+ } : await messageFromSessionEvent(ctx, assistant) ?? {
2824
+ role: "assistant",
2825
+ content: []
2826
+ };
2827
+ await dispatch(state, "turn_end", {
2828
+ type: "turn_end",
2829
+ turnIndex: index,
2830
+ message,
2831
+ toolResults
2832
+ }, eventContext);
2833
+ }).catch((error) => warn("turn_end", error));
2311
2834
  }
2312
2835
  if (type === "tool/call") {
2313
2836
  const data = event.data;
@@ -2358,21 +2881,26 @@ function subscribeLifecycle(ctx, state) {
2358
2881
  type: "session_before_compact",
2359
2882
  preparation: { ...event.data },
2360
2883
  branchEntries: [],
2361
- reason: "threshold",
2884
+ reason: compactionReason(event.data),
2362
2885
  willRetry: false
2363
2886
  }, eventContext).catch((error) => warn("session_before_compact", error));
2364
- if (type === "compaction/end" || type === "compaction/summary") {
2887
+ if (type === "compaction/summary") {
2365
2888
  const data = event.data;
2889
+ const shadowed = data.shadowedRange;
2890
+ const usage = data.usage;
2366
2891
  dispatch(state, "session_compact", {
2367
2892
  type: "session_compact",
2368
2893
  compactionEntry: {
2369
2894
  type: "compaction",
2370
2895
  id: `dsh-${String(event.seq ?? "")}`,
2371
- summary: typeof data.summary === "string" ? data.summary : "",
2372
- ...data
2896
+ summary: textBlocks(data.summary).map((block) => block.text).join("\n"),
2897
+ firstKeptEntryId: typeof shadowed?.end === "number" ? `dsh-${shadowed.end + 1}` : "",
2898
+ tokensBefore: Number(data.shadowedTokenCount ?? 0),
2899
+ ...usage === void 0 ? {} : { usage },
2900
+ fromHook: false
2373
2901
  },
2374
2902
  fromExtension: false,
2375
- reason: "threshold",
2903
+ reason: compactionReason(data),
2376
2904
  willRetry: false
2377
2905
  }, eventContext).catch((error) => warn("session_compact", error));
2378
2906
  }
@@ -2390,29 +2918,25 @@ function subscribeLifecycle(ctx, state) {
2390
2918
  state.lastLoggedModels.set(agent, String(model));
2391
2919
  }
2392
2920
  }
2393
- const message = messageFromSessionEvent(event);
2394
- if (message !== void 0) dispatch(state, "message_start", {
2395
- type: "message_start",
2396
- message
2397
- }, eventContext).then(() => dispatch(state, "message_end", {
2398
- type: "message_end",
2399
- message
2400
- }, eventContext)).catch((error) => warn("message lifecycle", error));
2401
- if (type === "turn/end") {
2402
- const data = event.data;
2403
- dispatch(state, "turn_end", {
2404
- type: "turn_end",
2405
- turnIndex: Number(data.turn ?? 1) - 1,
2406
- message: {
2407
- role: "assistant",
2408
- content: []
2409
- },
2410
- toolResults: []
2411
- }, eventContext).then(() => dispatch(state, "agent_end", {
2921
+ state.projection = state.projection.then(async () => {
2922
+ const message = await messageFromSessionEvent(ctx, event);
2923
+ if (message === void 0) return;
2924
+ await dispatch(state, "message_start", {
2925
+ type: "message_start",
2926
+ message
2927
+ }, eventContext);
2928
+ await dispatch(state, "message_end", {
2929
+ type: "message_end",
2930
+ message
2931
+ }, eventContext);
2932
+ }).catch((error) => warn("message lifecycle", error));
2933
+ if (type === "turn/end") state.projection = state.projection.then(async () => {
2934
+ await dispatch(state, "agent_end", {
2412
2935
  type: "agent_end",
2413
2936
  messages: []
2414
- }, eventContext)).then(() => dispatch(state, "agent_settled", { type: "agent_settled" }, eventContext)).catch((error) => warn("turn end lifecycle", error));
2415
- }
2937
+ }, eventContext);
2938
+ await dispatch(state, "agent_settled", { type: "agent_settled" }, eventContext);
2939
+ }).catch((error) => warn("turn end lifecycle", error));
2416
2940
  });
2417
2941
  cordis.on("agent/request", async (payload, next) => {
2418
2942
  const config = await next();
@@ -2428,19 +2952,6 @@ function subscribeLifecycle(ctx, state) {
2428
2952
  ...thinking === void 0 || thinking === "off" ? {} : { reasoningEffort: thinking }
2429
2953
  };
2430
2954
  });
2431
- cordis.on("tools/result", (exec, result) => {
2432
- const agent = exec.agent;
2433
- dispatch(state, "tool_execution_end", {
2434
- type: "tool_execution_end",
2435
- toolCallId: exec.callId,
2436
- toolName: exec.name,
2437
- result: {
2438
- content: dshToPiContent(result.content),
2439
- details: result.meta ?? null
2440
- },
2441
- isError: result.isError
2442
- }, contextFor(ctx, state, agent, exec.signal)).catch((error) => warn("tool_execution_end", error));
2443
- });
2444
2955
  cordis.effect(() => async () => {
2445
2956
  for (const agent of state.activeAgents) if (typeof agent === "object" && agent !== null && !state.disposedAgents.has(agent)) {
2446
2957
  state.disposedAgents.add(agent);
@@ -2458,6 +2969,7 @@ function subscribeLifecycle(ctx, state) {
2458
2969
  function subscribeInterceptors(ctx, state) {
2459
2970
  const cordis = ctx;
2460
2971
  cordis.on("tools/pre-execute", async (exec, next) => {
2972
+ if (isSubagentOrigin(exec.agent)) return next();
2461
2973
  const input = cloneJson(exec.arguments);
2462
2974
  const event = {
2463
2975
  type: "tool_call",
@@ -2473,21 +2985,47 @@ function subscribeInterceptors(ctx, state) {
2473
2985
  reason: `pi2dsh: a Pi tool_call hook mutated arguments of native DSH tool ${JSON.stringify(exec.name)}; DSH logs arguments before policy, so this mutation cannot be honored`
2474
2986
  };
2475
2987
  }
2476
- for (const result of results) if (typeof result === "object" && result !== null && result.block === true) return {
2477
- kind: "deny",
2478
- reason: String(result.reason ?? "blocked by migrated Pi tool_call hook")
2988
+ const agent = exec.agent;
2989
+ const tally = agent === void 0 ? void 0 : state.terminateBatch.get(agent) ?? {
2990
+ calls: 0,
2991
+ terminating: 0
2479
2992
  };
2993
+ if (tally !== void 0 && agent !== void 0) {
2994
+ tally.calls += 1;
2995
+ state.terminateBatch.set(agent, tally);
2996
+ }
2997
+ for (const result of results) {
2998
+ if (typeof result !== "object" || result === null) continue;
2999
+ const record = result;
3000
+ if (record.block !== true) continue;
3001
+ if (record.terminate === true && tally !== void 0) tally.terminating += 1;
3002
+ return {
3003
+ kind: "deny",
3004
+ reason: String(record.reason ?? "blocked by migrated Pi tool_call hook")
3005
+ };
3006
+ }
2480
3007
  return next();
2481
3008
  });
2482
3009
  cordis.on("tools/post-execute", async (exec, result, next) => {
3010
+ if (isSubagentOrigin(exec.agent)) return next();
2483
3011
  const downstream = await next();
3012
+ await dispatch(state, "tool_execution_end", {
3013
+ type: "tool_execution_end",
3014
+ toolCallId: exec.callId,
3015
+ toolName: exec.name,
3016
+ result: {
3017
+ content: await dshToPiContent(ctx, result.content),
3018
+ details: result.meta ?? null
3019
+ },
3020
+ isError: result.isError
3021
+ }, contextFor(ctx, state, exec.agent, exec.signal)).catch((error) => logger(ctx).warn(`[pi2dsh] tool_execution_end handler failed: ${String(error)}`));
2484
3022
  if (downstream.kind === "block") return downstream;
2485
3023
  const event = {
2486
3024
  type: "tool_result",
2487
3025
  toolName: exec.name,
2488
3026
  toolCallId: exec.callId,
2489
3027
  input: cloneJson(exec.arguments),
2490
- content: dshToPiContent(result.content),
3028
+ content: await dshToPiContent(ctx, result.content),
2491
3029
  details: result.meta ?? null,
2492
3030
  isError: result.isError,
2493
3031
  usage: void 0
@@ -2503,57 +3041,37 @@ function subscribeInterceptors(ctx, state) {
2503
3041
  feedback: content
2504
3042
  };
2505
3043
  if (event.isError === false && result.isError) logger(ctx).warn("[pi2dsh] a Pi tool_result hook attempted to recover a DSH error; error recovery was ignored");
2506
- if (!jsonEqual(event.content, dshToPiContent(result.content))) return {
3044
+ if (!jsonEqual(event.content, await dshToPiContent(ctx, result.content))) return {
2507
3045
  kind: "accept",
2508
3046
  content
2509
3047
  };
2510
3048
  return downstream;
2511
3049
  });
3050
+ cordis.on("agent/inbox/claimed", (payload) => {
3051
+ const agent = payload.agent;
3052
+ if (agent === void 0 || isSubagentOrigin(agent)) return;
3053
+ const claimed = state.claimedForStep.get(agent) ?? [];
3054
+ claimed.push({
3055
+ message: payload.message,
3056
+ turn: Number(payload.turn ?? 0)
3057
+ });
3058
+ state.claimedForStep.set(agent, claimed);
3059
+ });
2512
3060
  cordis.on("agent/pre-step", async (payload, next) => {
2513
- const decision = await next();
2514
- if (decision.kind !== "enter" || payload.step !== 1) return decision;
2515
- if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return decision;
2516
3061
  const agent = payload.agent;
2517
- if (isSubagentOrigin(agent)) return decision;
3062
+ if (isSubagentOrigin(agent)) return next();
3063
+ const tally = state.terminateBatch.get(agent);
3064
+ state.terminateBatch.delete(agent);
3065
+ if (tally !== void 0 && tally.calls > 0 && tally.calls === tally.terminating) {
3066
+ logger(ctx).info(`[pi2dsh] a Pi tool_call hook terminated the turn: all ${tally.calls} call(s) in the batch were blocked with terminate`);
3067
+ return { kind: "reject" };
3068
+ }
3069
+ const decision = await next();
3070
+ if (decision.kind !== "enter") return decision;
2518
3071
  const signal = payload.signal;
2519
3072
  const messages = decision.messages ?? [];
2520
- const userMessages = messages.filter((message) => (message.source?.kind ?? "user") === "user");
2521
- const prompt = userMessages.flatMap((message) => textBlocks(message.content)).map((block) => block.text).join("\n");
2522
- const images = await collectPiImages(ctx, userMessages);
2523
- state.turnSystemPromptOverrides.delete(agent);
2524
- const event = {
2525
- type: "before_agent_start",
2526
- prompt,
2527
- ...images.length > 0 ? { images } : {},
2528
- systemPrompt: state.currentSystemPrompt,
2529
- systemPromptOptions: {}
2530
- };
2531
- const results = await dispatch(state, "before_agent_start", event, contextFor(ctx, state, agent, signal));
2532
- const injected = [];
2533
- for (const result of results) {
2534
- if (typeof result !== "object" || result === null) continue;
2535
- const record = result;
2536
- if (typeof record.systemPrompt === "string") {
2537
- state.turnSystemPromptOverrides.set(agent, record.systemPrompt);
2538
- event.systemPrompt = record.systemPrompt;
2539
- }
2540
- const message = record.message;
2541
- if (message !== void 0) {
2542
- const content = message.content;
2543
- const blocks = await piToDshContent(ctx, typeof content === "string" ? [{
2544
- type: "text",
2545
- text: content
2546
- }] : content ?? []);
2547
- injected.push(createUserMessage({
2548
- content: blocks,
2549
- source: {
2550
- kind: "plugin",
2551
- plugin: state.messageSource,
2552
- ...typeof message.customType === "string" ? { piCustomType: message.customType } : {}
2553
- }
2554
- }));
2555
- }
2556
- }
3073
+ const injected = state.pendingInjections.get(agent) ?? [];
3074
+ state.pendingInjections.delete(agent);
2557
3075
  const stepMessages = injected.length === 0 ? messages : [...messages, ...injected];
2558
3076
  const transformed = await applyPiContextTransform(ctx, state, agent, signal, stepMessages);
2559
3077
  if (transformed === stepMessages && injected.length === 0) return decision;
@@ -2562,13 +3080,19 @@ function subscribeInterceptors(ctx, state) {
2562
3080
  messages: transformed
2563
3081
  };
2564
3082
  });
3083
+ const systemPrompt = optionalService(ctx, "systemPrompt");
3084
+ if (systemPrompt !== void 0) ctx.effect(() => systemPrompt.section({
3085
+ name: "pi2dsh:tool-guidance",
3086
+ order: 150,
3087
+ text: () => piToolPromptContribution(ctx, state)
3088
+ }));
2565
3089
  cordis.on("system-prompt/assemble", async (assembly, assembleContext, next) => {
2566
3090
  const downstream = await next();
2567
- if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return downstream;
2568
3091
  const original = renderPrompt(downstream);
2569
3092
  state.currentSystemPrompt = original;
2570
3093
  const agent = assembleContext.agent ?? assembleContext.scope;
2571
3094
  if (agent !== void 0 && !isSubagentOrigin(agent)) {
3095
+ await runBeforeAgentStart(ctx, state, agent, assembleContext.signal, original);
2572
3096
  const override = state.turnSystemPromptOverrides.get(agent);
2573
3097
  if (override === void 0) return downstream;
2574
3098
  state.currentSystemPrompt = override;
@@ -2580,6 +3104,7 @@ function subscribeInterceptors(ctx, state) {
2580
3104
  }]
2581
3105
  };
2582
3106
  }
3107
+ if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return downstream;
2583
3108
  const event = {
2584
3109
  type: "before_agent_start",
2585
3110
  prompt: "",
@@ -2602,9 +3127,69 @@ function subscribeInterceptors(ctx, state) {
2602
3127
  };
2603
3128
  });
2604
3129
  }
2605
- function piShapeOfPending(message) {
3130
+ /**
3131
+ * Pi's `before_agent_start`, run inside the assembly of the turn it belongs to.
3132
+ *
3133
+ * Fires once per user prompt: only when this step's claim opened a NEW turn,
3134
+ * matching Pi, where the event follows the user's message rather than every
3135
+ * model call. A handler's `systemPrompt` becomes this turn's override (reset
3136
+ * at the next turn, exactly as Pi resets to the base prompt) and a returned
3137
+ * `message` is held for the step that is about to be entered.
3138
+ * @param ctx - context used for attachments and handler dispatch.
3139
+ * @param state - runtime state holding claims, overrides, and injections.
3140
+ * @param agent - the agent whose assembly this is.
3141
+ * @param signal - the turn's control signal, when one exists.
3142
+ * @param assembled - the prompt as assembled, before any package override.
3143
+ */
3144
+ async function runBeforeAgentStart(ctx, state, agent, signal, assembled) {
3145
+ const claimed = state.claimedForStep.get(agent) ?? [];
3146
+ state.claimedForStep.delete(agent);
3147
+ if (claimed.length === 0) return;
3148
+ const turn = Number(claimed[0]?.turn ?? 0);
3149
+ if (state.promptedTurn.get(agent) === turn) return;
3150
+ state.promptedTurn.set(agent, turn);
3151
+ state.turnSystemPromptOverrides.delete(agent);
3152
+ if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return;
3153
+ const userMessages = claimed.map((entry) => entry.message).filter((message) => (message.source?.kind ?? "user") === "user");
3154
+ const prompt = userMessages.flatMap((message) => textBlocks(message.content)).map((block) => block.text).join("\n");
3155
+ const images = await collectPiImages(ctx, userMessages);
3156
+ const event = {
3157
+ type: "before_agent_start",
3158
+ prompt,
3159
+ ...images.length > 0 ? { images } : {},
3160
+ systemPrompt: assembled,
3161
+ systemPromptOptions: {}
3162
+ };
3163
+ const results = await dispatch(state, "before_agent_start", event, contextFor(ctx, state, agent, signal));
3164
+ const injected = [];
3165
+ for (const result of results) {
3166
+ if (typeof result !== "object" || result === null) continue;
3167
+ const record = result;
3168
+ if (typeof record.systemPrompt === "string") {
3169
+ state.turnSystemPromptOverrides.set(agent, record.systemPrompt);
3170
+ event.systemPrompt = record.systemPrompt;
3171
+ }
3172
+ const message = record.message;
3173
+ if (message === void 0) continue;
3174
+ const content = message.content;
3175
+ const blocks = await piToDshContent(ctx, typeof content === "string" ? [{
3176
+ type: "text",
3177
+ text: content
3178
+ }] : content ?? []);
3179
+ injected.push(createUserMessage({
3180
+ content: blocks,
3181
+ source: {
3182
+ kind: "plugin",
3183
+ plugin: state.messageSource,
3184
+ ...typeof message.customType === "string" ? { piCustomType: message.customType } : {}
3185
+ }
3186
+ }));
3187
+ }
3188
+ if (injected.length > 0) state.pendingInjections.set(agent, injected);
3189
+ }
3190
+ async function piShapeOfPending(ctx, message) {
2606
3191
  const source = message.source;
2607
- const content = dshToPiContent(message.content ?? []);
3192
+ const content = await dshToPiContent(ctx, message.content ?? []);
2608
3193
  return typeof source?.piCustomType === "string" ? {
2609
3194
  role: "custom",
2610
3195
  customType: source.piCustomType,
@@ -2619,7 +3204,7 @@ async function applyPiContextTransform(ctx, state, agent, signal, pending) {
2619
3204
  const session = agentSession(agent);
2620
3205
  const history = [];
2621
3206
  for (const event of session?.events ?? []) {
2622
- const projected = messageFromSessionEvent(event);
3207
+ const projected = await messageFromSessionEvent(ctx, event);
2623
3208
  if (projected === void 0) continue;
2624
3209
  if (event.type === "user/message") {
2625
3210
  const customType = (event.data?.source)?.piCustomType;
@@ -2634,7 +3219,7 @@ async function applyPiContextTransform(ctx, state, agent, signal, pending) {
2634
3219
  }
2635
3220
  history.push(projected);
2636
3221
  }
2637
- const projectedPending = pending.map(piShapeOfPending);
3222
+ const projectedPending = await Promise.all(pending.map((message) => piShapeOfPending(ctx, message)));
2638
3223
  const event = {
2639
3224
  type: "context",
2640
3225
  messages: [...history, ...projectedPending]
@@ -2681,7 +3266,7 @@ async function applyPiContextTransform(ctx, state, agent, signal, pending) {
2681
3266
  return rebuilt;
2682
3267
  }
2683
3268
  async function collectPiImages(ctx, messages) {
2684
- const attachments = ctx.get("attachments");
3269
+ const attachments = optionalService(ctx, "attachments");
2685
3270
  if (attachments === void 0) return [];
2686
3271
  const images = [];
2687
3272
  for (const message of messages) {
@@ -2905,7 +3490,13 @@ function registerTool(ctx, state, tool) {
2905
3490
  const mutated = state.argMutations.get(exec);
2906
3491
  if (mutated !== void 0) state.argMutations.delete(exec);
2907
3492
  const effective = mutated ?? args;
2908
- const prepared = live.prepareArguments?.(cloneJson(effective)) ?? effective;
3493
+ const prepared = validateToolArguments({
3494
+ name: live.name,
3495
+ parameters: live.parameters
3496
+ }, {
3497
+ name: live.name,
3498
+ arguments: live.prepareArguments?.(cloneJson(effective)) ?? effective
3499
+ });
2909
3500
  const agent = exec.agent;
2910
3501
  const result = await normalizeToolResultForDsh(ctx, await state.agentScope.run(agent, () => live.execute(String(exec.callId), prepared, exec.signal, (update) => {
2911
3502
  dispatch(state, "tool_execution_update", {
@@ -2943,10 +3534,57 @@ function currentAgent(state) {
2943
3534
  function toolRuntime(ctx, agent) {
2944
3535
  return (agent?.ctx)?.tools ?? ctx.tools;
2945
3536
  }
3537
+ /**
3538
+ * The system-prompt text a package's ACTIVE tools contribute.
3539
+ *
3540
+ * Pi renders `promptSnippet` into its "Available tools" list and
3541
+ * `promptGuidelines` into its "Guidelines" bullets. DSH assembles its prompt
3542
+ * from ordered sections, so both arrive as one section in DSH's tool-guidance
3543
+ * band, headed the way Pi heads them.
3544
+ * @param ctx - context used to read which tools are active.
3545
+ * @param state - runtime state holding the registered Pi tools.
3546
+ * @returns the section text, empty when no active Pi tool contributes any.
3547
+ */
3548
+ function piToolPromptContribution(ctx, state) {
3549
+ const active = new Set(getActiveTools(ctx, state));
3550
+ const snippets = [];
3551
+ const guidelines = [];
3552
+ for (const [name, tool] of state.tools) {
3553
+ if (!active.has(name)) continue;
3554
+ const snippet = tool.promptSnippet;
3555
+ if (typeof snippet === "string" && snippet.trim().length > 0) snippets.push(`- ${snippet.trim()}`);
3556
+ const bullets = tool.promptGuidelines;
3557
+ if (!Array.isArray(bullets)) continue;
3558
+ for (const bullet of bullets) if (typeof bullet === "string" && bullet.trim().length > 0) guidelines.push(`- ${bullet.trim()}`);
3559
+ }
3560
+ const parts = [];
3561
+ if (snippets.length > 0) parts.push(`Available tools:\n${snippets.join("\n")}`);
3562
+ if (guidelines.length > 0) parts.push(`Guidelines:\n${guidelines.join("\n")}`);
3563
+ return parts.join("\n\n");
3564
+ }
2946
3565
  function getActiveTools(ctx, state) {
2947
3566
  const agent = currentAgent(state);
2948
3567
  return toolRuntime(ctx, agent).schemas(agent).map((tool) => tool.name);
2949
3568
  }
3569
+ /**
3570
+ * Pi's `setActiveTools`, on DSH's restriction seam.
3571
+ *
3572
+ * Pi walks the requested names and keeps the ones its registry knows —
3573
+ * **unknown names are silently skipped**. DSH's `restrict` instead FAILS the
3574
+ * whole call on a name it cannot restrict (unknown, scope-local, or a
3575
+ * reserved transport name), so passing Pi's list through verbatim turned a
3576
+ * routine Pi call into a hard error over one stale name.
3577
+ *
3578
+ * So the list is narrowed to what DSH says is restrictable before restricting.
3579
+ * The one case that cannot be both: a tool that is VISIBLE but not
3580
+ * restrictable (a scope's own registration, `run_code` outside native mode)
3581
+ * cannot be switched off at all. Silence there would leave a tool running that
3582
+ * the package believes it disabled, so it is reported once — the package's
3583
+ * call still takes effect for everything else.
3584
+ * @param ctx - context used to reach the tool runtime.
3585
+ * @param state - runtime state holding the per-agent restriction disposers.
3586
+ * @param names - the tool names the package wants active.
3587
+ */
2950
3588
  function setActiveTools(ctx, state, names) {
2951
3589
  const unique = [...new Set(names)];
2952
3590
  const agent = currentAgent(state);
@@ -2957,27 +3595,104 @@ function setActiveTools(ctx, state, names) {
2957
3595
  logger(ctx).warn("[pi2dsh] setActiveTools deferred: the current agent exposes no scoped tools.restrict()");
2958
3596
  return;
2959
3597
  }
3598
+ const restrictable = restrictableToolNames(scopedTools, agent);
3599
+ const allow = restrictable === void 0 ? unique : unique.filter((name) => restrictable.has(name));
3600
+ if (restrictable !== void 0) {
3601
+ const unswitchable = [...new Set(scopedTools.schemas(agent).map((schema) => schema.name))].filter((name) => !restrictable.has(name) && !unique.includes(name));
3602
+ if (unswitchable.length > 0) logger(ctx).warn(`[pi2dsh] setActiveTools could not deactivate ${unswitchable.map((name) => JSON.stringify(name)).join(", ")}: DSH does not allow restricting a scope-registered or reserved tool, so it stays available to the model`);
3603
+ }
2960
3604
  state.toolRestrictions.get(agent)?.();
2961
- state.toolRestrictions.set(agent, scopedTools.restrict({ allow: unique }));
3605
+ state.toolRestrictions.set(agent, allow.length === 0 ? scopedTools.restrict({ deny: [...restrictable ?? /* @__PURE__ */ new Set()] }) : scopedTools.restrict({ allow }));
3606
+ }
3607
+ /**
3608
+ * The tool names DSH will accept in a restriction for this scope.
3609
+ * @param tools - the agent-scoped tool runtime.
3610
+ * @param agent - the scope key.
3611
+ * @returns the restrictable names, or undefined when this runtime exposes no view.
3612
+ */
3613
+ function restrictableToolNames(tools, agent) {
3614
+ const view = tools.view;
3615
+ if (typeof view !== "function") return void 0;
3616
+ try {
3617
+ const names = view.call(tools, agent)?.restrictableNames;
3618
+ return names instanceof Set ? names : void 0;
3619
+ } catch {
3620
+ return;
3621
+ }
2962
3622
  }
2963
3623
  function deliverAgentMessage(agent, message, mode) {
2964
3624
  const deliver = agent[mode];
2965
3625
  if (typeof deliver !== "function") throw new Error(`pi2dsh: active DSH agent has no ${mode}() delivery method`);
2966
3626
  deliver.call(agent, message);
2967
3627
  }
2968
- async function sendPiMessage(ctx, state, content, mode) {
2969
- const agent = requireAgent(state, mode === "inject" ? "sendMessage" : "sendUserMessage");
3628
+ /**
3629
+ * Pi's `sendMessage` / `sendUserMessage`, on DSH.
3630
+ *
3631
+ * Two things this has to get right that a plain `deliverAgentMessage` does not:
3632
+ *
3633
+ * - **Durability.** Pi's no-turn `sendMessage` appends to the session and
3634
+ * emits its message events before it returns: on return the message IS in
3635
+ * the conversation. DSH's inject only queues it in the agent's inbox, where
3636
+ * it becomes a `user/message` when the next step claims it — so a turn
3637
+ * cancelled in between dropped it, and a package that had already reported
3638
+ * success was wrong. The no-turn mode now appends to the durable log
3639
+ * itself, which is what Pi's contract promises.
3640
+ * - **Which session.** Inside a `withSession` callback the context is bound to
3641
+ * the REPLACEMENT session, but the live agent is still the one that started
3642
+ * the operation — so routing through the agent wrote into the OLD session.
3643
+ * An override session is written to directly.
3644
+ * @param ctx - context used for content conversion.
3645
+ * @param state - runtime state (message source, active agent).
3646
+ * @param content - the Pi content to deliver.
3647
+ * @param mode - inject (no turn), steer, or followup.
3648
+ * @param sessionOverride - the replacement session, inside a withSession callback.
3649
+ * @param customType - Pi's role:"custom" marker, when the caller sent one.
3650
+ */
3651
+ async function sendPiMessage(ctx, state, content, mode, sessionOverride, customType) {
2970
3652
  const blocks = await piToDshContent(ctx, typeof content === "string" ? [{
2971
3653
  type: "text",
2972
3654
  text: content
2973
3655
  }] : content);
2974
- deliverAgentMessage(agent, createUserMessage({
3656
+ const message = createUserMessage({
2975
3657
  content: blocks,
2976
3658
  source: {
2977
3659
  kind: "plugin",
2978
- plugin: state.messageSource
3660
+ plugin: state.messageSource,
3661
+ ...customType === void 0 ? {} : { piCustomType: customType }
2979
3662
  }
2980
- }), mode);
3663
+ });
3664
+ if (sessionOverride !== void 0) {
3665
+ appendUserMessage(sessionOverride, message);
3666
+ return;
3667
+ }
3668
+ if (mode === "inject") {
3669
+ const session = agentSession(currentAgent(state));
3670
+ if (session === void 0) throw new Error("pi2dsh: sendMessage requires one active DSH agent with a durable session");
3671
+ appendUserMessage(session, message);
3672
+ return;
3673
+ }
3674
+ deliverAgentMessage(requireAgent(state, "sendUserMessage"), message, mode);
3675
+ }
3676
+ /**
3677
+ * Pi's delivery options → this bridge's mode. No options at all means "into
3678
+ * the conversation, no turn", which is Pi's own default.
3679
+ * @param options - the caller's delivery options.
3680
+ */
3681
+ function deliveryMode(options) {
3682
+ if (options.deliverAs === "steer") return "steer";
3683
+ if (options.deliverAs === "followUp" || options.deliverAs === "nextTurn" || options.triggerTurn === true) return "followup";
3684
+ return "inject";
3685
+ }
3686
+ /**
3687
+ * Append one plugin-sourced user message to a session's durable log, which is
3688
+ * what makes it part of the conversation before the call returns.
3689
+ * @param session - the live DSH session to append to.
3690
+ * @param message - the message, already in DSH shape.
3691
+ */
3692
+ function appendUserMessage(session, message) {
3693
+ const append = session.append;
3694
+ if (typeof append !== "function") throw new Error("pi2dsh: this session cannot be appended to, so the message could not be delivered durably");
3695
+ append.call(session, "user/message", message, { surfaceOp: "append" });
2981
3696
  }
2982
3697
  function combineExecSignal(options) {
2983
3698
  const controller = new AbortController();
@@ -3087,6 +3802,51 @@ function registerCommand(ctx, state, command) {
3087
3802
  }
3088
3803
  logger(ctx).warn(`[pi2dsh] command /${command.name} was not registered: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
3089
3804
  }
3805
+ /**
3806
+ * The session's current name: DSH's title when a title service is mounted,
3807
+ * this bridge's sidecar otherwise (and as the fallback for a session named
3808
+ * before the service was there).
3809
+ * @param ctx - the cordis context to resolve the optional service from.
3810
+ * @param state - runtime state holding the sidecar.
3811
+ * @param session - the live DSH session.
3812
+ */
3813
+ function sessionNameOf(ctx, state, session) {
3814
+ const title = optionalService(ctx, "sessionTitle")?.get(session)?.title;
3815
+ return typeof title === "string" && title.length > 0 ? title : state.bridge.getName(session.id);
3816
+ }
3817
+ /**
3818
+ * Whether a human can actually answer a question right now.
3819
+ *
3820
+ * Two facts decide it, and the old check (`the service is mounted`) saw
3821
+ * neither: a headless composition mounts the service and registers NO
3822
+ * provider, and a delegated child agent has no human answerer at all — both
3823
+ * make every `ctx.ui.select/confirm/input` throw for a package that was told
3824
+ * `hasUI: true` and skipped its non-interactive path.
3825
+ *
3826
+ * The provider is a private field, but its existence is publicly observable:
3827
+ * DSH allows exactly one provider per context and refuses a second with
3828
+ * `DUPLICATE_PROVIDER`. So registering a probe answers the question — a
3829
+ * refusal means a real provider is there, and an acceptance means there was
3830
+ * none, which the immediate disposal restores.
3831
+ * @param userQuestions - the mounted question service, if any.
3832
+ * @param agent - the agent whose turn this context belongs to.
3833
+ */
3834
+ function humanAnswererAvailable(userQuestions, agent) {
3835
+ if (userQuestions === void 0) return false;
3836
+ if (isSubagentOrigin(agent)) return false;
3837
+ const service = userQuestions;
3838
+ if (typeof service.registerProvider !== "function") return false;
3839
+ let dispose;
3840
+ try {
3841
+ dispose = service.registerProvider({ ask: async () => {
3842
+ throw new Error("pi2dsh probe provider");
3843
+ } });
3844
+ } catch {
3845
+ return true;
3846
+ }
3847
+ dispose?.();
3848
+ return false;
3849
+ }
3090
3850
  function requireSession(state, operation) {
3091
3851
  const session = agentSession(currentAgent(state));
3092
3852
  if (session === void 0) throw new Error(`pi2dsh: ${operation} requires one active DSH agent with a durable session`);
@@ -3135,7 +3895,8 @@ function createPiApi(ctx, state) {
3135
3895
  providerConfig: value,
3136
3896
  store: oauthStoreOf(state)
3137
3897
  }),
3138
- warn: (message) => logger(ctx).warn(message)
3898
+ warn: (message) => logger(ctx).warn(message),
3899
+ resolveAttachments: () => optionalService(ctx, "attachments")
3139
3900
  }
3140
3901
  });
3141
3902
  if (routeDisposer !== void 0) {
@@ -3190,8 +3951,7 @@ function createPiApi(ctx, state) {
3190
3951
  },
3191
3952
  sendMessage(message, options = {}) {
3192
3953
  requireAgent(state, "sendMessage");
3193
- const mode = options.deliverAs === "steer" ? "steer" : options.deliverAs === "followUp" || options.deliverAs === "nextTurn" || options.triggerTurn === true ? "followup" : "inject";
3194
- return sendPiMessage(ctx, state, message.content, mode);
3954
+ return sendPiMessage(ctx, state, message.content, deliveryMode(options), void 0, typeof message.customType === "string" ? message.customType : void 0);
3195
3955
  },
3196
3956
  sendUserMessage(content, options = {}) {
3197
3957
  requireAgent(state, "sendUserMessage");
@@ -3203,15 +3963,17 @@ function createPiApi(ctx, state) {
3203
3963
  },
3204
3964
  setSessionName(name) {
3205
3965
  const session = requireSession(state, "setSessionName");
3206
- state.bridge.setName(session.id, String(name));
3966
+ const titles = optionalService(ctx, "sessionTitle");
3967
+ if (titles !== void 0 && String(name).trim().length > 0) titles.rename(session, String(name));
3968
+ else state.bridge.setName(session.id, String(name));
3207
3969
  dispatch(state, "session_info_changed", {
3208
3970
  type: "session_info_changed",
3209
- name: state.bridge.getName(session.id)
3971
+ name: sessionNameOf(ctx, state, session)
3210
3972
  }, contextFor(ctx, state, currentAgent(state), void 0)).catch((error) => logger(ctx).warn(`[pi2dsh] session_info_changed handler failed: ${String(error)}`));
3211
3973
  },
3212
3974
  getSessionName() {
3213
3975
  const session = agentSession(currentAgent(state));
3214
- return session === void 0 ? void 0 : state.bridge.getName(session.id);
3976
+ return session === void 0 ? void 0 : sessionNameOf(ctx, state, session);
3215
3977
  },
3216
3978
  setLabel(entryId, label) {
3217
3979
  const session = requireSession(state, "setLabel");
@@ -3258,11 +4020,12 @@ function createPiApi(ctx, state) {
3258
4020
  ...typeof model?.id === "string" ? { model: model.id } : {}
3259
4021
  };
3260
4022
  if (override.model === void 0) return false;
4023
+ const previousModel = currentPiModel(state, agent);
3261
4024
  state.modelOverrides.set(agent, override);
3262
4025
  dispatch(state, "model_select", {
3263
4026
  type: "model_select",
3264
4027
  model,
3265
- previousModel: state.modelOverrides.get(agent),
4028
+ previousModel,
3266
4029
  source: "set"
3267
4030
  }, contextFor(ctx, state, agent, void 0)).catch((error) => logger(ctx).warn(`[pi2dsh] model_select handler failed: ${String(error)}`));
3268
4031
  return true;
@@ -3460,6 +4223,12 @@ async function applyPiPackage(ctx, options) {
3460
4223
  modelOverrides: /* @__PURE__ */ new WeakMap(),
3461
4224
  thinkingLevels: /* @__PURE__ */ new WeakMap(),
3462
4225
  turnSystemPromptOverrides: /* @__PURE__ */ new WeakMap(),
4226
+ projection: Promise.resolve(),
4227
+ terminateBatch: /* @__PURE__ */ new WeakMap(),
4228
+ piTurnIndex: /* @__PURE__ */ new WeakMap(),
4229
+ claimedForStep: /* @__PURE__ */ new WeakMap(),
4230
+ promptedTurn: /* @__PURE__ */ new WeakMap(),
4231
+ pendingInjections: /* @__PURE__ */ new WeakMap(),
3463
4232
  globalThinkingLevel: "off",
3464
4233
  argMutations: /* @__PURE__ */ new WeakMap(),
3465
4234
  streamingTexts: /* @__PURE__ */ new Map(),
@@ -3520,7 +4289,7 @@ async function applyPiPackage(ctx, options) {
3520
4289
  },
3521
4290
  piContentToDsh: (content) => piToDshContent(ctx, content),
3522
4291
  deliver: (agent, message, mode) => deliverAgentMessage(agent, message, mode),
3523
- messageFromSessionEvent,
4292
+ messageFromSessionEvent: (event) => messageFromSessionEvent(ctx, event),
3524
4293
  messageSource: state.messageSource,
3525
4294
  packageName: state.packageName
3526
4295
  }, subagentOptions));
@@ -3556,7 +4325,10 @@ async function applyPiPackage(ctx, options) {
3556
4325
  logger(ctx).info(`[pi2dsh] loaded ${options.manifest.package.name}: ${state.tools.size} tools, ${state.commands.size} commands, ${options.manifest.skillDirs.length} skill roots${healthSuffix}`);
3557
4326
  }
3558
4327
  const runtimeInternals = {
4328
+ compactionReason,
4329
+ dshToPiContent,
3559
4330
  expandPrompt,
4331
+ isSubagentOrigin,
3560
4332
  normalizeToolResult,
3561
4333
  splitArguments,
3562
4334
  textBlocks
@@ -3564,4 +4336,4 @@ const runtimeInternals = {
3564
4336
  //#endregion
3565
4337
  export { runtimeInternals as i, normalizeToolSchema as n, registerVisionCompanions as r, applyPiPackage as t };
3566
4338
 
3567
- //# sourceMappingURL=runtime-Bh5k4Kjt.mjs.map
4339
+ //# sourceMappingURL=runtime-DsPaznXJ.mjs.map