@skydiveai/pi-extensions 0.1.0-beta.6 → 0.1.0-beta.601

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +84 -50
  2. package/package.json +2 -7
package/dist/index.mjs CHANGED
@@ -21,6 +21,7 @@ import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace
21
21
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
22
22
  import { hc } from "hono/client";
23
23
  import { parse } from "yaml";
24
+ import { quote } from "shell-quote";
24
25
  import { createWriteStream } from "node:fs";
25
26
  import { finished } from "node:stream/promises";
26
27
  import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
@@ -1690,8 +1691,8 @@ function sandboxClient() {
1690
1691
  return hc(`${apiUrl}/api/v1/sandbox`);
1691
1692
  }
1692
1693
  /**
1693
- * Fetch every harness feature flag in one GET (`{ contextManagement, subagent,
1694
- * ... }` — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1694
+ * Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
1695
+ * — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1695
1696
  * null when indeterminate (no api url, or the request failed) so the shared
1696
1697
  * poller keeps the last-known values rather than flipping on a transient error.
1697
1698
  * This is the single fetch behind `feature-flags-poll.ts`; extensions read the
@@ -1709,11 +1710,7 @@ async function fetchHarnessFlags() {
1709
1710
  }, "feature-flags fetch failed");
1710
1711
  return null;
1711
1712
  }
1712
- const body = await res.json();
1713
- return {
1714
- contextManagement: body.contextManagement ?? null,
1715
- subagent: body.subagent ?? null
1716
- };
1713
+ return { contextManagement: (await res.json()).contextManagement ?? null };
1717
1714
  } catch (err) {
1718
1715
  log$10.debug({
1719
1716
  err,
@@ -1772,7 +1769,11 @@ async function postSubagentSpawn({ messageId, tasks }) {
1772
1769
  tasks
1773
1770
  } });
1774
1771
  if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
1775
- return { taskIds: (await res.json()).taskIds };
1772
+ const body = await res.json();
1773
+ return {
1774
+ taskIds: body.taskIds,
1775
+ tasks: body.tasks ?? []
1776
+ };
1776
1777
  }
1777
1778
  function createHeartbeatThrottle({ messageId }) {
1778
1779
  let lastAt = 0;
@@ -1921,20 +1922,16 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1921
1922
  * Shared harness feature-flag poll.
1922
1923
  *
1923
1924
  * The api exposes one `/feature-flags` GET that returns every harness flag in a
1924
- * single response (`{ contextManagement, subagent, commandFlags }` — see
1925
+ * single response (`{ contextManagement, commandFlags }` — see
1925
1926
  * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1926
1927
  * extension issuing its own GET — and, worse, a *blocking* GET on the
1927
1928
  * pre-first-token `session_start` path — a single background poller fetches
1928
1929
  * that response once per interval and fans the values out to every subscriber.
1929
1930
  *
1930
- * Why one poller: the subagent extension gates its tool registration on the
1931
- * `subagent` flag. If it awaited a fresh GET inside `session_start` the tool
1932
- * schema (part of the prefill) couldn't be finalized until a serial
1933
- * sandbox→api round-trip settled, adding a net-new pre-token network hop on
1934
- * every session, flag on or off. Reading the last-polled value instead keeps
1935
- * the hot path allocation-only. A cold cache reads as `null` (fail-open to
1936
- * unregistered); a newly-flipped flag takes effect on the next poll, matching
1937
- * how context-management already treats its flag.
1931
+ * Why one poller: context-management consumes the `contextManagement` flag
1932
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
1933
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
1934
+ * as `null` and a newly-flipped flag takes effect on the next poll.
1938
1935
  *
1939
1936
  * The poll is fire-and-forget and self-unref'd — it never keeps the process
1940
1937
  * alive and an indeterminate result (no api url / transient failure) leaves the
@@ -1943,15 +1940,21 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1943
1940
  const log$9 = logger.child({ module: "feature-flags-poll" });
1944
1941
  const FLAG_POLL_INTERVAL_MS = 6e4;
1945
1942
  let contextManagement = null;
1946
- let subagent = null;
1947
- const subscribers = {
1948
- contextManagement: /* @__PURE__ */ new Set(),
1949
- subagent: /* @__PURE__ */ new Set()
1950
- };
1943
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
1951
1944
  let pollerStarted = false;
1945
+ let firstPollSettled = false;
1946
+ let resolveFirstPoll = null;
1947
+ new Promise((resolve) => {
1948
+ resolveFirstPoll = resolve;
1949
+ });
1950
+ function markFirstPollSettled() {
1951
+ if (firstPollSettled) return;
1952
+ firstPollSettled = true;
1953
+ resolveFirstPoll?.();
1954
+ }
1952
1955
  /** Last-polled value of a flag, or `null` if not yet resolved. */
1953
- function getPolledFlag(name) {
1954
- return name === "contextManagement" ? contextManagement : subagent;
1956
+ function getPolledFlag(_name) {
1957
+ return contextManagement;
1955
1958
  }
1956
1959
  /**
1957
1960
  * Subscribe to changes of a flag. The callback fires only on a *transition*
@@ -1964,9 +1967,8 @@ function onFlagChange(name, cb) {
1964
1967
  }
1965
1968
  function apply(name, next) {
1966
1969
  if (next === null) return;
1967
- const prev = name === "contextManagement" ? contextManagement : subagent;
1968
- if (name === "contextManagement") contextManagement = next;
1969
- else subagent = next;
1970
+ const prev = contextManagement;
1971
+ contextManagement = next;
1970
1972
  if (next !== prev) for (const cb of subscribers[name]) try {
1971
1973
  cb(next);
1972
1974
  } catch (err) {
@@ -1977,10 +1979,13 @@ function apply(name, next) {
1977
1979
  }
1978
1980
  }
1979
1981
  async function pollOnce() {
1980
- const flags = await fetchHarnessFlags();
1981
- if (!flags) return;
1982
- apply("contextManagement", flags.contextManagement ?? null);
1983
- apply("subagent", flags.subagent ?? null);
1982
+ try {
1983
+ const flags = await fetchHarnessFlags();
1984
+ if (!flags) return;
1985
+ apply("contextManagement", flags.contextManagement ?? null);
1986
+ } catch (err) {
1987
+ log$9.debug({ err }, "feature-flag poll threw");
1988
+ }
1984
1989
  }
1985
1990
  /**
1986
1991
  * Start the shared background poll (idempotent). No-op when there's no
@@ -1991,7 +1996,7 @@ async function pollOnce() {
1991
1996
  function startFeatureFlagPoller() {
1992
1997
  if (pollerStarted || !hasFlagSource()) return;
1993
1998
  pollerStarted = true;
1994
- pollOnce();
1999
+ pollOnce().finally(markFirstPollSettled);
1995
2000
  setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
1996
2001
  }
1997
2002
  //#endregion
@@ -2736,7 +2741,7 @@ function soulSection(cwd, soul) {
2736
2741
 
2737
2742
  **\`soul.md\` is where behavior lives.** Any standing instruction about how you should act — a rule a user wants you to follow going forward, a tone or format preference, a workflow convention, a "from now on, always/never …" — belongs here, not in \`.memory/\`. Memory records *what happened* (facts, events, findings); soul defines *how you behave*. When a user gives you a durable behavioral rule, write it to \`soul.md\`. If you find behavioral rules that ended up in \`.memory/\`, treat that as misfiled and move them here.
2738
2743
 
2739
- Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Edit it (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
2744
+ Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect, a secret or auth credential you wire in — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Do this the moment you gain the capability, and for a credential that means the moment it verifies with a real call, not after a human points out that you forgot. Connecting a capability is itself a durable change worth recording, not merely a step toward the task in front of you. Edit \`soul.md\` (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
2740
2745
 
2741
2746
  ${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
2742
2747
  }
@@ -2758,9 +2763,19 @@ const log$2 = logger.child({ module: "subagent-ext" });
2758
2763
  const MAX_TASKS = 8;
2759
2764
  const TaskItem = Type.Object({
2760
2765
  task: Type.String({ description: "The task to delegate to a subagent run." }),
2766
+ title: Type.String({
2767
+ description: "A SHORT name for this task — 3-6 words, sentence case, no trailing period. This is what the person in the chat sees as the row for this subagent, so name the work, do not restate the prompt. Good: \"Audit the billing gate\", \"Compare competitor pricing\", \"Draft the migration\". Bad: \"You are looking at apps/anyone/web and should check every component…\".",
2768
+ maxLength: 120
2769
+ }),
2761
2770
  persona: Type.Optional(Type.String({ description: "Optional extra system prompt / role for this task, applied ON TOP of the child run's own default persona (your full identity and soul are still there underneath). Omit to run with just your default persona." })),
2762
- model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on (e.g. \"anthropic/claude-opus-4-8\"). Must be a real catalogued model. Omit to run on your own model. If you are locked to a Google-compliant model, only compliant models are accepted." }))
2771
+ model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on. PREFER A LOWER-COST, FASTER MODEL when the task is well-scoped and does not need your full reasoning depth — most delegated subtasks (searching, summarizing, mechanical edits, gathering or reformatting data, running a check) run just as well on a lighter model and cost far less. Reserve a top-tier model for subtasks that genuinely need deep reasoning or careful judgment. Must be a real catalogued model id. Omit to inherit your own model. If you are locked to a Google-compliant model, only compliant models are accepted." })),
2772
+ timeoutMinutes: Type.Optional(Type.Integer({
2773
+ description: "Optional wall-clock timeout for this subagent, in minutes. If the run is still going after this long it is ended and you are rewoken with a timeout result, so a hung subagent can never strand you. Omit for the default (30 minutes). Raise it for genuinely long work (a big migration, a large audit); lower it for a quick lookup. Range 1-360.",
2774
+ minimum: 1,
2775
+ maximum: 360
2776
+ }))
2763
2777
  });
2778
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2764
2779
  const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2765
2780
  description: "One or more tasks to delegate. Each spawns an isolated subagent run linked to this conversation; they run in parallel and each rewakes you with its result when it finishes.",
2766
2781
  minItems: 1,
@@ -2775,7 +2790,8 @@ function buildTool(messageId) {
2775
2790
  "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2776
2791
  "Use it to parallelize independent work, to keep a large or noisy subtask out of your own context, or to run a task under a specialized persona.",
2777
2792
  "Fire-and-forget: this returns immediately after queueing. It does NOT wait for results. Each subagent runs on its own and, when it finishes, sends you its result on this thread — so queue the work, then keep going or end your turn. To chain, re-delegate after a result lands.",
2778
- "Pass tasks: [{ task, persona?, model? }]. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task; omit it to run on your own model."
2793
+ "Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task — prefer a lower-cost, faster model for well-scoped subtasks that don't need deep reasoning, and reserve a top-tier model for the ones that do; omit it to inherit your own model.",
2794
+ "Peering: each queued task comes back with its own conversation id. A subagent is a real linked conversation, so to see what one is doing RIGHT NOW while it runs — its reasoning, the tools it has called and their results, its progress — read that conversation with `platform conversations show <conversationId>` (you are already authorized; it is your own delegated run). Check in that way instead of waiting blind for the final result. The read reflects the child's persisted state, which lags a few seconds behind live (tool results land as they complete; in-progress reasoning can be up to ~5s stale), so peek between checkpoints rather than polling in a tight loop."
2779
2795
  ].join(" "),
2780
2796
  promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2781
2797
  parameters: SubagentParams,
@@ -2791,25 +2807,37 @@ function buildTool(messageId) {
2791
2807
  };
2792
2808
  const spawnTasks = tasks.map((t) => ({
2793
2809
  task: t.task,
2810
+ title: t.title ?? null,
2794
2811
  persona: t.persona ?? null,
2795
- model: t.model ?? null
2812
+ model: t.model ?? null,
2813
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2796
2814
  }));
2797
2815
  try {
2798
- const { taskIds } = await postSubagentSpawn({
2816
+ const spawned = await postSubagentSpawn({
2799
2817
  messageId,
2800
2818
  tasks: spawnTasks
2801
2819
  });
2820
+ const { taskIds } = spawned;
2802
2821
  log$2.info({
2803
2822
  event: "subagent_spawned",
2804
2823
  count: taskIds.length
2805
2824
  }, "subagent tasks queued");
2806
- const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.task ?? ""}`).join("\n");
2825
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
2826
+ const lines = taskIds.map((id, i) => {
2827
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
2828
+ const conv = convByTask.get(id);
2829
+ return `- ${id}: ${label}${conv ? ` — peek: conversations show ${conv}` : ""}`;
2830
+ }).join("\n");
2831
+ const peerHint = spawned.tasks.length ? "\nTo see what a subagent is doing while it runs, read its conversation with `platform conversations show <conversationId>` (shown per task above)." : "";
2807
2832
  return {
2808
2833
  content: [{
2809
2834
  type: "text",
2810
- text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}`
2835
+ text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}${peerHint}`
2811
2836
  }],
2812
- details: { taskIds }
2837
+ details: {
2838
+ taskIds,
2839
+ tasks: spawned.tasks
2840
+ }
2813
2841
  };
2814
2842
  } catch (err) {
2815
2843
  const message = err instanceof Error ? err.message : String(err);
@@ -2830,31 +2858,37 @@ function buildTool(messageId) {
2830
2858
  };
2831
2859
  }
2832
2860
  /**
2833
- * Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
2834
2861
  * The factory takes the session's channel context to resolve the originating
2835
2862
  * messageId — the api links each spawned run to the conversation that message
2836
2863
  * belongs to and rewakes it on completion (nothing about the parent is piped
2837
- * from the sandbox beyond that id).
2864
+ * from the sandbox beyond that id). The tool is registered unconditionally at
2865
+ * session_start.
2838
2866
  */
2839
2867
  function createSubagentExtension({ channelContext }) {
2840
2868
  return (pi) => {
2841
2869
  const messageId = extractMessageId(channelContext);
2842
- startFeatureFlagPoller();
2843
- pi.on("session_start", async () => {
2844
- if (getPolledFlag("subagent") === true) {
2845
- pi.registerTool(buildTool(messageId));
2846
- log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
2847
- }
2870
+ let registered = false;
2871
+ const registerOnce = () => {
2872
+ if (registered) return;
2873
+ registered = true;
2874
+ pi.registerTool(buildTool(messageId));
2875
+ log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
2876
+ };
2877
+ pi.on("session_start", () => {
2878
+ registerOnce();
2848
2879
  });
2849
2880
  };
2850
2881
  }
2851
2882
  //#endregion
2852
2883
  //#region src/extensions/tool-call-env.ts
2853
2884
  const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
2885
+ function shellQuoteValue(value) {
2886
+ return quote([value]);
2887
+ }
2854
2888
  function withToolCallId({ command, toolCallId }) {
2855
- return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
2889
+ return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
2856
2890
  }
2857
- const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
2891
+ const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
2858
2892
  function stripPlatformExportsForDisplay(command) {
2859
2893
  let c = command;
2860
2894
  let m;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.601",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",
@@ -17,12 +17,6 @@
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public",
20
- "exports": {
21
- ".": {
22
- "types": "./dist/index.d.mts",
23
- "default": "./dist/index.mjs"
24
- }
25
- },
26
20
  "registry": "https://registry.npmjs.org"
27
21
  },
28
22
  "scripts": {
@@ -45,6 +39,7 @@
45
39
  "@skydiveai/pi-server": "^0.1.0",
46
40
  "hono": "^4.6.14",
47
41
  "pino": "^9.6.0",
42
+ "shell-quote": "^1.8.4",
48
43
  "typebox": "^1.1.34",
49
44
  "yaml": "^2.8.3",
50
45
  "zod": "^3.25.0"