@giladbarnea/pi-user-agents 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INTERNALS.md CHANGED
@@ -81,11 +81,11 @@ The attached agent parks idle first (`runAttachedTurns` waits for an instruction
81
81
 
82
82
  `h` and `-h` hand a child session to a Pi running in a new [herdr](https://herdr.dev) pane. Both go through `openPiInHerdrPane` (`herdr.ts`), a thin wrapper over the `herdr` CLI: `pane layout --current` picks the split direction (a wide caller pane splits right, a narrow or tall one down), `pane split --current --cwd <cwd> --no-focus` creates the pane without taking the user's focus, and `agent start <name> --kind pi --pane <id> -- <pi args>` starts pi there and returns once it is ready for input. The agent name is `agent-` plus the first 13 characters of the child's session id, which hold a v7 id's complete millisecond timestamp, so names stay unique among live agents. Every command runs with the caller's environment, so `--current` resolves the pane this Pi lives in; both actions are available only under `HERDR_ENV=1`, and the widget hint hides otherwise.
83
83
 
84
- Both paths hand the pane's Pi `forwardedArgsForPane(...)`, the dispatch's forwarded tokens minus two groups: the *inert* options (`role: "inert"` in the option table — the session, approval, offline, and API-key families), which a background run ignores but a real `pi` process would obey (`--no-session` would run the pane ephemeral, `--fork` would open another file), and `--model`, `--provider`, `--thinking`, which the pane's Pi gets explicitly or from its file.
84
+ Both paths hand the pane's Pi the parent's launch extension options (see [Child extensions](#child-extensions)), then `forwardedArgsForPane(...)`, the dispatch's forwarded tokens minus two groups: the *inert* options (`role: "inert"` in the option table — the session, approval, offline, and API-key families), which a background run ignores but a real `pi` process would obey (`--no-session` would run the pane ephemeral, `--fork` would open another file), and `--model`, `--provider`, `--thinking`, which the pane's Pi gets explicitly or from its file.
85
85
 
86
86
  **The move (`h`)** is attach in reverse, for another process, in three steps ordered so that failures cost as little as possible. First the delivery resolves the file — like `/agent-attach`, from the session directory listing by exact id; a child that has not answered yet has no file and the move stops here — and splits the pane; a herdr failure at this point leaves the agent untouched. Then the widget ends the agent here: abort, retire, wait for `finished` so the child session is disposed, and keep its row as a completed snapshot. Only then does pi start in the pane, so the file has one writer at a time; the dispatch record's forwarded args replay as CLI flags and the model and thinking level come from the file, as on any resume. A live agent has to be interrupted first. Once the agent is ended, its snapshot row survives whatever happens next — with `herdrPane` set on success, so it renders `herdr pane w1:p3` in place of the status word and withholds `h`, or without it on failure, still squashable. This is the squash pattern: the row persists as the record, and no transcript breadcrumb is written; `d` on the row announces `Detached session …` like any other, which stays true — the file is on disk.
87
87
 
88
- **The dispatch (`-h`)** never creates an in-process child session, and branches off before the child resource load that only a background child needs. The child's session manager is built as usual (dispatch record, inherited snapshot) plus explicit `model_change` and `thinking_level_change` entries appended after the snapshot: pi resumes a session with the model of whichever comes last on the path, a `model_change` entry or an assistant message, and writes `model_change` itself only on its empty-session path, which the pane's Pi never takes for an inherited context. Without the entries a later `/agent-attach` or `/resume` would take the model of main's last answer, or the default. `flushSessionFile` then writes the file to disk, because pi defers a session file until its first assistant message and a snapshot with no assistant message (`-i`, or a main session that has not answered yet) would otherwise exist only in memory; when the snapshot already flushed it, the file is left alone. The pane's Pi runs `pi <forwarded args> --provider <p> --model <id> --thinking <level> --session <file> "<pane preamble + task>"`: the flags repeat the model and thinking level because an isolated child's file has no messages, and pi then takes the flags over the file. The positional prompt carries `PANE_DISPATCH_PREAMBLE`, which tells the child it is interactive in a pane rather than an ephemeral background process; `splitAtDispatchBoundary` accepts either preamble, so attach recognizes the child later. The widget row is a snapshot from birth, with nothing to squash; `-h` and `-s` conflict at parse time.
88
+ **The dispatch (`-h`)** never creates an in-process child session, and branches off before the child resource load that only a background child needs. The child's session manager is built as usual (dispatch record, inherited snapshot) plus explicit `model_change` and `thinking_level_change` entries appended after the snapshot: pi resumes a session with the model of whichever comes last on the path, a `model_change` entry or an assistant message, and writes `model_change` itself only on its empty-session path, which the pane's Pi never takes for an inherited context. Without the entries a later `/agent-attach` or `/resume` would take the model of main's last answer, or the default. `flushSessionFile` then writes the file to disk, because pi defers a session file until its first assistant message and a snapshot with no assistant message (`-i`, or a main session that has not answered yet) would otherwise exist only in memory; when the snapshot already flushed it, the file is left alone. The pane's Pi runs `pi <launch extension options> <forwarded args> --provider <p> --model <id> --thinking <level> --session <file> "<pane preamble + task>"`: the flags repeat the model and thinking level because an isolated child's file has no messages, and pi then takes the flags over the file. The positional prompt carries `PANE_DISPATCH_PREAMBLE`, which tells the child it is interactive in a pane rather than an ephemeral background process; `splitAtDispatchBoundary` accepts either preamble, so attach recognizes the child later. The widget row is a snapshot from birth, with nothing to squash; `-h` and `-s` conflict at parse time.
89
89
 
90
90
  ## Runtime sharing
91
91
 
@@ -93,6 +93,12 @@ Child agent sessions share the parent's `ModelRuntime` instance. This is critica
93
93
 
94
94
  The shared runtime is extracted from the parent's `ModelRegistry` facade (`ctx.modelRegistry`) which wraps a `ModelRuntime` as a plain `.runtime` property.
95
95
 
96
+ ## Child extensions
97
+
98
+ A child loads the extensions its parent loaded. Settings discovery covers configured extensions, but `pi -e <path>` and `pi --no-extensions` live only in the parent process's arguments. `parentExtensionArgs` re-reads them from `process.argv` with Pi's own parser and resolves local `-e` paths against the launch directory, as Pi does. Every child path puts them before the dispatch's forwarded options, as on one pi command line: the background child's resource loader, `/agent-attach`, and both herdr pane paths. The dispatch's `-e` adds extensions, and its `--no-extensions` drops configured ones while explicit `-e` paths still load, as in pi.
99
+
100
+ The shared runtime alone is not enough. It carries the parent's providers, but a `-e` extension's tools, commands, and hooks exist only where the extension loads.
101
+
96
102
  ## Model name & alias resolution
97
103
 
98
104
  User-defined model aliases (`name` in `models.json` `modelOverrides`) take precedence: `-m opus` resolves to `claude-bridge/claude-opus-4-6` directly, without ambiguity. Other model values must exactly match a live model ID or canonical `provider/id` reference before the extension calls the SDK's `resolveCliModel`. Partial and custom model IDs are rejected.
package/attach.ts CHANGED
@@ -6,9 +6,9 @@ import { herdrAgentName, insideHerdr, splitPane, startPi } from "./herdr.js";
6
6
  import { conversationFingerprint } from "./rebase.js";
7
7
  import {
8
8
  assistantText,
9
- buildChildResourceLoaderOptions,
10
9
  createChildServices,
11
10
  DISPATCH_PREAMBLES,
11
+ parentExtensionArgs,
12
12
  parseForwardedArgs,
13
13
  reportAgentFailure,
14
14
  resolveToolOptions,
@@ -278,10 +278,7 @@ async function attachUserAgent(
278
278
  // dispatch, or a /fork) restores pi defaults.
279
279
  const record = readDispatchRecord(childSessionManager);
280
280
  const dispatchArgs = parseForwardedArgs(record?.forwardedArgs ?? []);
281
- const services = await createChildServices(
282
- ctx,
283
- buildChildResourceLoaderOptions(dispatchArgs, ctx.cwd),
284
- );
281
+ const services = await createChildServices(ctx, record?.forwardedArgs ?? []);
285
282
  // A non-empty session manager takes pi's resume path: messages, model, and thinking level
286
283
  // are restored from the child's own file; tools and resources come from the dispatch record.
287
284
  const { session, modelFallbackMessage } = await createAgentSessionFromServices({
@@ -332,9 +329,10 @@ async function attachUserAgent(
332
329
 
333
330
  /**
334
331
  * The herdr mechanism: attach a child session to a Pi in a new herdr pane instead of to this
335
- * process. The pane's Pi resumes the child's file with the dispatch record's options replayed as
336
- * CLI flags (see `forwardedArgsForPane`); the model and thinking level come from the file, as on
337
- * any resume. The file is resolved before the pane is split, so an agent without one costs no pane.
332
+ * process. The pane's Pi resumes the child's file with this Pi's launch extension options and the
333
+ * dispatch record's options as CLI flags (see `forwardedArgsForPane`); the model and thinking level
334
+ * come from the file, as on any resume. The file is resolved before the pane is split, so an agent
335
+ * without one costs no pane.
338
336
  */
339
337
  export function createHerdrDelivery(
340
338
  getSessionContext: () => ExtensionCommandContext | undefined,
@@ -350,7 +348,12 @@ export function createHerdrDelivery(
350
348
  )?.file;
351
349
  if (!file) throw new Error("the agent has no session file yet (it has not answered)");
352
350
  const record = readDispatchRecord(SessionManager.open(file));
353
- const piArgs = [...forwardedArgsForPane(record?.forwardedArgs ?? []), "--session", file];
351
+ const piArgs = [
352
+ ...parentExtensionArgs(),
353
+ ...forwardedArgsForPane(record?.forwardedArgs ?? []),
354
+ "--session",
355
+ file,
356
+ ];
354
357
  const paneId = await splitPane(ctx.cwd);
355
358
  return { paneId, start: () => startPi(paneId, herdrAgentName(agent.sessionId), piArgs) };
356
359
  },
package/command-line.ts CHANGED
@@ -590,6 +590,10 @@ function analyzeArgumentCursor(
590
590
  usedSemanticIds,
591
591
  );
592
592
  }
593
+ // A cursor on the first character of the prose is inside the prompt, not in an option slot.
594
+ const nextToken = /^\S+/.exec(args.slice(position))?.[0];
595
+ if (position === cursor && nextToken !== undefined && !OPTION_BY_NAME.has(nextToken))
596
+ return undefined;
593
597
  return optionExpectation(command, "", cursor, cursor, usedSemanticIds);
594
598
  }
595
599
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
package/runner.ts CHANGED
@@ -147,10 +147,7 @@ async function startUserAgent(
147
147
  }
148
148
 
149
149
  // The pane's Pi loads its own resources; only a background child needs them here.
150
- const services = await createChildServices(
151
- ctx,
152
- buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
153
- );
150
+ const services = await createChildServices(ctx, parsed.forwardedArgs);
154
151
  runningAgents.add(runningAgent);
155
152
  logSteering(runningAgent.id, "agent-created", { command, taskLength: parsed.task.length });
156
153
  if (ctx.hasUI) {
@@ -204,6 +201,7 @@ async function dispatchToHerdrPane(
204
201
  // Explicit on the CLI too: an isolated child's file has no messages, and pi then takes the
205
202
  // flags over the file.
206
203
  const paneId = await openPiInHerdrPane(ctx.cwd, herdrAgentName(runningAgent.sessionId), [
204
+ ...parentExtensionArgs(),
207
205
  ...forwardedArgsForPane(parsed.forwardedArgs),
208
206
  "--provider",
209
207
  model.provider,
@@ -367,24 +365,46 @@ type ForwardedOptions = ChildToolOptions & {
367
365
  thinkingLevel?: ThinkingLevel;
368
366
  };
369
367
 
370
- /** The cwd-bound services for one child session; the parent's ModelRuntime rides along (see INTERNALS). */
368
+ /**
369
+ * The cwd-bound services for one child session; the parent's ModelRuntime rides along (see INTERNALS).
370
+ * Resources come from the parent's launch extension options, then the dispatch's forwarded pi options.
371
+ */
371
372
  export function createChildServices(
372
373
  ctx: ExtensionCommandContext,
373
- resourceLoaderOptions?: ChildResourceLoaderOptions,
374
+ forwardedArgs: readonly string[],
374
375
  ): Promise<AgentSessionServices> {
375
376
  const agentDir = getAgentDir();
376
377
  const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
377
378
  projectTrusted: ctx.isProjectTrusted(),
378
379
  });
380
+ const resourceArgs = parseForwardedArgs([...parentExtensionArgs(), ...forwardedArgs]);
379
381
  return createAgentSessionServices({
380
382
  cwd: ctx.cwd,
381
383
  agentDir,
382
384
  settingsManager,
383
385
  modelRuntime: parentModelRuntime(ctx),
384
- resourceLoaderOptions,
386
+ resourceLoaderOptions: buildChildResourceLoaderOptions(resourceArgs, ctx.cwd),
385
387
  });
386
388
  }
387
389
 
390
+ /**
391
+ * The extension options this Pi process was launched with, as pi CLI tokens. Pi keeps them out of
392
+ * settings, so a child that loads extensions from settings alone misses the parent's `-e` extensions.
393
+ *
394
+ * @example parentExtensionArgs(["--mode", "rpc", "-ne", "-e", "./a.ts", "-e", "npm:b", "hi"], "/work") // ["--no-extensions", "--extension", "/work/a.ts", "--extension", "npm:b"]
395
+ */
396
+ export function parentExtensionArgs(
397
+ launchArgs: readonly string[] = process.argv.slice(2),
398
+ launchCwd: string = process.cwd(),
399
+ ): string[] {
400
+ const launch = parseArgs([...launchArgs]);
401
+ const extensions = resolveResourcePaths(launch.extensions ?? [], launchCwd).flatMap((source) => [
402
+ "--extension",
403
+ source,
404
+ ]);
405
+ return launch.noExtensions ? ["--no-extensions", ...extensions] : extensions;
406
+ }
407
+
388
408
  /**
389
409
  * The parent's ModelRuntime: the extension facade wraps it as a plain property. Sharing it keeps
390
410
  * extension-registered providers (their process-global dedup guard) available to the child.
@@ -532,6 +552,8 @@ export function buildChildResourceLoaderOptions(
532
552
  /** Resolve resource paths to absolute against the session cwd, matching how the CLI hands them to the loader. */
533
553
  function resolveResourcePaths(values: string[], cwd: string): string[] {
534
554
  return values.map((value) => {
555
+ // Package sources such as npm:<pkg> are not paths; pi resolves them itself.
556
+ if (/^(npm|git|github|https?|ssh|builtin):/.test(value)) return value;
535
557
  if (value === "~" || value.startsWith("~/")) return path.join(os.homedir(), value.slice(1));
536
558
  return path.isAbsolute(value) ? value : path.resolve(cwd, value);
537
559
  });