pi-onlyne 1.2.1 → 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/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
  });
@@ -2,9 +2,11 @@
2
2
  // through the pi extension API, with a probe for each optional member so an
3
3
  // older pi degrades instead of throwing.
4
4
  //
5
- // Probed members (measured against pi 0.85.1):
5
+ // Probed members (measured against pi 0.87.1):
6
6
  // wakeUser pi.sendUserMessage(content, { deliverAs: "followUp" })
7
- // proseContext pi.sendMessage({customType,...}, { deliverAs:"followUp", triggerTurn:false })
7
+ // roleProse pi.on("before_agent_start") -> event.systemPromptOptions.sections
8
+ // (index.ts owns the subscription; this module holds the prose and
9
+ // writes the section it becomes)
8
10
  // customEntry pi.appendEntry(customType, data)
9
11
  // widget ctx.ui.setWidget("onlyne", lines) / ctx.ui.setWidget("onlyne", undefined)
10
12
  // status ctx.ui.setStatus("onlyne", text)
@@ -13,15 +15,25 @@
13
15
  // pending ctx.hasPendingMessages()
14
16
  // toolNames pi.getAllTools() (background-work.mjs)
15
17
  // eventBus pi.events (background-work.mjs)
18
+ // subagentRegistry ~/.pi/subagents/missions (background-subagents.mjs)
16
19
  // registerTool / registerCommand are probed by index.ts itself.
17
20
 
18
21
  import { WIDGET_KEY } from "./activity.mjs";
22
+ import { createSubagentProbe } from "./background-subagents.mjs";
19
23
  import { createBackgroundProbe } from "./background-work.mjs";
20
24
 
21
25
  /**
22
- * @param {{ pi: any, log: (line: string) => void, context: () => any }} options
26
+ * The system-prompt section the role prose occupies. pi wraps a section in a tag
27
+ * of the same name and records it under that name in the transcript, which is
28
+ * also where the live case reads the prose back from
29
+ * (`crates/onlyne-testkit/e2e/pi-live.sh`), so the name is stable.
23
30
  */
24
- export function createSurface({ pi, log, context }) {
31
+ export const PROSE_SECTION = "onlyne-role-prose";
32
+
33
+ /**
34
+ * @param {{ pi: any, log: (line: string) => void, context: () => any, sessionId?: string | null }} options
35
+ */
36
+ export function createSurface({ pi, log, context, sessionId = null }) {
25
37
  const has = (value) => typeof value === "function";
26
38
  const ctx = () => {
27
39
  try {
@@ -55,9 +67,33 @@ export function createSurface({ pi, log, context }) {
55
67
  log,
56
68
  });
57
69
 
70
+ // The second family of off-loop work: `Agent` calls, which return a handle and
71
+ // leave a subagent running with no tool event to bracket it. Scoped to this
72
+ // session's own missions when the plugin knows its id.
73
+ const subagents = createSubagentProbe({
74
+ getSessionId: () => sessionId,
75
+ log,
76
+ });
77
+
78
+ /**
79
+ * Live work in either extension holds the turn open. Both probes fail safe on
80
+ * their own, and a throw from either is caught here, so a broken probe still
81
+ * resolves to "not running" and the session proceeds.
82
+ * @returns {Promise<boolean>}
83
+ */
84
+ const backgroundRunning = async () => {
85
+ try {
86
+ const answers = await Promise.all([background.running(), subagents.running()]);
87
+ return answers.some(Boolean);
88
+ } catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ log(`background work probe failed: ${message}`);
91
+ return false;
92
+ }
93
+ };
94
+
58
95
  const available = {
59
96
  wakeUser: has(pi.sendUserMessage),
60
- proseContext: has(pi.sendMessage),
61
97
  customEntry: has(pi.appendEntry),
62
98
  widget: has(ctx()?.ui?.setWidget),
63
99
  status: true,
@@ -83,19 +119,35 @@ export function createSurface({ pi, log, context }) {
83
119
  }
84
120
  };
85
121
 
86
- /** One pi user message; images ride along as pi image content parts. */
122
+ /**
123
+ * One pi user message; images ride along as pi image content parts.
124
+ *
125
+ * pi reads an image part as the flat `ImageContent` of its message types —
126
+ * `data` plus `mimeType` — and normalizes every part before the message is
127
+ * built, so a part missing either string stops the whole delivery inside pi.
128
+ * pi reports that failure in its own pane and hands nothing back to this
129
+ * plugin, so an unusable part is dropped here and the assignment still
130
+ * travels: the delivery text already names the path the client wrote.
131
+ */
87
132
  const wakeUser = (text, parts = []) => {
88
133
  if (!available.wakeUser) {
89
134
  log("pi has no sendUserMessage; the assignment reached the session log only");
90
135
  return false;
91
136
  }
92
- const content = parts.length === 0
137
+ const images = parts.filter(
138
+ (part) => typeof part?.data === "string" && typeof part?.mime === "string" && part.mime,
139
+ );
140
+ if (images.length !== parts.length) {
141
+ log(`attachment carried no base64 data or no media type; ${parts.length - images.length} dropped, the task text still went`);
142
+ }
143
+ const content = images.length === 0
93
144
  ? text
94
145
  : [
95
146
  { type: "text", text },
96
- ...parts.map((part) => ({
147
+ ...images.map((part) => ({
97
148
  type: "image",
98
- source: { type: "base64", mediaType: part.mime, data: part.data },
149
+ data: part.data,
150
+ mimeType: part.mime,
99
151
  })),
100
152
  ];
101
153
  try {
@@ -114,26 +166,53 @@ export function createSurface({ pi, log, context }) {
114
166
  };
115
167
 
116
168
  /**
117
- * The role prose, once, as a custom message that joins the LLM context
118
- * without starting a turn of its own.
169
+ * The role prose this session was handed, held for the instruction layer.
170
+ *
171
+ * The client rendered it and this module adds nothing to it: no prefix, no
172
+ * label, no formatting. It reaches the model as a system-prompt section and
173
+ * never as a conversation message: a message would file the spec's prose in
174
+ * the transcript's message stream beside the delivery the model was asked to
175
+ * act on, and the model would read both as the same kind of thing.
119
176
  */
120
- const proseContext = (text, welcome) => {
121
- if (!available.proseContext) return false;
122
- try {
123
- pi.sendMessage(
124
- {
125
- customType: "onlyne-role-prose",
126
- content: `[onlyne] role prose for ${welcome.role} (from the cluster spec, delivered with welcome):\n\n${text}`,
127
- display: true,
128
- },
129
- { deliverAs: "followUp", triggerTurn: false },
130
- );
131
- return true;
132
- } catch (error) {
133
- const message = error instanceof Error ? error.message : String(error);
134
- log(`role prose injection refused: ${message}`);
177
+ let roleProseText = "";
178
+
179
+ /** One line for a pi without sectioned prompts, not one per run. */
180
+ let warnedNoSections = false;
181
+
182
+ /**
183
+ * Hand the role prose over once.
184
+ * @param {string} text
185
+ * @returns {boolean} whether there is prose to carry
186
+ */
187
+ const roleProse = (text) => {
188
+ roleProseText = typeof text === "string" ? text : "";
189
+ return roleProseText.length > 0;
190
+ };
191
+
192
+ /**
193
+ * Put that prose into the run that is starting: one section of pi's system
194
+ * prompt, whose value is the client's bytes exactly.
195
+ *
196
+ * `before_agent_start` is pi's only instruction-layer point, and it hands the
197
+ * handler the prompt options it is about to render (`prompt-customizer.ts` in
198
+ * pi's own examples does this). pi rebuilds those options for every run, so the
199
+ * section is written again rather than once — the write is idempotent, and the
200
+ * first run records it in the transcript's system message.
201
+ * @param {{ systemPromptOptions?: { sections?: Record<string, string> } } | null} event
202
+ * @returns {boolean} whether a section was written
203
+ */
204
+ const applyRoleProse = (event) => {
205
+ if (roleProseText.length === 0) return false;
206
+ const sections = event?.systemPromptOptions?.sections;
207
+ if (!sections) {
208
+ if (!warnedNoSections) {
209
+ warnedNoSections = true;
210
+ log("before_agent_start carries no prompt sections; the role prose reached no instruction layer");
211
+ }
135
212
  return false;
136
213
  }
214
+ sections[PROSE_SECTION] = roleProseText;
215
+ return true;
137
216
  };
138
217
 
139
218
  const customEntry = (customType, data) => {
@@ -151,7 +230,8 @@ export function createSurface({ pi, log, context }) {
151
230
  return {
152
231
  available,
153
232
  wakeUser,
154
- proseContext,
233
+ roleProse,
234
+ applyRoleProse,
155
235
  customEntry,
156
236
  widget,
157
237
  status,
@@ -169,12 +249,12 @@ export function createSurface({ pi, log, context }) {
169
249
  },
170
250
  /**
171
251
  * The phase rule in one place: idle means waiting for user input, and a
172
- * background-task extension holding live work keeps the session running
173
- * even while pi itself waits.
252
+ * background extension holding live work — a `bg_*` task or a subagent
253
+ * mission — keeps the session running even while pi itself waits.
174
254
  */
175
255
  async waitingForInput() {
176
256
  if (!piWaitsForInput()) return false;
177
- return !(await background.running());
257
+ return !(await backgroundRunning());
178
258
  },
179
259
  closeBackground() {
180
260
  background.close();