pi-onlyne 1.2.2 → 2.0.0
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/README.md +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -301
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +90 -26
- package/src/pi-surface.test.mjs +76 -4
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -59
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The other question a background extension makes necessary: is work this
|
|
2
|
+
// session dispatched through the `Agent` tool still running where the agent loop
|
|
3
|
+
// cannot see it?
|
|
4
|
+
//
|
|
5
|
+
// The subagents extension is not one of the `pi-background-tasks` family, and
|
|
6
|
+
// its `Agent` call is not one of those tools. A field test on one session showed
|
|
7
|
+
// all five `Agent` calls issued in a single assistant message, each returning at
|
|
8
|
+
// once with `Agent started in background` and an output file under
|
|
9
|
+
// `/tmp/pi-subagents-<uid>/` — so the tool hands back a handle and the child
|
|
10
|
+
// carries on, and no `tool_execution_start` / `tool_execution_end` pair brackets
|
|
11
|
+
// it. The tool is not pi's own either: the string does not appear in pi's dist,
|
|
12
|
+
// and pi's extension API exposes no subagent or background-task query at all,
|
|
13
|
+
// only `getActiveTools` / `getAllTools` / `setActiveTools`. So there is no event
|
|
14
|
+
// to subscribe to and no frame to ask for; what the extension leaves behind is
|
|
15
|
+
// an on-disk registry at `~/.pi/subagents/missions/<uuid>.json`, and this probe
|
|
16
|
+
// reads that.
|
|
17
|
+
//
|
|
18
|
+
// Every failure mode is inert, exactly as in `background-work.mjs`: no
|
|
19
|
+
// directory, an unreadable file, a missing field, malformed JSON, or a read that
|
|
20
|
+
// throws all read as "no subagent work known" and leave the plugin's own
|
|
21
|
+
// judgement untouched. A probe that throws is worse than one that answers
|
|
22
|
+
// nothing.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mission statuses that mean the work is over.
|
|
30
|
+
*
|
|
31
|
+
* The live spelling was never observed. The machine this was measured on had
|
|
32
|
+
* four missions, all of them terminal (`completed`, `failed`, `cancelled` for
|
|
33
|
+
* missions; `completed`, `failed` for `workflowChildren`; `stopped`, `complete`,
|
|
34
|
+
* `failed` for `runs[].status`), so the running value is unenumerated. The safe
|
|
35
|
+
* direction is therefore "anything not on this list is live": guessing the other
|
|
36
|
+
* way would read a genuinely running session as idle, which is the exact bug
|
|
37
|
+
* this probe exists to fix. The cost of being wrong this way is a session that
|
|
38
|
+
* waits a little longer than it had to — a new terminal spelling delays the
|
|
39
|
+
* phase, it does not lose work.
|
|
40
|
+
*/
|
|
41
|
+
export const TERMINAL_MISSION_STATUSES = Object.freeze([
|
|
42
|
+
"cancelled",
|
|
43
|
+
"complete",
|
|
44
|
+
"completed",
|
|
45
|
+
"failed",
|
|
46
|
+
"stopped",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How many mission files one call will open. The registry is one JSON object per
|
|
51
|
+
* mission and grows without bound over a machine's life, so an uncapped scan
|
|
52
|
+
* would make the probe cost a function of history while the caller only ever
|
|
53
|
+
* cares about the present. The cap trades that for a bounded read: a session
|
|
54
|
+
* whose live work sits past the cap is reported as not running, the same fail-safe
|
|
55
|
+
* direction as every other answer here, and the newest files are read first
|
|
56
|
+
* because those are the ones just dispatched.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_MAX_MISSION_FILES = 200;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How many bytes one mission file may be before it is skipped. A mission
|
|
62
|
+
* transcript, not a mission, is the large artefact; anything past this is not
|
|
63
|
+
* the status record this probe reads.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_MAX_MISSION_BYTES = 256 * 1024;
|
|
66
|
+
|
|
67
|
+
/** @param {unknown} status */
|
|
68
|
+
export function isTerminalMissionStatus(status) {
|
|
69
|
+
return typeof status === "string" && TERMINAL_MISSION_STATUSES.includes(status);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} mission
|
|
74
|
+
* @param {string | null} sessionId this session's id, or `null` when unknown
|
|
75
|
+
*/
|
|
76
|
+
export function isLiveMission(mission, sessionId = null) {
|
|
77
|
+
if (!mission || typeof mission !== "object") return false;
|
|
78
|
+
// A mission with no status is not live: absence of evidence is absence of
|
|
79
|
+
// work, the same direction `background-work.mjs` fails safe in.
|
|
80
|
+
if (typeof mission.status !== "string" || !mission.status) return false;
|
|
81
|
+
if (isTerminalMissionStatus(mission.status)) return false;
|
|
82
|
+
const owner = mission.ownerSessionId;
|
|
83
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
84
|
+
// Scope by owner so the answer is "is *this* session's work still running",
|
|
85
|
+
// not "is anything on this machine running".
|
|
86
|
+
return typeof owner === "string" && owner === sessionId;
|
|
87
|
+
}
|
|
88
|
+
// The caller cannot name its own session, so a mission naming no session still
|
|
89
|
+
// counts: watching a silent subset would be worse than watching too much.
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Newest first, so a capped scan spends its budget on the missions just dispatched. */
|
|
94
|
+
function byNewestFirst(left, right) {
|
|
95
|
+
const a = right.mtimeMs ?? 0;
|
|
96
|
+
const b = left.mtimeMs ?? 0;
|
|
97
|
+
if (a !== b) return a - b;
|
|
98
|
+
return String(right.name).localeCompare(String(left.name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @returns {string[]} the `.json` mission files, newest first and already capped. */
|
|
102
|
+
function missionFiles(directory, maxFiles) {
|
|
103
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
104
|
+
return entries
|
|
105
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
106
|
+
.map((entry) => {
|
|
107
|
+
let mtimeMs = 0;
|
|
108
|
+
try {
|
|
109
|
+
mtimeMs = fs.statSync(path.join(directory, entry.name)).mtimeMs;
|
|
110
|
+
} catch {
|
|
111
|
+
/* an entry that will not stat sorts as oldest and may be cut by the cap */
|
|
112
|
+
}
|
|
113
|
+
return { name: entry.name, mtimeMs };
|
|
114
|
+
})
|
|
115
|
+
.sort(byNewestFirst)
|
|
116
|
+
.slice(0, maxFiles)
|
|
117
|
+
.map((entry) => path.join(directory, entry.name));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readMission(file, maxBytes) {
|
|
121
|
+
if (fs.statSync(file).size > maxBytes) return null;
|
|
122
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One live-mission question, asked of the on-disk registry and answered by
|
|
127
|
+
* whatever the subagents extension left there. Inert by construction: a throw
|
|
128
|
+
* anywhere inside resolves to `false`.
|
|
129
|
+
*
|
|
130
|
+
* @param {{
|
|
131
|
+
* homeDir?: string,
|
|
132
|
+
* getSessionId?: () => string | null,
|
|
133
|
+
* log?: (line: string) => void,
|
|
134
|
+
* maxFiles?: number,
|
|
135
|
+
* maxBytes?: number,
|
|
136
|
+
* }} options
|
|
137
|
+
*/
|
|
138
|
+
export function createSubagentProbe({
|
|
139
|
+
homeDir = os.homedir(),
|
|
140
|
+
getSessionId = null,
|
|
141
|
+
log = () => {},
|
|
142
|
+
maxFiles = DEFAULT_MAX_MISSION_FILES,
|
|
143
|
+
maxBytes = DEFAULT_MAX_MISSION_BYTES,
|
|
144
|
+
} = {}) {
|
|
145
|
+
const warned = new Set();
|
|
146
|
+
|
|
147
|
+
const warnOnce = (reason) => {
|
|
148
|
+
if (warned.has(reason)) return;
|
|
149
|
+
warned.add(reason);
|
|
150
|
+
log(`subagent work: ${reason}`);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const sessionId = () => {
|
|
154
|
+
if (typeof getSessionId !== "function") return null;
|
|
155
|
+
return getSessionId() ?? null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @returns {Promise<boolean>}
|
|
160
|
+
*/
|
|
161
|
+
function running() {
|
|
162
|
+
try {
|
|
163
|
+
const directory = path.join(homeDir, ".pi", "subagents", "missions");
|
|
164
|
+
if (!fs.existsSync(directory)) {
|
|
165
|
+
// No registry at all means the extension has never run here; that is a
|
|
166
|
+
// steady state rather than a fault, so it is recorded once.
|
|
167
|
+
warnOnce("no subagent registry in this home");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
for (const file of missionFiles(directory, maxFiles)) {
|
|
171
|
+
let mission = null;
|
|
172
|
+
try {
|
|
173
|
+
mission = readMission(file, maxBytes);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
warnOnce(`mission unreadable: ${error.message}`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (mission === null) {
|
|
179
|
+
warnOnce("mission record too large to be a status record");
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (isLiveMission(mission, sessionId())) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
warnOnce(`registry unreadable: ${error.message}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { running };
|
|
192
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { createSubagentProbe } from "./background-subagents.mjs";
|
|
8
|
+
|
|
9
|
+
const SESSION = "s-ours";
|
|
10
|
+
const OTHER = "s-theirs";
|
|
11
|
+
|
|
12
|
+
/** A throwaway home with a missions registry, as the extension leaves it. */
|
|
13
|
+
function homeWith(files) {
|
|
14
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "onlyne-subagents-"));
|
|
15
|
+
const missions = path.join(homeDir, ".pi", "subagents", "missions");
|
|
16
|
+
fs.mkdirSync(missions, { recursive: true });
|
|
17
|
+
for (const [name, body] of Object.entries(files)) {
|
|
18
|
+
fs.writeFileSync(path.join(missions, name), typeof body === "string" ? body : JSON.stringify(body));
|
|
19
|
+
}
|
|
20
|
+
return { homeDir, missions };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function probeFor(homeDir, sessionId = SESSION) {
|
|
24
|
+
const logs = [];
|
|
25
|
+
const probe = createSubagentProbe({
|
|
26
|
+
homeDir,
|
|
27
|
+
getSessionId: () => sessionId,
|
|
28
|
+
log: (line) => logs.push(line),
|
|
29
|
+
});
|
|
30
|
+
return { logs, probe };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("a registry of terminal missions reads as not running", async () => {
|
|
34
|
+
const { homeDir } = homeWith({
|
|
35
|
+
"a.json": { id: "a", status: "completed", ownerSessionId: SESSION },
|
|
36
|
+
"b.json": { id: "b", status: "failed", ownerSessionId: SESSION },
|
|
37
|
+
"c.json": { id: "c", status: "cancelled", ownerSessionId: SESSION },
|
|
38
|
+
});
|
|
39
|
+
const { probe } = probeFor(homeDir);
|
|
40
|
+
|
|
41
|
+
assert.equal(await probe.running(), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a mission whose status is not terminal reads as running", async () => {
|
|
45
|
+
// The live spelling was never observed, so any unrecognised value is treated as
|
|
46
|
+
// live; that is the safe direction, and this is what it looks like in a test.
|
|
47
|
+
const { homeDir } = homeWith({
|
|
48
|
+
"a.json": { id: "a", status: "running", ownerSessionId: SESSION },
|
|
49
|
+
"b.json": { id: "b", status: "something-new", ownerSessionId: SESSION },
|
|
50
|
+
});
|
|
51
|
+
const { probe } = probeFor(homeDir);
|
|
52
|
+
|
|
53
|
+
assert.equal(await probe.running(), true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a mission owned by this session reads as running and another session's does not", async () => {
|
|
57
|
+
const theirs = homeWith({ "a.json": { id: "a", status: "running", ownerSessionId: OTHER } });
|
|
58
|
+
const { probe: other } = probeFor(theirs.homeDir, SESSION);
|
|
59
|
+
assert.equal(await other.running(), false, "another session's live mission is not this session's work");
|
|
60
|
+
|
|
61
|
+
const ours = homeWith({ "a.json": { id: "a", status: "running", ownerSessionId: SESSION } });
|
|
62
|
+
const { probe: mine } = probeFor(ours.homeDir, SESSION);
|
|
63
|
+
assert.equal(await mine.running(), true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a caller that cannot name its own session counts a mission naming none", async () => {
|
|
67
|
+
const { homeDir } = homeWith({ "a.json": { id: "a", status: "running" } });
|
|
68
|
+
const { probe } = probeFor(homeDir, null);
|
|
69
|
+
|
|
70
|
+
assert.equal(await probe.running(), true, "watching a silent subset is worse than watching too much");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a missing registry reads as not running and does not throw", async () => {
|
|
74
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "onlyne-subagents-empty-"));
|
|
75
|
+
const { logs, probe } = probeFor(homeDir);
|
|
76
|
+
|
|
77
|
+
assert.equal(await probe.running(), false);
|
|
78
|
+
assert.equal(logs.length, 1, "the missing registry is said once");
|
|
79
|
+
assert.equal(await probe.running(), false);
|
|
80
|
+
assert.equal(logs.length, 1, "and not repeated on the next call");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a missions path that is a file rather than a directory reads as not running", async () => {
|
|
84
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "onlyne-subagents-file-"));
|
|
85
|
+
const missions = path.join(homeDir, ".pi", "subagents");
|
|
86
|
+
fs.mkdirSync(missions, { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(missions, "missions"), "not a directory");
|
|
88
|
+
const { probe } = probeFor(homeDir);
|
|
89
|
+
|
|
90
|
+
assert.equal(await probe.running(), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("an unreadable mission file reads as not running and does not throw", async () => {
|
|
94
|
+
const { homeDir, missions } = homeWith({ "a.json": { id: "a", status: "completed" } });
|
|
95
|
+
fs.writeFileSync(path.join(missions, "b.json"), "{ not json");
|
|
96
|
+
fs.mkdirSync(path.join(missions, "c.json")); // a directory where a file is expected
|
|
97
|
+
fs.chmodSync(missions, 0o000);
|
|
98
|
+
const { probe } = probeFor(homeDir);
|
|
99
|
+
try {
|
|
100
|
+
assert.equal(await probe.running(), false);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.chmodSync(missions, 0o755);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a mission with no status reads as not running", async () => {
|
|
107
|
+
const { homeDir } = homeWith({
|
|
108
|
+
"a.json": { id: "a", ownerSessionId: SESSION },
|
|
109
|
+
"b.json": { id: "b", status: null, ownerSessionId: SESSION },
|
|
110
|
+
"c.json": { id: "c", status: 42, ownerSessionId: SESSION },
|
|
111
|
+
});
|
|
112
|
+
const { probe } = probeFor(homeDir);
|
|
113
|
+
|
|
114
|
+
assert.equal(await probe.running(), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("malformed JSON in a live-looking registry still answers", async () => {
|
|
118
|
+
const { homeDir, missions } = homeWith({ "a.json": "<<<not json>>>" });
|
|
119
|
+
fs.writeFileSync(path.join(missions, "b.json"), JSON.stringify({ status: "running", ownerSessionId: SESSION }));
|
|
120
|
+
const { probe } = probeFor(homeDir);
|
|
121
|
+
|
|
122
|
+
assert.equal(await probe.running(), true, "one bad file does not hide a live mission");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a mission larger than the byte bound is skipped, and the rest of the registry still answers", async () => {
|
|
126
|
+
const { homeDir, missions } = homeWith({
|
|
127
|
+
"a.json": { id: "a", status: "completed", ownerSessionId: SESSION, summary: "x".repeat(4096) },
|
|
128
|
+
});
|
|
129
|
+
fs.writeFileSync(path.join(missions, "huge.json"), JSON.stringify({
|
|
130
|
+
status: "running",
|
|
131
|
+
ownerSessionId: SESSION,
|
|
132
|
+
transcript: "x".repeat(300 * 1024),
|
|
133
|
+
}));
|
|
134
|
+
const { logs, probe } = probeFor(homeDir);
|
|
135
|
+
|
|
136
|
+
assert.equal(await probe.running(), false);
|
|
137
|
+
assert.equal(logs.some((line) => line.includes("too large")), true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a capped read spends its budget on the newest missions", async () => {
|
|
141
|
+
// The cap is asserted by which file the probe read, so each case puts the live
|
|
142
|
+
// mission on one side of the cap and the terminal one on the other.
|
|
143
|
+
const registry = {
|
|
144
|
+
"old.json": { id: "old", status: "running", ownerSessionId: SESSION },
|
|
145
|
+
"new.json": { id: "new", status: "completed", ownerSessionId: SESSION },
|
|
146
|
+
};
|
|
147
|
+
const { homeDir, missions } = homeWith(registry);
|
|
148
|
+
const old = new Date(Date.now() - 60_000);
|
|
149
|
+
fs.utimesSync(path.join(missions, "old.json"), old, old);
|
|
150
|
+
const capped = createSubagentProbe({ homeDir, getSessionId: () => SESSION, maxFiles: 1 });
|
|
151
|
+
|
|
152
|
+
// Only the newer file is inside the cap, and it is terminal.
|
|
153
|
+
assert.equal(await capped.running(), false);
|
|
154
|
+
|
|
155
|
+
// The same cap, the same two files, with the newer one now live: the answer
|
|
156
|
+
// flips only because the newer file is the one that got read.
|
|
157
|
+
fs.writeFileSync(
|
|
158
|
+
path.join(missions, "new.json"),
|
|
159
|
+
JSON.stringify({ id: "new", status: "running", ownerSessionId: SESSION }),
|
|
160
|
+
);
|
|
161
|
+
assert.equal(await createSubagentProbe({
|
|
162
|
+
homeDir,
|
|
163
|
+
getSessionId: () => SESSION,
|
|
164
|
+
maxFiles: 1,
|
|
165
|
+
}).running(), true);
|
|
166
|
+
});
|
package/src/config.mjs
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// generate-time template advice), so the only consumer is this extension. A
|
|
7
7
|
// malformed or missing file falls back to the defaults and reports a warning
|
|
8
8
|
// instead of disabling the session: the extension's own `enabled` key is the one
|
|
9
|
-
// deliberate off switch.
|
|
10
|
-
// say — keeps the one default it names and leaves the rest of the file alone.
|
|
9
|
+
// deliberate off switch.
|
|
11
10
|
|
|
12
11
|
import { readFileSync } from "node:fs";
|
|
13
12
|
import { join } from "node:path";
|
|
@@ -15,18 +14,10 @@ import { join } from "node:path";
|
|
|
15
14
|
/** Where the switch file lives, relative to the pi working directory. */
|
|
16
15
|
export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
* How many idle reminders one task may collect before the ladder fails it
|
|
20
|
-
* (`agent.mjs` `settleNow`): two, so the third idle without a completion is the
|
|
21
|
-
* failure.
|
|
22
|
-
*/
|
|
23
|
-
export const DEFAULT_IDLE_REMINDERS = 2;
|
|
24
|
-
|
|
25
|
-
/** Defaults: on, connecting as soon as a session starts, and the idle bound. */
|
|
17
|
+
/** Defaults: on, and connecting as soon as a session starts. */
|
|
26
18
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
27
19
|
enabled: true,
|
|
28
20
|
autoStart: true,
|
|
29
|
-
idleReminders: DEFAULT_IDLE_REMINDERS,
|
|
30
21
|
});
|
|
31
22
|
|
|
32
23
|
/**
|
|
@@ -34,7 +25,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
34
25
|
*
|
|
35
26
|
* @param {string} cwd
|
|
36
27
|
* @param {{ readFile?: (path: string) => string }} [options]
|
|
37
|
-
* @returns {{ enabled: boolean, autoStart: boolean,
|
|
28
|
+
* @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
|
|
38
29
|
*/
|
|
39
30
|
export function loadConfig(cwd, options = {}) {
|
|
40
31
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
@@ -60,21 +51,11 @@ export function loadConfig(cwd, options = {}) {
|
|
|
60
51
|
return { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
|
|
61
52
|
}
|
|
62
53
|
const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
|
|
63
|
-
// The bound is a count, so only a non-negative integer is a value: a string,
|
|
64
|
-
// a fraction or a negative would either count nothing or count forever.
|
|
65
|
-
// Zero is a value — it says the first idle without a completion is already
|
|
66
|
-
// the failure — and it is the operator's call to make.
|
|
67
|
-
const idleReminders = parsed.idleReminders;
|
|
68
|
-
const usable = Number.isInteger(idleReminders) && idleReminders >= 0;
|
|
69
54
|
return {
|
|
70
55
|
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
|
|
71
56
|
autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
|
|
72
|
-
idleReminders: usable ? idleReminders : DEFAULT_CONFIG.idleReminders,
|
|
73
57
|
path,
|
|
74
|
-
warning:
|
|
75
|
-
idleReminders !== undefined && !usable
|
|
76
|
-
? `${path} idleReminders must be a non-negative integer; using ${DEFAULT_CONFIG.idleReminders}`
|
|
77
|
-
: null,
|
|
58
|
+
warning: null,
|
|
78
59
|
present: true,
|
|
79
60
|
};
|
|
80
61
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,21 +7,26 @@
|
|
|
7
7
|
// `crates/onlyne-client/src/dispatch.rs`); with any of the three missing this is
|
|
8
8
|
// a plain pi session and the extension stays silent rather than failing.
|
|
9
9
|
//
|
|
10
|
-
// session_start
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
10
|
+
// session_start -> read env + .pi/onlyne.json, connect, register tools
|
|
11
|
+
// before_agent_start -> the role prose becomes one section of the system
|
|
12
|
+
// prompt the run is about to send (the instruction layer)
|
|
13
|
+
// turn_start -> heartbeat{running}
|
|
14
|
+
// turn_end -> one turn of a run ended; the phase is re-derived from pi
|
|
15
|
+
// and the fallback window for a witnessed failure opens
|
|
16
|
+
// message_end -> keep the last assistant text; a failed turn is `failed`
|
|
17
|
+
// agent_settled -> heartbeat{idle} when the session waits for input, and the
|
|
18
|
+
// report a failed turn owes is sent from here
|
|
19
|
+
// session_shutdown -> detach{reason}
|
|
20
|
+
//
|
|
21
|
+
// Host frames are dispatched in `agent.mjs`, not here: `assign` and `nudge` are
|
|
22
|
+
// injected as user messages, `probe` is answered with a heartbeat, and `recycle`
|
|
23
|
+
// settles the task and stops the plugin.
|
|
18
24
|
|
|
19
25
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
26
|
import { Type } from "typebox";
|
|
21
27
|
|
|
22
28
|
import { OnlyneAgent } from "./agent.mjs";
|
|
23
29
|
import { loadConfig, sessionIdentity } from "./config.mjs";
|
|
24
|
-
import { loadRelay, relayEnabled } from "./relay.mjs";
|
|
25
30
|
import { resolveSocketPath } from "./socket.mjs";
|
|
26
31
|
import { createSurface } from "./pi-surface.mjs";
|
|
27
32
|
|
|
@@ -54,7 +59,6 @@ interface WelcomeLike {
|
|
|
54
59
|
interface PiSurface {
|
|
55
60
|
available: {
|
|
56
61
|
wakeUser: boolean;
|
|
57
|
-
proseContext: boolean;
|
|
58
62
|
customEntry: boolean;
|
|
59
63
|
widget: boolean;
|
|
60
64
|
status: boolean;
|
|
@@ -64,7 +68,8 @@ interface PiSurface {
|
|
|
64
68
|
registerCommand: boolean;
|
|
65
69
|
};
|
|
66
70
|
wakeUser(text: string, parts?: ImagePartInput[]): boolean;
|
|
67
|
-
|
|
71
|
+
roleProse(text: string): boolean;
|
|
72
|
+
applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
|
|
68
73
|
customEntry(customType: string, data: unknown): boolean;
|
|
69
74
|
widget(lines: string[] | undefined): void;
|
|
70
75
|
status(text: string): void;
|
|
@@ -141,10 +146,10 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
141
146
|
name: "onlyne_send",
|
|
142
147
|
label: "Onlyne send",
|
|
143
148
|
description:
|
|
144
|
-
"Send one message to another role
|
|
145
|
-
promptSnippet: "Send a note or a task to another
|
|
149
|
+
"Send one message to another role. kind=note (default) is free text; kind=task hands work to that role and opens a task for it.",
|
|
150
|
+
promptSnippet: "Send a note or a task to another role",
|
|
146
151
|
promptGuidelines: [
|
|
147
|
-
"Use onlyne_send when
|
|
152
|
+
"Use onlyne_send when something has to reach another role; the call returns once the message is queued.",
|
|
148
153
|
],
|
|
149
154
|
parameters: Type.Object({
|
|
150
155
|
to: Type.String({ description: "target role name, e.g. builder" }),
|
|
@@ -160,7 +165,9 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
160
165
|
kind: params.kind,
|
|
161
166
|
imagePath: params.image ?? null,
|
|
162
167
|
});
|
|
163
|
-
|
|
168
|
+
// The recipient and nothing else: a tool result is model-visible, and
|
|
169
|
+
// there is no fact about this send the model needs beyond where it went.
|
|
170
|
+
return textResult(`sent to ${result.to}`, { to: result.to });
|
|
164
171
|
},
|
|
165
172
|
}));
|
|
166
173
|
} catch (error) {
|
|
@@ -171,33 +178,34 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
171
178
|
name: "onlyne_complete",
|
|
172
179
|
label: "Onlyne complete",
|
|
173
180
|
description:
|
|
174
|
-
"End
|
|
175
|
-
promptSnippet: "Finish the current
|
|
181
|
+
"End the current task with an explicit outcome: done (the work is finished), failed (it is provably impossible), cancelled (it was withdrawn), or blocked (something outside this session stops it). summary is the one-line result and details is the full one; files names the paths the result rests on. If the workspace requires a handoff before the task may end, the call is refused until that handoff has gone out.",
|
|
182
|
+
promptSnippet: "Finish the current task with an outcome and a one-line summary",
|
|
176
183
|
promptGuidelines: [
|
|
177
|
-
"Use onlyne_complete at the end of
|
|
178
|
-
"If the assignment is sent to you again while it is still open, the previous turn ended without a completion: finish the work and call onlyne_complete.",
|
|
179
|
-
"If onlyne_complete answers 'relay guard', the session still owes a downstream handoff: make it with onlyne_send and call onlyne_complete again. Close the session anyway only when the handoff is genuinely impossible, with force: true and a reason.",
|
|
184
|
+
"Use onlyne_complete at the end of the current task, naming the outcome and the result in one line.",
|
|
180
185
|
],
|
|
181
186
|
parameters: Type.Object({
|
|
182
|
-
outcome: Type.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
outcome: Type.String({ description: '"done", "failed", "cancelled", or "blocked"' }),
|
|
188
|
+
summary: Type.String({ description: "one-line result summary" }),
|
|
189
|
+
details: Type.Optional(Type.String({ description: "the full result, delivered as it stands" })),
|
|
190
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "absolute paths of the files the result names" })),
|
|
186
191
|
}),
|
|
187
192
|
async execute(_toolCallId, params) {
|
|
188
193
|
if (!agent) throw new Error("onlyne: session is not connected");
|
|
189
194
|
// The exit is not a tool-result flag: pi 0.85.1 has no tool-result
|
|
190
195
|
// `terminate` handling. `agent.complete` asks the surface to shut the
|
|
191
|
-
// process down once the client has acknowledged the report. A
|
|
192
|
-
//
|
|
193
|
-
//
|
|
196
|
+
// process down once the client has acknowledged the report. A refusal
|
|
197
|
+
// from the client throws out of here as a tool error, so the model
|
|
198
|
+
// reads the host's own sentence.
|
|
194
199
|
const result = await agent.completeFromTool({
|
|
195
200
|
outcome: params.outcome,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
201
|
+
summary: params.summary,
|
|
202
|
+
details: params.details,
|
|
203
|
+
files: params.files,
|
|
199
204
|
});
|
|
200
|
-
|
|
205
|
+
// The outcome and nothing else: the ledger's head stays a display
|
|
206
|
+
// field (docs/v2-CONTRACT.md §3c), and the task's identity is not a
|
|
207
|
+
// fact the model is meant to hold.
|
|
208
|
+
return textResult(`reported ${result.outcome}`, { outcome: result.outcome });
|
|
201
209
|
},
|
|
202
210
|
}));
|
|
203
211
|
} catch (error) {
|
|
@@ -208,16 +216,14 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
208
216
|
name: "onlyne_handoff",
|
|
209
217
|
label: "Onlyne handoff",
|
|
210
218
|
description:
|
|
211
|
-
"Hand
|
|
212
|
-
promptSnippet: "Hand
|
|
219
|
+
"Hand the current task on to another role, which continues it. Call it when this task's work goes on to another role.",
|
|
220
|
+
promptSnippet: "Hand the current task on to another role",
|
|
213
221
|
promptGuidelines: [
|
|
214
|
-
"Use onlyne_handoff when
|
|
215
|
-
"Use onlyne_send with kind=\"task\" when a role should get work of its own: that child is hop 0 of a family this session starts.",
|
|
216
|
-
"Use onlyne_send with kind=\"note\" for free text to a role, which carries no task and no hop.",
|
|
222
|
+
"Use onlyne_handoff when this task's work goes on to another role; the receiving role continues it.",
|
|
217
223
|
],
|
|
218
224
|
parameters: Type.Object({
|
|
219
225
|
to: Type.String({ description: "target role name, e.g. builder" }),
|
|
220
|
-
text: Type.String({ description: "handoff text for the
|
|
226
|
+
text: Type.String({ description: "handoff text for the receiving role" }),
|
|
221
227
|
image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
|
|
222
228
|
}),
|
|
223
229
|
async execute(_toolCallId, params) {
|
|
@@ -227,7 +233,10 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
227
233
|
text: params.text,
|
|
228
234
|
imagePath: params.image ?? null,
|
|
229
235
|
});
|
|
230
|
-
|
|
236
|
+
// The recipient and nothing else: the child's id and the hop are the
|
|
237
|
+
// host's bookkeeping, and a result naming them would teach the model
|
|
238
|
+
// to read itself as one node of a numbered chain.
|
|
239
|
+
return textResult(`handed on to ${result.to}`, { to: result.to });
|
|
231
240
|
},
|
|
232
241
|
}));
|
|
233
242
|
} catch (error) {
|
|
@@ -270,21 +279,18 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
270
279
|
log(`disabled by ${config.path}`);
|
|
271
280
|
return;
|
|
272
281
|
}
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const origin = relay.source === "env" ? "the client's environment" : relay.path;
|
|
283
|
-
log(
|
|
284
|
-
`relay guard from ${origin}: required=${JSON.stringify(relay.required)} count=${relay.count ?? "-"}`,
|
|
285
|
-
);
|
|
282
|
+
// The path the client injected, or the client the runtime directory's
|
|
283
|
+
// registration files name for this workspace (socket.mjs). A session with
|
|
284
|
+
// neither has no socket to dial, and saying so is the whole answer: this
|
|
285
|
+
// stays a plain pi session instead of retrying a path nothing serves.
|
|
286
|
+
let socketPath: string | null = null;
|
|
287
|
+
try {
|
|
288
|
+
socketPath = resolveSocketPath(env, ctx.cwd);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
|
|
286
291
|
}
|
|
287
|
-
|
|
292
|
+
if (socketPath === null) return;
|
|
293
|
+
surface = createSurface({ pi, log, context: () => context, sessionId: identity.sessionId });
|
|
288
294
|
agent = new OnlyneAgent({
|
|
289
295
|
socketPath,
|
|
290
296
|
cwd: ctx.cwd,
|
|
@@ -292,8 +298,6 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
292
298
|
sessionId: identity.sessionId,
|
|
293
299
|
taskId: identity.taskId,
|
|
294
300
|
surface,
|
|
295
|
-
relay,
|
|
296
|
-
idleReminders: config.idleReminders,
|
|
297
301
|
log,
|
|
298
302
|
});
|
|
299
303
|
log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
|
|
@@ -307,6 +311,15 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
307
311
|
if (config.autoStart) agent.start();
|
|
308
312
|
});
|
|
309
313
|
|
|
314
|
+
// The role prose is instruction-layer text: `before_agent_start` hands the
|
|
315
|
+
// handler the prompt options the run is about to render, and a section written
|
|
316
|
+
// there is part of the system prompt rather than one more message the model has
|
|
317
|
+
// to read as an utterance. Outside an onlyne session there is no surface and
|
|
318
|
+
// nothing to add.
|
|
319
|
+
pi.on("before_agent_start", async (event) => {
|
|
320
|
+
surface?.applyRoleProse(event);
|
|
321
|
+
});
|
|
322
|
+
|
|
310
323
|
pi.on("turn_start", async () => {
|
|
311
324
|
agent?.onTurnStart();
|
|
312
325
|
});
|