@giladbarnea/pi-user-agents 0.0.8 → 0.0.9

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/herdr.ts ADDED
@@ -0,0 +1,92 @@
1
+ import * as childProcess from "node:child_process";
2
+
3
+ /** Whether this Pi runs inside a herdr pane, so new panes can be split from it. */
4
+ export function insideHerdr(): boolean {
5
+ return process.env.HERDR_ENV === "1";
6
+ }
7
+
8
+ /**
9
+ * The herdr agent name for a child session. Herdr names must be unique among live agents and
10
+ * match [a-z][a-z0-9_-]{0,31}; the first 13 characters of a v7 session id hold its complete
11
+ * millisecond timestamp, so two children never share one.
12
+ *
13
+ * @example herdrAgentName("0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce") // "agent-0199c4f2-8b1a"
14
+ */
15
+ export function herdrAgentName(sessionId: string): string {
16
+ return `agent-${sessionId.slice(0, 13)}`;
17
+ }
18
+
19
+ /** Split the calling pane and start `pi <piArgs>` in the new pane; resolves with the pane id once pi is ready. */
20
+ export async function openPiInHerdrPane(
21
+ cwd: string,
22
+ agentName: string,
23
+ piArgs: readonly string[],
24
+ ): Promise<string> {
25
+ const paneId = await splitPane(cwd);
26
+ await startPi(paneId, agentName, piArgs);
27
+ return paneId;
28
+ }
29
+
30
+ /** Split the calling pane without taking the user's focus; resolves with the new pane's id. */
31
+ export async function splitPane(cwd: string): Promise<string> {
32
+ const direction = await splitDirection();
33
+ const split = (await herdr([
34
+ "pane",
35
+ "split",
36
+ "--current",
37
+ "--direction",
38
+ direction,
39
+ "--cwd",
40
+ cwd,
41
+ "--no-focus",
42
+ ])) as { pane: { pane_id: string } };
43
+ return split.pane.pane_id;
44
+ }
45
+
46
+ /** Start `pi <piArgs>` in a shell pane as a named herdr agent; resolves once pi is ready for input. */
47
+ export async function startPi(
48
+ paneId: string,
49
+ agentName: string,
50
+ piArgs: readonly string[],
51
+ ): Promise<void> {
52
+ await herdr(["agent", "start", agentName, "--kind", "pi", "--pane", paneId, "--", ...piArgs]);
53
+ }
54
+
55
+ type PaneLayout = {
56
+ panes: Array<{ pane_id: string; rect: { width: number; height: number } }>;
57
+ };
58
+
59
+ /** A wide pane splits to the right and a narrow or tall one down, so neither half turns unusable. */
60
+ async function splitDirection(): Promise<"right" | "down"> {
61
+ const { layout } = (await herdr(["pane", "layout", "--current"])) as { layout: PaneLayout };
62
+ const callerRect = layout.panes.find((pane) => pane.pane_id === process.env.HERDR_PANE_ID)?.rect;
63
+ if (!callerRect) return "right";
64
+ return callerRect.width >= callerRect.height * 2 ? "right" : "down";
65
+ }
66
+
67
+ /** Run one herdr CLI command and return its JSON `result`. Rejects with herdr's own message. */
68
+ function herdr(args: readonly string[]): Promise<unknown> {
69
+ return new Promise((resolve, reject) => {
70
+ childProcess.execFile("herdr", [...args], (error, stdout, stderr) => {
71
+ if (error) {
72
+ reject(new Error(herdrFailureMessage(error, stdout, stderr)));
73
+ return;
74
+ }
75
+ try {
76
+ resolve((JSON.parse(stdout) as { result: unknown }).result);
77
+ } catch (parseError) {
78
+ reject(parseError);
79
+ }
80
+ });
81
+ });
82
+ }
83
+
84
+ /** Herdr reports server errors as JSON on stderr and usage errors as plain text. */
85
+ function herdrFailureMessage(error: Error, stdout: string, stderr: string): string {
86
+ const text = (stderr || stdout || error.message).trim();
87
+ try {
88
+ return (JSON.parse(text) as { error?: { message?: string } }).error?.message ?? text;
89
+ } catch {
90
+ return text;
91
+ }
92
+ }
package/index.ts CHANGED
@@ -2,7 +2,7 @@ import type {
2
2
  MessageEndEvent,
3
3
  MessageEndEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { handleAttachCommand } from "./attach.js";
5
+ import { createHerdrDelivery, handleAttachCommand } from "./attach.js";
6
6
  import { registerAgentAutocomplete } from "./autocomplete.js";
7
7
  import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
8
8
  import type {
@@ -25,6 +25,7 @@ export default function userAgent(pi: ExtensionAPI): void {
25
25
  (message) => pi.sendMessage(message, { triggerTurn: true }),
26
26
  (sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
27
27
  createRebaseDelivery(pi, () => mainSessionContext),
28
+ createHerdrDelivery(() => mainSessionContext),
28
29
  );
29
30
  let shuttingDown = false;
30
31
  let nextAgentNumber = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -45,7 +45,7 @@
45
45
  ]
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-coding-agent": "0.84.2",
49
- "@earendil-works/pi-tui": "0.84.2"
48
+ "@earendil-works/pi-coding-agent": "0.85.1",
49
+ "@earendil-works/pi-tui": "0.85.1"
50
50
  }
51
51
  }
package/runner.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import {
@@ -16,7 +17,12 @@ import {
16
17
  SessionManager,
17
18
  SettingsManager,
18
19
  } from "@earendil-works/pi-coding-agent";
19
- import { type AgentValueValidator, parseAgentCommand } from "./command-line.js";
20
+ import {
21
+ type AgentValueValidator,
22
+ forwardedArgsForPane,
23
+ parseAgentCommand,
24
+ } from "./command-line.js";
25
+ import { herdrAgentName, insideHerdr, openPiInHerdrPane } from "./herdr.js";
20
26
  import { conversationFingerprint, mainContextFingerprint } from "./rebase.js";
21
27
  import type {
22
28
  AgentCommandName,
@@ -39,6 +45,7 @@ import {
39
45
  errorMessage,
40
46
  formatModel,
41
47
  formatModelLabel,
48
+ herdrPaneLabel,
42
49
  logSteering,
43
50
  REBASED_ENTRY_TYPE,
44
51
  } from "./shared.js";
@@ -91,20 +98,18 @@ async function startUserAgent(
91
98
  ctx: ExtensionCommandContext,
92
99
  ): Promise<void> {
93
100
  const parsed = parseAgentCommand(args, command);
101
+ if (parsed.herdr && !insideHerdr())
102
+ throw new Error(`/${command} -h needs Pi to run inside a herdr pane`);
94
103
  const parsedForwardedArgs = parseForwardedArgs(parsed.forwardedArgs);
95
- const services = await createChildServices(
96
- ctx,
97
- buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
98
- );
99
- const forwarded = resolveForwardedOptions(parsedForwardedArgs, services.modelRuntime);
104
+ const modelRuntime = parentModelRuntime(ctx);
105
+ const forwarded = resolveForwardedOptions(parsedForwardedArgs, modelRuntime);
100
106
  const selectedModel = forwarded.model ?? ctx.model;
101
107
  if (!selectedModel) throw new Error("No current model is selected; pass -m MODELNAME");
102
- const model =
103
- services.modelRuntime.getModel(selectedModel.provider, selectedModel.id) ?? selectedModel;
108
+ const model = modelRuntime.getModel(selectedModel.provider, selectedModel.id) ?? selectedModel;
104
109
  const thinkingLevel = forwarded.thinkingLevel ?? pi.getThinkingLevel();
105
110
  const inheritedMessages = parsed.isolate ? [] : buildInheritedMessages(ctx);
106
111
  // A real session file from birth: the child outlives the widget row and stays resumable.
107
- const childSessionManager = SessionManager.create(services.cwd, undefined, {
112
+ const childSessionManager = SessionManager.create(ctx.cwd, undefined, {
108
113
  parentSession: ctx.sessionManager.getSessionFile(),
109
114
  });
110
115
  // The dispatch record: /agent-attach restores tool and resource options from it later.
@@ -123,11 +128,32 @@ async function startUserAgent(
123
128
  conversationFingerprint(inheritedMessages),
124
129
  );
125
130
 
126
- runningAgents.add(runningAgent);
127
- logSteering(runningAgent.id, "agent-created", { command, taskLength: parsed.task.length });
128
131
  if (ctx.hasUI) {
129
132
  widget.setUI(ctx.ui);
130
133
  for (const warning of parsed.warnings) ctx.ui.notify(warning, "warning");
134
+ }
135
+ if (parsed.herdr) {
136
+ await dispatchToHerdrPane(
137
+ ctx,
138
+ widget,
139
+ childSessionManager,
140
+ inheritedMessages,
141
+ model,
142
+ thinkingLevel,
143
+ parsed,
144
+ runningAgent,
145
+ );
146
+ return;
147
+ }
148
+
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
+ );
154
+ runningAgents.add(runningAgent);
155
+ logSteering(runningAgent.id, "agent-created", { command, taskLength: parsed.task.length });
156
+ if (ctx.hasUI) {
131
157
  ctx.ui.notify(formatStartNotification(runningAgent), "info");
132
158
  widget.ensureTimer();
133
159
  widget.update();
@@ -153,6 +179,64 @@ async function startUserAgent(
153
179
  });
154
180
  }
155
181
 
182
+ /**
183
+ * An agent born in a herdr pane: the child file is flushed here, then the pane's Pi resumes it
184
+ * with the dispatch options as CLI flags and takes the preambled task as its first prompt.
185
+ * Nothing runs in this process, so the widget row is a pointer at the pane from birth.
186
+ */
187
+ async function dispatchToHerdrPane(
188
+ ctx: ExtensionCommandContext,
189
+ widget: UserAgentWidget,
190
+ childSessionManager: SessionManager,
191
+ inheritedMessages: AgentMessage[],
192
+ model: Model,
193
+ thinkingLevel: ThinkingLevel,
194
+ parsed: ParsedAgentCommand,
195
+ runningAgent: RunningAgent,
196
+ ): Promise<void> {
197
+ persistMessages(childSessionManager, inheritedMessages, []);
198
+ // Pi resumes a session with the model of whichever comes last on the path, a model_change entry
199
+ // or an assistant message, and writes model_change itself only on its empty-session path. Record
200
+ // both after the snapshot, or a later /agent-attach or /resume takes main's last answer's model.
201
+ childSessionManager.appendModelChange(model.provider, model.id);
202
+ childSessionManager.appendThinkingLevelChange(thinkingLevel);
203
+ const sessionFile = flushSessionFile(childSessionManager);
204
+ // Explicit on the CLI too: an isolated child's file has no messages, and pi then takes the
205
+ // flags over the file.
206
+ const paneId = await openPiInHerdrPane(ctx.cwd, herdrAgentName(runningAgent.sessionId), [
207
+ ...forwardedArgsForPane(parsed.forwardedArgs),
208
+ "--provider",
209
+ model.provider,
210
+ "--model",
211
+ model.id,
212
+ "--thinking",
213
+ thinkingLevel,
214
+ "--session",
215
+ sessionFile,
216
+ PANE_DISPATCH_PREAMBLE + parsed.task,
217
+ ]);
218
+ logSteering(runningAgent.id, "agent-dispatched-to-herdr", { paneId });
219
+ if (!ctx.hasUI) return;
220
+ widget.addHerdrDispatch(runningAgent, paneId);
221
+ ctx.ui.notify(`Started /${runningAgent.command} in ${herdrPaneLabel(paneId)}`, "info");
222
+ }
223
+
224
+ /**
225
+ * Make sure a child session file is on disk. Pi defers the file until the first assistant
226
+ * message, so a child whose inherited context has none is still in memory only, while a Pi in
227
+ * another pane can only resume a file that exists.
228
+ */
229
+ export function flushSessionFile(sessionManager: SessionManager): string {
230
+ const sessionFile = sessionManager.getSessionFile();
231
+ if (!sessionFile) throw new Error("The child session has no file path");
232
+ if (existsSync(sessionFile)) return sessionFile;
233
+ const lines = [sessionManager.getHeader(), ...sessionManager.getEntries()].map(
234
+ (entry) => `${JSON.stringify(entry)}\n`,
235
+ );
236
+ writeFileSync(sessionFile, lines.join(""), { flag: "wx" });
237
+ return sessionFile;
238
+ }
239
+
156
240
  function createRunningAgent(
157
241
  sequenceNumber: number,
158
242
  sessionId: string,
@@ -296,13 +380,19 @@ export function createChildServices(
296
380
  cwd: ctx.cwd,
297
381
  agentDir,
298
382
  settingsManager,
299
- // The extension facade wraps the parent's runtime as a plain property; sharing it keeps
300
- // extension-registered providers (their process-global dedup guard) available to the child.
301
- modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
383
+ modelRuntime: parentModelRuntime(ctx),
302
384
  resourceLoaderOptions,
303
385
  });
304
386
  }
305
387
 
388
+ /**
389
+ * The parent's ModelRuntime: the extension facade wraps it as a plain property. Sharing it keeps
390
+ * extension-registered providers (their process-global dedup guard) available to the child.
391
+ */
392
+ function parentModelRuntime(ctx: ExtensionCommandContext): ModelRuntime {
393
+ return (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime;
394
+ }
395
+
306
396
  /** Parse forwarded pi CLI tokens once before model and session resolution. */
307
397
  export function parseForwardedArgs(forwardedArgs: string[]): Args {
308
398
  const parsed = parseArgs(forwardedArgs);
@@ -516,9 +606,13 @@ export function reportAgentFailure(
516
606
  runningAgent.status = "posted";
517
607
  }
518
608
 
519
- /** Every dispatch prefixes its task with this; attach recognizes it as the child's dispatch boundary. */
609
+ /** Every background dispatch prefixes its task with this; attach recognizes it as the child's dispatch boundary. */
520
610
  export const DISPATCH_PREAMBLE =
521
611
  "You are running in an ephemeral, forked background process now, concurrently with the main session. ";
612
+ /** The -h dispatch's preamble: that child is interactive, and the user may talk to it in its pane. */
613
+ export const PANE_DISPATCH_PREAMBLE =
614
+ "You are running in a forked session now, in a herdr pane beside the main session. The user may talk to you here. ";
615
+ export const DISPATCH_PREAMBLES = [DISPATCH_PREAMBLE, PANE_DISPATCH_PREAMBLE] as const;
522
616
 
523
617
  /** Run turns on an initialized child session until it posts, retires, or shuts down. The initial instruction is sent verbatim. */
524
618
  export async function runChildTurns(
Binary file
package/shared.ts CHANGED
@@ -28,6 +28,8 @@ export type AgentStatus =
28
28
  export type ParsedAgentCommand = {
29
29
  isolate: boolean;
30
30
  squash: boolean;
31
+ /** -h/--herdr: open the agent in a new herdr pane instead of running it in the background. */
32
+ herdr: boolean;
31
33
  /** Leading pi CLI tokens (minus the extension's own options) to forward to the child, e.g. ["--thinking", "high"]. */
32
34
  forwardedArgs: string[];
33
35
  task: string;
@@ -162,6 +164,8 @@ export type CompletedAgent = {
162
164
  toolUses: number;
163
165
  turnCount: number;
164
166
  contextPercent?: number;
167
+ /** The herdr pane whose Pi now owns the session, once the agent was opened there. */
168
+ herdrPane?: string;
165
169
  };
166
170
 
167
171
  /** The mechanism that fast-forwards a child conversation onto the main session, injected into the widget. */
@@ -172,6 +176,21 @@ export type RebaseDelivery = {
172
176
  deliver(agent: RunningAgent | CompletedAgent, messages: AgentMessage[]): void;
173
177
  };
174
178
 
179
+ /** A pane split for an agent, whose Pi starts only once the agent's session is free here. */
180
+ export type HerdrPane = {
181
+ paneId: string;
182
+ /** Start pi on the agent's session file in this pane; resolves once it is ready for input. */
183
+ start(): Promise<void>;
184
+ };
185
+
186
+ /** The mechanism that hands an agent's session to a Pi in a new herdr pane, injected into the widget. */
187
+ export type HerdrDelivery = {
188
+ /** Whether this Pi runs inside a herdr pane, so new panes can be split from it. */
189
+ available(): boolean;
190
+ /** Resolve the agent's session file and split a pane for it; rejects before splitting when there is no file. */
191
+ split(agent: RunningAgent | CompletedAgent): Promise<HerdrPane>;
192
+ };
193
+
175
194
  /** How long a confirmation stays armed, and how long a transient footer notice shows. */
176
195
  export const CONFIRMATION_WINDOW_MS = 4000;
177
196
 
@@ -266,6 +285,13 @@ export function mainContextLabel(state: MainContextState | undefined): string |
266
285
  return undefined;
267
286
  }
268
287
 
288
+ /**
289
+ * @example herdrPaneLabel("w1:p3") // "herdr pane w1:p3"
290
+ */
291
+ export function herdrPaneLabel(paneId: string): string {
292
+ return `herdr pane ${paneId}`;
293
+ }
294
+
269
295
  export function formatTurns(turnCount: number): string {
270
296
  return `↻${turnCount}`;
271
297
  }