@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 +10 -0
- package/bin/pd.mjs +11 -7
- package/docs/HOSTS.md +2 -2
- package/docs/SPEC.md +11 -4
- package/package.json +1 -1
- package/src/agents/agents.ts +29 -20
- package/src/main-agent.ts +18 -12
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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
170
|
-
|
|
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
|
|
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
|
|
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
|
|
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
package/src/agents/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
760
|
-
record.
|
|
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
|
|
11
|
-
function
|
|
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
|
-
|
|
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
|
|
16
|
+
return {};
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
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: "
|
|
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
|
|
33
|
-
const
|
|
34
|
-
|
|
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({
|
|
45
|
+
writeFileSync(temporary, JSON.stringify(mains), { mode: 0o600 });
|
|
40
46
|
renameSync(temporary, record);
|
|
41
47
|
}
|
|
42
|
-
main = file !== undefined &&
|
|
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;
|