pi2dsh 0.12.1 → 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 {
@@ -1329,6 +1721,12 @@ function piCarriedFields(model) {
1329
1721
  * Structural typing carries it through `llm.registerAdapter`.
1330
1722
  */
1331
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
+ };
1332
1730
  return {
1333
1731
  providerInfo: (id) => ({
1334
1732
  id,
@@ -1362,7 +1760,14 @@ function piProviderDshAdapter(providerId, provider, host) {
1362
1760
  id: modelId,
1363
1761
  provider: providerId
1364
1762
  };
1365
- 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;
1366
1771
  const piOptions = {
1367
1772
  ...auth?.apiKey === void 0 ? {} : { apiKey: auth.apiKey },
1368
1773
  ...auth?.baseUrl === void 0 ? {} : { baseUrl: auth.baseUrl },
@@ -1370,9 +1775,20 @@ function piProviderDshAdapter(providerId, provider, host) {
1370
1775
  ...options.signal instanceof AbortSignal ? { signal: options.signal } : {},
1371
1776
  ...typeof options.maxTokens === "number" ? { maxTokens: options.maxTokens } : {},
1372
1777
  ...typeof options.temperature === "number" ? { temperature: options.temperature } : {},
1373
- ...typeof options.reasoningEffort === "string" ? { reasoningEffort: options.reasoningEffort } : {}
1778
+ ...typeof options.sessionId === "string" ? { sessionId: options.sessionId } : {},
1779
+ ...effort === void 0 || effort === "off" ? {} : { reasoning: effort }
1374
1780
  };
1375
- 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);
1376
1792
  }
1377
1793
  };
1378
1794
  }
@@ -1786,11 +2202,12 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
1786
2202
  const notices = [];
1787
2203
  const userQuestions = optionalService(ctx, "userQuestions");
1788
2204
  const ui = {
1789
- notify(message) {
2205
+ notify(message, type) {
1790
2206
  const text = String(message);
1791
2207
  notices.push(text);
1792
2208
  state.notifications.push(text);
1793
- logger(ctx).info(`[pi2dsh] ${text}`);
2209
+ const level = type === "warning" || type === "error" ? "warn" : "info";
2210
+ logger(ctx)[level](`[pi2dsh] ${text}`);
1794
2211
  },
1795
2212
  select: (title, options) => askOne(ctx, agent, signal, {
1796
2213
  id: "pi2dsh-select",
@@ -2003,9 +2420,18 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
2003
2420
  };
2004
2421
  const contextCwd = typeof (sessionOverride?.header)?.cwd === "string" ? sessionOverride.header.cwd : cwdOf(agent);
2005
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
+ },
2006
2430
  ui,
2007
2431
  mode: "rpc",
2008
- hasUI: userQuestions !== void 0,
2432
+ get hasUI() {
2433
+ return humanAnswererAvailable(userQuestions, agent);
2434
+ },
2009
2435
  cwd: contextCwd,
2010
2436
  sessionManager: session === void 0 ? state.bridge.readonlySessionManager({
2011
2437
  id: "pi2dsh-detached",
@@ -2013,7 +2439,7 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
2013
2439
  }, contextCwd) : state.bridge.readonlySessionManager(session, contextCwd),
2014
2440
  modelRegistry,
2015
2441
  model: agent === void 0 ? void 0 : currentPiModel(state, agent),
2016
- scopedModels: catalog?.all() ?? [],
2442
+ scopedModels: [],
2017
2443
  thinkingLevel: thinkingLevelOf(state, agent),
2018
2444
  isIdle: () => command,
2019
2445
  isProjectTrusted: () => false,
@@ -2026,7 +2452,7 @@ function contextFor(ctx, state, agent, signal, command = false, sessionOverride)
2026
2452
  reason: "pi2dsh: aborted by migrated Pi extension"
2027
2453
  });
2028
2454
  },
2029
- hasPendingMessages: () => false,
2455
+ hasPendingMessages: () => agent?.inbox?.hasPending === true,
2030
2456
  shutdown: () => {
2031
2457
  capabilityLedgerOf(ctx, state).reportHostDecision({
2032
2458
  capability: "ctx.shutdown",
@@ -2228,39 +2654,93 @@ async function dispatch(state, eventName, event, eventContext) {
2228
2654
  for (const handler of state.handlers.get(eventName) ?? []) results.push(await state.agentScope.run(agent, () => handler(event, eventContext)));
2229
2655
  return results;
2230
2656
  }
2231
- function dshToPiContent(content) {
2232
- return content.map((block) => {
2233
- if (block.type === "text") return {
2234
- type: "text",
2235
- text: block.text
2236
- };
2237
- if (block.type === "reasoning") return {
2238
- type: "thinking",
2239
- thinking: block.text
2240
- };
2241
- if (block.type === "tool-call") return {
2242
- type: "toolCall",
2243
- id: block.id,
2244
- name: block.name,
2245
- arguments: block.arguments
2246
- };
2247
- 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
2248
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;
2249
2708
  }
2250
- function messageFromSessionEvent(event) {
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
+ }
2729
+ }
2730
+ async function messageFromSessionEvent(ctx, event) {
2251
2731
  const type = event.type;
2252
2732
  const data = event.data;
2253
2733
  if (typeof data !== "object" || data === null) return void 0;
2254
2734
  const record = data;
2255
2735
  if (type === "user/message") return {
2256
2736
  role: "user",
2257
- content: dshToPiContent(record.content ?? [])
2737
+ content: await dshToPiContent(ctx, record.content ?? [])
2258
2738
  };
2259
2739
  if (type === "assistant/message") {
2260
2740
  const message = record.message;
2261
2741
  return {
2262
2742
  role: "assistant",
2263
- content: dshToPiContent(message?.content ?? [])
2743
+ content: await dshToPiContent(ctx, message?.content ?? [])
2264
2744
  };
2265
2745
  }
2266
2746
  if (type === "tool/result") {
@@ -2268,7 +2748,7 @@ function messageFromSessionEvent(event) {
2268
2748
  return {
2269
2749
  role: "toolResult",
2270
2750
  toolCallId: tool?.toolCallId,
2271
- content: dshToPiContent(tool?.content ?? []),
2751
+ content: await dshToPiContent(ctx, tool?.content ?? []),
2272
2752
  isError: tool?.isError === true
2273
2753
  };
2274
2754
  }
@@ -2315,13 +2795,42 @@ function subscribeLifecycle(ctx, state) {
2315
2795
  const eventContext = contextFor(ctx, state, agent, void 0);
2316
2796
  const type = event.type;
2317
2797
  if (type === "turn/start") {
2318
- const turn = Number(event.data.turn ?? 1);
2798
+ if (session !== void 0) state.piTurnIndex.set(session, 0);
2319
2799
  dispatch(state, "agent_start", { type: "agent_start" }, eventContext).catch((error) => warn("agent_start", error));
2320
- dispatch(state, "turn_start", {
2321
- type: "turn_start",
2322
- turnIndex: turn - 1,
2323
- timestamp: event.time ?? Date.now()
2324
- }, 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));
2325
2834
  }
2326
2835
  if (type === "tool/call") {
2327
2836
  const data = event.data;
@@ -2372,21 +2881,26 @@ function subscribeLifecycle(ctx, state) {
2372
2881
  type: "session_before_compact",
2373
2882
  preparation: { ...event.data },
2374
2883
  branchEntries: [],
2375
- reason: "threshold",
2884
+ reason: compactionReason(event.data),
2376
2885
  willRetry: false
2377
2886
  }, eventContext).catch((error) => warn("session_before_compact", error));
2378
- if (type === "compaction/end" || type === "compaction/summary") {
2887
+ if (type === "compaction/summary") {
2379
2888
  const data = event.data;
2889
+ const shadowed = data.shadowedRange;
2890
+ const usage = data.usage;
2380
2891
  dispatch(state, "session_compact", {
2381
2892
  type: "session_compact",
2382
2893
  compactionEntry: {
2383
2894
  type: "compaction",
2384
2895
  id: `dsh-${String(event.seq ?? "")}`,
2385
- summary: typeof data.summary === "string" ? data.summary : "",
2386
- ...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
2387
2901
  },
2388
2902
  fromExtension: false,
2389
- reason: "threshold",
2903
+ reason: compactionReason(data),
2390
2904
  willRetry: false
2391
2905
  }, eventContext).catch((error) => warn("session_compact", error));
2392
2906
  }
@@ -2404,29 +2918,25 @@ function subscribeLifecycle(ctx, state) {
2404
2918
  state.lastLoggedModels.set(agent, String(model));
2405
2919
  }
2406
2920
  }
2407
- const message = messageFromSessionEvent(event);
2408
- if (message !== void 0) dispatch(state, "message_start", {
2409
- type: "message_start",
2410
- message
2411
- }, eventContext).then(() => dispatch(state, "message_end", {
2412
- type: "message_end",
2413
- message
2414
- }, eventContext)).catch((error) => warn("message lifecycle", error));
2415
- if (type === "turn/end") {
2416
- const data = event.data;
2417
- dispatch(state, "turn_end", {
2418
- type: "turn_end",
2419
- turnIndex: Number(data.turn ?? 1) - 1,
2420
- message: {
2421
- role: "assistant",
2422
- content: []
2423
- },
2424
- toolResults: []
2425
- }, 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", {
2426
2935
  type: "agent_end",
2427
2936
  messages: []
2428
- }, eventContext)).then(() => dispatch(state, "agent_settled", { type: "agent_settled" }, eventContext)).catch((error) => warn("turn end lifecycle", error));
2429
- }
2937
+ }, eventContext);
2938
+ await dispatch(state, "agent_settled", { type: "agent_settled" }, eventContext);
2939
+ }).catch((error) => warn("turn end lifecycle", error));
2430
2940
  });
2431
2941
  cordis.on("agent/request", async (payload, next) => {
2432
2942
  const config = await next();
@@ -2442,19 +2952,6 @@ function subscribeLifecycle(ctx, state) {
2442
2952
  ...thinking === void 0 || thinking === "off" ? {} : { reasoningEffort: thinking }
2443
2953
  };
2444
2954
  });
2445
- cordis.on("tools/result", (exec, result) => {
2446
- const agent = exec.agent;
2447
- dispatch(state, "tool_execution_end", {
2448
- type: "tool_execution_end",
2449
- toolCallId: exec.callId,
2450
- toolName: exec.name,
2451
- result: {
2452
- content: dshToPiContent(result.content),
2453
- details: result.meta ?? null
2454
- },
2455
- isError: result.isError
2456
- }, contextFor(ctx, state, agent, exec.signal)).catch((error) => warn("tool_execution_end", error));
2457
- });
2458
2955
  cordis.effect(() => async () => {
2459
2956
  for (const agent of state.activeAgents) if (typeof agent === "object" && agent !== null && !state.disposedAgents.has(agent)) {
2460
2957
  state.disposedAgents.add(agent);
@@ -2472,6 +2969,7 @@ function subscribeLifecycle(ctx, state) {
2472
2969
  function subscribeInterceptors(ctx, state) {
2473
2970
  const cordis = ctx;
2474
2971
  cordis.on("tools/pre-execute", async (exec, next) => {
2972
+ if (isSubagentOrigin(exec.agent)) return next();
2475
2973
  const input = cloneJson(exec.arguments);
2476
2974
  const event = {
2477
2975
  type: "tool_call",
@@ -2487,21 +2985,47 @@ function subscribeInterceptors(ctx, state) {
2487
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`
2488
2986
  };
2489
2987
  }
2490
- for (const result of results) if (typeof result === "object" && result !== null && result.block === true) return {
2491
- kind: "deny",
2492
- 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
2493
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
+ }
2494
3007
  return next();
2495
3008
  });
2496
3009
  cordis.on("tools/post-execute", async (exec, result, next) => {
3010
+ if (isSubagentOrigin(exec.agent)) return next();
2497
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)}`));
2498
3022
  if (downstream.kind === "block") return downstream;
2499
3023
  const event = {
2500
3024
  type: "tool_result",
2501
3025
  toolName: exec.name,
2502
3026
  toolCallId: exec.callId,
2503
3027
  input: cloneJson(exec.arguments),
2504
- content: dshToPiContent(result.content),
3028
+ content: await dshToPiContent(ctx, result.content),
2505
3029
  details: result.meta ?? null,
2506
3030
  isError: result.isError,
2507
3031
  usage: void 0
@@ -2517,57 +3041,37 @@ function subscribeInterceptors(ctx, state) {
2517
3041
  feedback: content
2518
3042
  };
2519
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");
2520
- if (!jsonEqual(event.content, dshToPiContent(result.content))) return {
3044
+ if (!jsonEqual(event.content, await dshToPiContent(ctx, result.content))) return {
2521
3045
  kind: "accept",
2522
3046
  content
2523
3047
  };
2524
3048
  return downstream;
2525
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
+ });
2526
3060
  cordis.on("agent/pre-step", async (payload, next) => {
2527
- const decision = await next();
2528
- if (decision.kind !== "enter" || payload.step !== 1) return decision;
2529
- if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return decision;
2530
3061
  const agent = payload.agent;
2531
- 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;
2532
3071
  const signal = payload.signal;
2533
3072
  const messages = decision.messages ?? [];
2534
- const userMessages = messages.filter((message) => (message.source?.kind ?? "user") === "user");
2535
- const prompt = userMessages.flatMap((message) => textBlocks(message.content)).map((block) => block.text).join("\n");
2536
- const images = await collectPiImages(ctx, userMessages);
2537
- state.turnSystemPromptOverrides.delete(agent);
2538
- const event = {
2539
- type: "before_agent_start",
2540
- prompt,
2541
- ...images.length > 0 ? { images } : {},
2542
- systemPrompt: state.currentSystemPrompt,
2543
- systemPromptOptions: {}
2544
- };
2545
- const results = await dispatch(state, "before_agent_start", event, contextFor(ctx, state, agent, signal));
2546
- const injected = [];
2547
- for (const result of results) {
2548
- if (typeof result !== "object" || result === null) continue;
2549
- const record = result;
2550
- if (typeof record.systemPrompt === "string") {
2551
- state.turnSystemPromptOverrides.set(agent, record.systemPrompt);
2552
- event.systemPrompt = record.systemPrompt;
2553
- }
2554
- const message = record.message;
2555
- if (message !== void 0) {
2556
- const content = message.content;
2557
- const blocks = await piToDshContent(ctx, typeof content === "string" ? [{
2558
- type: "text",
2559
- text: content
2560
- }] : content ?? []);
2561
- injected.push(createUserMessage({
2562
- content: blocks,
2563
- source: {
2564
- kind: "plugin",
2565
- plugin: state.messageSource,
2566
- ...typeof message.customType === "string" ? { piCustomType: message.customType } : {}
2567
- }
2568
- }));
2569
- }
2570
- }
3073
+ const injected = state.pendingInjections.get(agent) ?? [];
3074
+ state.pendingInjections.delete(agent);
2571
3075
  const stepMessages = injected.length === 0 ? messages : [...messages, ...injected];
2572
3076
  const transformed = await applyPiContextTransform(ctx, state, agent, signal, stepMessages);
2573
3077
  if (transformed === stepMessages && injected.length === 0) return decision;
@@ -2576,13 +3080,19 @@ function subscribeInterceptors(ctx, state) {
2576
3080
  messages: transformed
2577
3081
  };
2578
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
+ }));
2579
3089
  cordis.on("system-prompt/assemble", async (assembly, assembleContext, next) => {
2580
3090
  const downstream = await next();
2581
- if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return downstream;
2582
3091
  const original = renderPrompt(downstream);
2583
3092
  state.currentSystemPrompt = original;
2584
3093
  const agent = assembleContext.agent ?? assembleContext.scope;
2585
3094
  if (agent !== void 0 && !isSubagentOrigin(agent)) {
3095
+ await runBeforeAgentStart(ctx, state, agent, assembleContext.signal, original);
2586
3096
  const override = state.turnSystemPromptOverrides.get(agent);
2587
3097
  if (override === void 0) return downstream;
2588
3098
  state.currentSystemPrompt = override;
@@ -2594,6 +3104,7 @@ function subscribeInterceptors(ctx, state) {
2594
3104
  }]
2595
3105
  };
2596
3106
  }
3107
+ if ((state.handlers.get("before_agent_start")?.length ?? 0) === 0) return downstream;
2597
3108
  const event = {
2598
3109
  type: "before_agent_start",
2599
3110
  prompt: "",
@@ -2616,9 +3127,69 @@ function subscribeInterceptors(ctx, state) {
2616
3127
  };
2617
3128
  });
2618
3129
  }
2619
- 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) {
2620
3191
  const source = message.source;
2621
- const content = dshToPiContent(message.content ?? []);
3192
+ const content = await dshToPiContent(ctx, message.content ?? []);
2622
3193
  return typeof source?.piCustomType === "string" ? {
2623
3194
  role: "custom",
2624
3195
  customType: source.piCustomType,
@@ -2633,7 +3204,7 @@ async function applyPiContextTransform(ctx, state, agent, signal, pending) {
2633
3204
  const session = agentSession(agent);
2634
3205
  const history = [];
2635
3206
  for (const event of session?.events ?? []) {
2636
- const projected = messageFromSessionEvent(event);
3207
+ const projected = await messageFromSessionEvent(ctx, event);
2637
3208
  if (projected === void 0) continue;
2638
3209
  if (event.type === "user/message") {
2639
3210
  const customType = (event.data?.source)?.piCustomType;
@@ -2648,7 +3219,7 @@ async function applyPiContextTransform(ctx, state, agent, signal, pending) {
2648
3219
  }
2649
3220
  history.push(projected);
2650
3221
  }
2651
- const projectedPending = pending.map(piShapeOfPending);
3222
+ const projectedPending = await Promise.all(pending.map((message) => piShapeOfPending(ctx, message)));
2652
3223
  const event = {
2653
3224
  type: "context",
2654
3225
  messages: [...history, ...projectedPending]
@@ -2695,7 +3266,7 @@ async function applyPiContextTransform(ctx, state, agent, signal, pending) {
2695
3266
  return rebuilt;
2696
3267
  }
2697
3268
  async function collectPiImages(ctx, messages) {
2698
- const attachments = ctx.get("attachments");
3269
+ const attachments = optionalService(ctx, "attachments");
2699
3270
  if (attachments === void 0) return [];
2700
3271
  const images = [];
2701
3272
  for (const message of messages) {
@@ -2919,7 +3490,13 @@ function registerTool(ctx, state, tool) {
2919
3490
  const mutated = state.argMutations.get(exec);
2920
3491
  if (mutated !== void 0) state.argMutations.delete(exec);
2921
3492
  const effective = mutated ?? args;
2922
- 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
+ });
2923
3500
  const agent = exec.agent;
2924
3501
  const result = await normalizeToolResultForDsh(ctx, await state.agentScope.run(agent, () => live.execute(String(exec.callId), prepared, exec.signal, (update) => {
2925
3502
  dispatch(state, "tool_execution_update", {
@@ -2957,10 +3534,57 @@ function currentAgent(state) {
2957
3534
  function toolRuntime(ctx, agent) {
2958
3535
  return (agent?.ctx)?.tools ?? ctx.tools;
2959
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
+ }
2960
3565
  function getActiveTools(ctx, state) {
2961
3566
  const agent = currentAgent(state);
2962
3567
  return toolRuntime(ctx, agent).schemas(agent).map((tool) => tool.name);
2963
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
+ */
2964
3588
  function setActiveTools(ctx, state, names) {
2965
3589
  const unique = [...new Set(names)];
2966
3590
  const agent = currentAgent(state);
@@ -2971,27 +3595,104 @@ function setActiveTools(ctx, state, names) {
2971
3595
  logger(ctx).warn("[pi2dsh] setActiveTools deferred: the current agent exposes no scoped tools.restrict()");
2972
3596
  return;
2973
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
+ }
2974
3604
  state.toolRestrictions.get(agent)?.();
2975
- 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
+ }
2976
3622
  }
2977
3623
  function deliverAgentMessage(agent, message, mode) {
2978
3624
  const deliver = agent[mode];
2979
3625
  if (typeof deliver !== "function") throw new Error(`pi2dsh: active DSH agent has no ${mode}() delivery method`);
2980
3626
  deliver.call(agent, message);
2981
3627
  }
2982
- async function sendPiMessage(ctx, state, content, mode) {
2983
- 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) {
2984
3652
  const blocks = await piToDshContent(ctx, typeof content === "string" ? [{
2985
3653
  type: "text",
2986
3654
  text: content
2987
3655
  }] : content);
2988
- deliverAgentMessage(agent, createUserMessage({
3656
+ const message = createUserMessage({
2989
3657
  content: blocks,
2990
3658
  source: {
2991
3659
  kind: "plugin",
2992
- plugin: state.messageSource
3660
+ plugin: state.messageSource,
3661
+ ...customType === void 0 ? {} : { piCustomType: customType }
2993
3662
  }
2994
- }), 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" });
2995
3696
  }
2996
3697
  function combineExecSignal(options) {
2997
3698
  const controller = new AbortController();
@@ -3101,6 +3802,51 @@ function registerCommand(ctx, state, command) {
3101
3802
  }
3102
3803
  logger(ctx).warn(`[pi2dsh] command /${command.name} was not registered: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
3103
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
+ }
3104
3850
  function requireSession(state, operation) {
3105
3851
  const session = agentSession(currentAgent(state));
3106
3852
  if (session === void 0) throw new Error(`pi2dsh: ${operation} requires one active DSH agent with a durable session`);
@@ -3149,7 +3895,8 @@ function createPiApi(ctx, state) {
3149
3895
  providerConfig: value,
3150
3896
  store: oauthStoreOf(state)
3151
3897
  }),
3152
- warn: (message) => logger(ctx).warn(message)
3898
+ warn: (message) => logger(ctx).warn(message),
3899
+ resolveAttachments: () => optionalService(ctx, "attachments")
3153
3900
  }
3154
3901
  });
3155
3902
  if (routeDisposer !== void 0) {
@@ -3204,8 +3951,7 @@ function createPiApi(ctx, state) {
3204
3951
  },
3205
3952
  sendMessage(message, options = {}) {
3206
3953
  requireAgent(state, "sendMessage");
3207
- const mode = options.deliverAs === "steer" ? "steer" : options.deliverAs === "followUp" || options.deliverAs === "nextTurn" || options.triggerTurn === true ? "followup" : "inject";
3208
- 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);
3209
3955
  },
3210
3956
  sendUserMessage(content, options = {}) {
3211
3957
  requireAgent(state, "sendUserMessage");
@@ -3217,15 +3963,17 @@ function createPiApi(ctx, state) {
3217
3963
  },
3218
3964
  setSessionName(name) {
3219
3965
  const session = requireSession(state, "setSessionName");
3220
- 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));
3221
3969
  dispatch(state, "session_info_changed", {
3222
3970
  type: "session_info_changed",
3223
- name: state.bridge.getName(session.id)
3971
+ name: sessionNameOf(ctx, state, session)
3224
3972
  }, contextFor(ctx, state, currentAgent(state), void 0)).catch((error) => logger(ctx).warn(`[pi2dsh] session_info_changed handler failed: ${String(error)}`));
3225
3973
  },
3226
3974
  getSessionName() {
3227
3975
  const session = agentSession(currentAgent(state));
3228
- return session === void 0 ? void 0 : state.bridge.getName(session.id);
3976
+ return session === void 0 ? void 0 : sessionNameOf(ctx, state, session);
3229
3977
  },
3230
3978
  setLabel(entryId, label) {
3231
3979
  const session = requireSession(state, "setLabel");
@@ -3272,11 +4020,12 @@ function createPiApi(ctx, state) {
3272
4020
  ...typeof model?.id === "string" ? { model: model.id } : {}
3273
4021
  };
3274
4022
  if (override.model === void 0) return false;
4023
+ const previousModel = currentPiModel(state, agent);
3275
4024
  state.modelOverrides.set(agent, override);
3276
4025
  dispatch(state, "model_select", {
3277
4026
  type: "model_select",
3278
4027
  model,
3279
- previousModel: state.modelOverrides.get(agent),
4028
+ previousModel,
3280
4029
  source: "set"
3281
4030
  }, contextFor(ctx, state, agent, void 0)).catch((error) => logger(ctx).warn(`[pi2dsh] model_select handler failed: ${String(error)}`));
3282
4031
  return true;
@@ -3474,6 +4223,12 @@ async function applyPiPackage(ctx, options) {
3474
4223
  modelOverrides: /* @__PURE__ */ new WeakMap(),
3475
4224
  thinkingLevels: /* @__PURE__ */ new WeakMap(),
3476
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(),
3477
4232
  globalThinkingLevel: "off",
3478
4233
  argMutations: /* @__PURE__ */ new WeakMap(),
3479
4234
  streamingTexts: /* @__PURE__ */ new Map(),
@@ -3534,7 +4289,7 @@ async function applyPiPackage(ctx, options) {
3534
4289
  },
3535
4290
  piContentToDsh: (content) => piToDshContent(ctx, content),
3536
4291
  deliver: (agent, message, mode) => deliverAgentMessage(agent, message, mode),
3537
- messageFromSessionEvent,
4292
+ messageFromSessionEvent: (event) => messageFromSessionEvent(ctx, event),
3538
4293
  messageSource: state.messageSource,
3539
4294
  packageName: state.packageName
3540
4295
  }, subagentOptions));
@@ -3570,7 +4325,10 @@ async function applyPiPackage(ctx, options) {
3570
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}`);
3571
4326
  }
3572
4327
  const runtimeInternals = {
4328
+ compactionReason,
4329
+ dshToPiContent,
3573
4330
  expandPrompt,
4331
+ isSubagentOrigin,
3574
4332
  normalizeToolResult,
3575
4333
  splitArguments,
3576
4334
  textBlocks
@@ -3578,4 +4336,4 @@ const runtimeInternals = {
3578
4336
  //#endregion
3579
4337
  export { runtimeInternals as i, normalizeToolSchema as n, registerVisionCompanions as r, applyPiPackage as t };
3580
4338
 
3581
- //# sourceMappingURL=runtime-Xy8yU8wO.mjs.map
4339
+ //# sourceMappingURL=runtime-DsPaznXJ.mjs.map