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.
@@ -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. A key whose value is unusable — the idle bound below,
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, idleReminders: number, path: string, warning: string | null, present: 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 -> read env + .pi/onlyne.json, connect, register tools
11
- // turn_start -> heartbeat{running}
12
- // turn_end -> one turn of a run ended; the phase is re-derived from pi
13
- // and the settle window opens
14
- // message_end -> keep the last assistant text; a failed turn is `failed`
15
- // agent_settled -> heartbeat{idle} when the session waits for input, then
16
- // the settle decision: the idle ladder, or `failed` at once
17
- // session_shutdown -> detach{reason}
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
- proseContext(text: string, welcome: WelcomeLike): boolean;
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 in this onlyne cluster. kind=note (default) is free text; kind=task hands work to the role and creates a session for it.",
145
- promptSnippet: "Send a note or a task to another onlyne role",
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 a task needs another onlyne role's work; it submits the envelope to the cluster and returns once the router has queued it.",
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
- return textResult(`queued ${result.kind} to ${result.to}`, result);
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 this onlyne task with an explicit outcome. Call it once, when the assigned work is finished (outcome=done), provably impossible (outcome=failed), or withdrawn (outcome=cancelled). This call is the only way the task reaches done: a turn that ends without it leaves the task open, the session re-sends you the assignment up to the workspace's idle-reminder bound, and the idle that finds the bound spent fails the task and ends the session. In a workspace whose relay policy (relay.toml) names the handoffs this session owes, the call is refused until each one has gone out.",
175
- promptSnippet: "Finish the current onlyne task with an outcome and a one-line summary",
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 an onlyne task, naming the outcome and the result in one line; the summary becomes the ledger head.",
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.Optional(Type.String({ description: '"done" (default), "failed", or "cancelled"' })),
183
- text: Type.Optional(Type.String({ description: "one-line result summary" })),
184
- force: Type.Optional(Type.Boolean({ description: "waive the relay guard; requires a non-empty reason" })),
185
- reason: Type.Optional(Type.String({ description: "why the relay guard is waived; stamped into the ledger head after `relay-guard-forced: `" })),
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 relay
192
- // refusal throws out of here as a tool error, which leaves the session
193
- // mounted for the handoff that clears it.
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
- text: params.text,
197
- force: params.force,
198
- reason: params.reason,
201
+ summary: params.summary,
202
+ details: params.details,
203
+ files: params.files,
199
204
  });
200
- return textResult(`onlyne task ${result.taskId} -> ${result.outcome}`, result);
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 this session's task on to the next hop of its family. The host mints one child task for the named role, names this task as the child's parent_task, raises the hop by one, and lets the family's budget, labels, origin and deadline ride along, so the child continues the run this session serves. Use it for the next slot of a ring or a chain; onlyne_send{kind:\"task\"} starts a new family at hop 0, and onlyne_send{kind:\"note\"} is free text.",
212
- promptSnippet: "Hand this task on to the next role of its family",
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 the work goes on to the next role of the run this session serves: the child the host mints carries the same family id, hop budget, labels, origin and deadline, and this task becomes its parent_task.",
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 next role of the run" }),
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
- return textResult(`handed on to ${result.to} as ${result.taskId} at hop ${result.hop}`, result);
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
- // Environment first (the client injects the path it serves), then the
274
- // marker the daemon publishes, then the canonical `run/s` (socket.mjs).
275
- const socketPath = resolveSocketPath(env, ctx.cwd);
276
- // The guard's policy comes from the spec through the client's environment;
277
- // a hand-written `relay.toml` beside the package is the fallback a manual
278
- // installation still has (relay.mjs).
279
- const relay = loadRelay();
280
- if (relay.warning) log(relay.warning);
281
- if (relayEnabled(relay)) {
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
- surface = createSurface({ pi, log, context: () => context });
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
  });