@tshu-w/pi-agents 0.4.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1] - 2026-11-04
4
+
5
+ ### Added
6
+
7
+ - `pd main --name <name>` opens a main Agent per name, so that each channel can have its own.
8
+
9
+ ### Changed
10
+
11
+ - Inputs answered by the same turn share one notification.
12
+
3
13
  ## [0.4.0] - 2026-11-04
4
14
 
5
15
  ### Added
package/bin/pd.mjs CHANGED
@@ -155,20 +155,24 @@ function headlessMode() {
155
155
  if (!interactive || parsed.print) return 'text';
156
156
  }
157
157
 
158
- /** Opens the main Agent that `main.json` names, from any directory, or starts one in `~` if there is none. */
158
+ /** Opens the main Agent of the entry `--name` picks (default `main`), from any directory, or starts one in `~` if there is none. */
159
159
  async function openMain() {
160
- const options = args.slice(1);
160
+ const name = parseArgs(args.slice(1)).name ?? 'main';
161
+ // `--name` picks the entry, so it does not rename the Session.
162
+ const options = argGroups(parseArgs, args.slice(1)).filter(([option]) => !['--name', '-n'].includes(option)).flat();
161
163
  let file;
162
- try { file = JSON.parse(readFileSync(paths.mainSession, 'utf8')).sessionFile; }
163
- catch (failure) { if (failure.code !== 'ENOENT') throw failure; }
164
+ // `main.json` maps entry names to Session files; an old one holds `{ sessionFile }` for `main`.
165
+ try {
166
+ const entries = JSON.parse(readFileSync(paths.mainSession, 'utf8'));
167
+ file = typeof entries.sessionFile === 'string' ? { main: entries.sessionFile }[name] : entries[name];
168
+ } catch (failure) { if (failure.code !== 'ENOENT') throw failure; }
164
169
  // A main Agent without messages has no file yet, but its host may be running.
165
170
  const holds = host => hostSessionFile(host) === file;
166
171
  if (file && (existsSync(file) || (await settledHosts(paths, holds)).some(holds))) args = ['--session', file, ...options];
167
172
  else {
168
173
  process.chdir(homedir());
169
- // These go first, as messages may follow `--`, and `--main` before a message would take it as its value.
170
- // A `--name` among the options comes later and wins.
171
- args = ['--main', '--name', 'main', ...options];
174
+ // These go first, as messages may follow `--`; with `=`, `--main` takes no other argument as its value.
175
+ args = [`--main=${name}`, '--name', name, ...options];
172
176
  }
173
177
  parsed = parseArgs(args);
174
178
  }
package/docs/HOSTS.md CHANGED
@@ -16,7 +16,7 @@ Files live in `${XDG_STATE_HOME:-~/.local/state}/pi/agents/`:
16
16
 
17
17
  - `workbench.json`: seen marks and which Sessions are listed.
18
18
  - `host.log`, `daemon.log`: logs.
19
- - `main.json`: which Session is the main Agent.
19
+ - `main.json`: which Session is the main Agent of each entry name.
20
20
 
21
21
  Sockets and Session locks go in `$XDG_RUNTIME_DIR/pi-agents/`. macOS has no `XDG_RUNTIME_DIR`, so there they go in `runtime/` under the state directory.
22
22
 
@@ -36,7 +36,7 @@ pi install "$(npm root -g)/@tshu-w/pi-agents"
36
36
  - `pd [pi options]` starts Pi interactively in a new host and attaches the terminal. If the Session is already running in a host, `pd` attaches to it instead, sending it the messages as follow-ups and ignoring other options.
37
37
  - `pd agents` opens the workbench.
38
38
  - `pd attach <session>` attaches to a Session, starting it first if it is not running.
39
- - `pd main [pi options]` opens the main Agent, which hands work to other Agents, from any directory; `/new` in it starts a new one.
39
+ - `pd main [--name <name>] [pi options]` opens the main Agent for `<name>` (default `main`), which hands work to other Agents, from any directory; `/new` in it starts a new one.
40
40
  - Non-interactive starts (`-p`, `--mode json`, `--mode rpc`, piped stdin) work as in Pi, on the Session in a host, so several of them and an attached terminal can use one Session at once. Their commands act on that Session, as `abort` stops whichever turn is running. Unlike Pi:
41
41
  - Dialogs are not sent to them. A dialog waits while a terminal is attached and is cancelled otherwise.
42
42
  - Extensions see interactive mode, so what an extension writes to stdout does not reach them; `/program` prints as with `pi -p`.
package/docs/SPEC.md CHANGED
@@ -38,7 +38,7 @@ When the user of a root Agent clears its queued messages, messages from other Ag
38
38
 
39
39
  ### 2.3 wait
40
40
 
41
- `wait` waits until owned Agents have answered the inputs sent to them. It can target specific Agents and take a timeout.
41
+ `wait` waits until owned Agents have finished the inputs sent to them. It can target specific Agents and take a timeout.
42
42
 
43
43
  ### 2.4 list
44
44
 
@@ -52,11 +52,11 @@ When the user of a root Agent clears its queued messages, messages from other Ag
52
52
 
53
53
  An Agent is `running` while a turn is in progress, `waiting` while its turn waits in `wait` (§4), `queued` while its next turn waits for a slot (§4), and `idle` otherwise. An Agent goes offline when its runtime unloads it; it stays `idle`.
54
54
 
55
- `send` returns once the recipient's runtime accepts the message, waking it if needed. Acceptance does not mean the recipient has handled it; a message not yet handled is lost if the recipient exits.
55
+ `send` returns once the recipient's runtime accepts the message, waking it if needed. Acceptance does not mean the recipient has handled it (§10).
56
56
 
57
57
  An input to an offline Agent wakes it; a write to one is rejected.
58
58
 
59
- Owned Agents run independently of their owner's turns. When their owner goes offline, they go offline too, from the bottom of the tree up, and their unfinished inputs end as `aborted`. They return as `idle` when their owner is loaded again.
59
+ Owned Agents run independently of their owner's turns and go offline with it (§10). They return as `idle` when their owner is loaded again.
60
60
 
61
61
  On abort or when going offline, work that has not stopped within a timeout is abandoned: it no longer counts as live work and may keep running until the process exits; the timeout is reported.
62
62
 
@@ -76,7 +76,7 @@ An input is outstanding from acceptance until its turn ends or it is withdrawn.
76
76
 
77
77
  An input sent to an owned Agent ends as `completed` when its turn answers it, `failed` when its turn fails, or `aborted` when it is aborted or withdrawn. Its result is the answer or the error. Only inputs from the owner have results; the recipient replies to other senders with `send`.
78
78
 
79
- When an input ends, its owner receives a notification carrying the result, unless the owner aborted it or is going offline. The notification is an input delivered as `steer` (a write after the owner's turn is stopped, §2.2).
79
+ When an input ends, its owner receives a notification carrying the result, unless the owner aborted it or is going offline. Inputs that a turn ends with the same outcome and result share one notification. The notification is an input delivered as `steer` (a write after the owner's turn is stopped, §2.2).
80
80
 
81
81
  `wait` ends when the Agents it waits for have no pending inputs, when its timeout expires, or when a steer input arrives; a write does not end it. Their notifications are held until it ends, then delivered as one.
82
82
 
@@ -362,3 +362,10 @@ Invalid settings produce a startup warning, and the defaults are used.
362
362
  Each Agent is a persistent Pi Session. A root Agent is an ordinary Session. An owned Agent is a child Session that records its owner and root, and runs in its owner's process.
363
363
 
364
364
  A delivered message enters the recipient's current branch. An offline root Agent is started in a host, detached, with its saved working directory and model and the current configuration (HOSTS §4). If a root process cannot finish cleanup within a grace period on exit, it is forcibly terminated. Before shutting down an idle Session, another extension can emit `busy:query` on `pi.events` with `{ busy: false }`; it is set to `true` while the tree has live work. One runtime uses a Session at a time. If a Session is occupied at startup, Pi reports it and exits.
365
+
366
+ ## 10. Limitations
367
+
368
+ In this version:
369
+
370
+ - A message accepted but not yet handled is lost if the recipient exits.
371
+ - When an owner goes offline, its owned Agents go offline too, from the bottom of the tree up, and their unfinished inputs end as `aborted`, as do inputs unfinished when a process exits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tshu-w/pi-agents",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Multi-agent runtime for Pi: persistent async agents, dynamic workflows, and inter-agent messaging",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -549,7 +549,7 @@ export class Agents {
549
549
  const turn = agent.turn;
550
550
  if (!turn && agent.queue.length === 0) return false;
551
551
  if (silent) for (const input of [...agent.queue, ...(turn?.inputs ?? [])]) input.silent = true;
552
- for (const input of agent.queue.splice(0)) this.end(agent, input, "aborted", "");
552
+ this.end(agent, agent.queue.splice(0), "aborted", "");
553
553
  this.changed();
554
554
  if (turn) await this.stop(agent, turn, deadline);
555
555
  return true;
@@ -560,7 +560,7 @@ export class Agents {
560
560
  const index = agent.queue.indexOf(input);
561
561
  if (index !== -1) {
562
562
  agent.queue.splice(index, 1);
563
- this.end(agent, input, "aborted", "");
563
+ this.end(agent, [input], "aborted", "");
564
564
  this.changed();
565
565
  } else if (agent.turn?.inputs.includes(input)) {
566
566
  await this.stop(agent, agent.turn, Date.now() + SHUTDOWN_TIMEOUT_MS);
@@ -736,31 +736,40 @@ export class Agents {
736
736
  turn.finished = true;
737
737
  if (turn.slot) scheduler.release(this.self.rootId, agent.record.id);
738
738
  if (agent.turn === turn) agent.turn = undefined;
739
- for (const input of turn.inputs) this.end(agent, input, answer.outcome, answer.result);
739
+ this.end(agent, turn.inputs, answer.outcome, answer.result);
740
740
  turn.done.resolve();
741
741
  this.changed();
742
742
  this.pump(agent);
743
743
  }
744
744
 
745
- private end(agent: Owned, input: Input, outcome: Outcome, result: string): void {
746
- if (input.permit) scheduler.releaseOutstanding(this.self.rootId, input.permit);
747
- input.permit = undefined;
748
- const record = input.record;
749
- if (!record || ended(record)) return;
750
- if (input.schema !== undefined && outcome !== "aborted") {
751
- if (input.submitted) {
752
- outcome = "completed";
753
- record.value = input.submitted.value;
754
- } else if (outcome === "completed") {
755
- outcome = "failed";
756
- result = `The turn ended without calling ${SUBMIT_RESULT_TOOL_NAME}.`;
745
+ /** Ends inputs together; those that end with the same state and result share one notification. */
746
+ private end(agent: Owned, inputs: Input[], outcome: Outcome, result: string): void {
747
+ const answers = new Map<string, InputRecord>();
748
+ let changed = false;
749
+ for (const input of inputs) {
750
+ if (input.permit) scheduler.releaseOutstanding(this.self.rootId, input.permit);
751
+ input.permit = undefined;
752
+ const record = input.record;
753
+ if (!record || ended(record)) continue;
754
+ changed = true;
755
+ record.state = outcome;
756
+ record.result = result;
757
+ if (input.schema !== undefined && outcome !== "aborted") {
758
+ if (input.submitted) {
759
+ record.state = "completed";
760
+ record.value = input.submitted.value;
761
+ } else if (outcome === "completed") {
762
+ record.state = "failed";
763
+ record.result = `The turn ended without calling ${SUBMIT_RESULT_TOOL_NAME}.`;
764
+ }
757
765
  }
766
+ input.done?.resolve();
767
+ if (input.silent) continue;
768
+ const key = JSON.stringify([record.state, record.result]);
769
+ if (!answers.has(key)) answers.set(key, record);
758
770
  }
759
- record.state = outcome;
760
- record.result = result;
761
- this.persist(agent);
762
- input.done?.resolve();
763
- if (!input.silent) this.notifyLater(agent, record);
771
+ if (changed) this.persist(agent);
772
+ for (const record of answers.values()) this.notifyLater(agent, record);
764
773
  }
765
774
 
766
775
  private notifyLater(agent: Owned, record: InputRecord): void {
package/src/main-agent.ts CHANGED
@@ -7,21 +7,22 @@ import { statePaths } from "./roots/paths.mjs";
7
7
  export const MAIN_FLAG = "main";
8
8
  const instructions = readFileSync(new URL("./main-agent.md", import.meta.url), "utf8").trim();
9
9
 
10
- /** The main Agent's Session file, as `main.json` names it. */
11
- function mainSessionFile(): string | undefined {
10
+ /** The main Agents' Session files by entry name, as `main.json` maps them; an old `{ sessionFile }` is the entry `main`. */
11
+ function mainEntries(): Record<string, string> {
12
12
  try {
13
- return (JSON.parse(readFileSync(statePaths().mainSession, "utf8")) as { sessionFile?: string }).sessionFile;
13
+ const entries = JSON.parse(readFileSync(statePaths().mainSession, "utf8")) as Record<string, string>;
14
+ return typeof entries.sessionFile === "string" ? { main: entries.sessionFile } : entries;
14
15
  } catch {
15
- return undefined;
16
+ return {};
16
17
  }
17
18
  }
18
19
 
19
20
  /**
20
- * The main Agent is the Session `main.json` names: `pd main` starts one with `--main`, and `/new` in it starts the
21
- * next. It gets the main Agent's instructions.
21
+ * A main Agent is a Session an entry of `main.json` names: `pd main --name <entry>` starts one with `--main=<entry>`,
22
+ * and `/new` in it starts the next for the same entry. It gets the main Agent's instructions.
22
23
  */
23
24
  export function registerMainAgent(pi: ExtensionAPI): void {
24
- pi.registerFlag(MAIN_FLAG, { type: "boolean", description: "Start the main Agent (used by pd main)" });
25
+ pi.registerFlag(MAIN_FLAG, { type: "string", description: "Start the main Agent of this entry (used by pd main)" });
25
26
  let main = false;
26
27
  pi.on("session_start", (event, ctx) => {
27
28
  main = false;
@@ -29,17 +30,22 @@ export function registerMainAgent(pi: ExtensionAPI): void {
29
30
  // Absolute, as `pd main` compares it from other directories; a session directory may be relative.
30
31
  const current = ctx.sessionManager.getSessionFile();
31
32
  const file = current && path.resolve(current);
32
- const started = event.reason === "startup" && pi.getFlag(MAIN_FLAG) === true;
33
- const renewed = event.reason === "new" && event.previousSessionFile !== undefined && path.resolve(event.previousSessionFile) === mainSessionFile();
34
- if (file && (started || renewed)) {
33
+ const mains = mainEntries();
34
+ const flag = pi.getFlag(MAIN_FLAG);
35
+ const previous = event.reason === "new" && event.previousSessionFile !== undefined ? path.resolve(event.previousSessionFile) : undefined;
36
+ const names = event.reason === "startup" && typeof flag === "string"
37
+ ? [flag]
38
+ : Object.keys(mains).filter((name) => previous !== undefined && mains[name] === previous);
39
+ if (file && names.length > 0) {
40
+ for (const name of names) mains[name] = file;
35
41
  const record = statePaths().mainSession;
36
42
  mkdirSync(path.dirname(record), { recursive: true, mode: 0o700 });
37
43
  // Replaced at once, so that a reader never sees it half written.
38
44
  const temporary = `${record}.${process.pid}`;
39
- writeFileSync(temporary, JSON.stringify({ sessionFile: file }));
45
+ writeFileSync(temporary, JSON.stringify(mains), { mode: 0o600 });
40
46
  renameSync(temporary, record);
41
47
  }
42
- main = file !== undefined && file === mainSessionFile();
48
+ main = file !== undefined && Object.values(mains).includes(file);
43
49
  });
44
50
  pi.on("before_agent_start", (event) => {
45
51
  if (main) event.systemPromptOptions.sections["main-agent"] = instructions;