pi-onlyne 1.1.2 → 1.2.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.
@@ -0,0 +1,176 @@
1
+ // The one question a background-task extension makes necessary: is this
2
+ // session's work still running somewhere the agent loop cannot see?
3
+ //
4
+ // `pi-background-tasks` and its relatives take a long command off the loop — the
5
+ // tool call returns a task id at once and the child process carries on. pi then
6
+ // waits for input while the work runs, so `ctx.isIdle()` alone would report a
7
+ // session as idle with a task in flight. This probe reads the extension's own
8
+ // live task list over the pi EventBus, and only when the extension is installed:
9
+ // without one of its tools there is nothing to recognise, nothing to query, and
10
+ // nothing to wait for.
11
+ //
12
+ // The contract is that package's documented `eventbus-v1` surface
13
+ // (`pi-background-tasks/docs/api/eventbus-v1.md`): one request frame in, one
14
+ // response frame out, both closed objects carrying a schema id. A response that
15
+ // never arrives, a frame that does not parse, an error response, and a host with
16
+ // no EventBus all read as "no background work known" and leave the plugin's own
17
+ // judgement untouched.
18
+
19
+ /** The tools that mark the extension as installed. */
20
+ export const BACKGROUND_TOOL_NAMES = Object.freeze([
21
+ "bg_run",
22
+ "bg_run_pi_attested",
23
+ "bg_status",
24
+ "bg_logs",
25
+ "bg_kill",
26
+ "bg_delegate",
27
+ "bg_result",
28
+ "fusion_reason",
29
+ "fusion_investigate",
30
+ "fusion_research",
31
+ "fusion_validate",
32
+ "fusion_web_fetch",
33
+ ]);
34
+
35
+ const REQUEST_CHANNEL = "pi-background-tasks:request:v1";
36
+ const RESPONSE_CHANNEL = "pi-background-tasks:response:v1";
37
+ const REQUEST_SCHEMA = "pi-background-tasks.extension-request.v1";
38
+ const RESPONSE_SCHEMA = "pi-background-tasks.extension-response.v1";
39
+
40
+ /** Task statuses that mean the work is still going. */
41
+ const LIVE_TASK_STATUS = "running";
42
+
43
+ /** How long one status query waits for its answer. */
44
+ export const DEFAULT_STATUS_TIMEOUT_MS = 500;
45
+
46
+ /** @param {string} name */
47
+ export function isBackgroundTool(name) {
48
+ return BACKGROUND_TOOL_NAMES.includes(name);
49
+ }
50
+
51
+ let requestCounter = 0;
52
+
53
+ function nextRequestId() {
54
+ requestCounter += 1;
55
+ return `onlyne-bg-status-${requestCounter}`;
56
+ }
57
+
58
+ function isResponseFor(frame, requestId) {
59
+ return frame
60
+ && typeof frame === "object"
61
+ && frame.schema_version === RESPONSE_SCHEMA
62
+ && frame.request_id === requestId;
63
+ }
64
+
65
+ /**
66
+ * One live-task question, asked of the EventBus and answered by whatever is
67
+ * listening. Every failure mode is inert: the probe reports `false` and says why
68
+ * once, then lets the caller's own judgement stand.
69
+ *
70
+ * @param {{
71
+ * events?: { emit: (channel: string, data: unknown) => void, on: (channel: string, handler: (data: unknown) => void) => () => void } | null,
72
+ * getToolNames?: () => string[] | null,
73
+ * log?: (line: string) => void,
74
+ * timeoutMs?: number,
75
+ * }} options
76
+ */
77
+ export function createBackgroundProbe({
78
+ events = null,
79
+ getToolNames = null,
80
+ log = () => {},
81
+ timeoutMs = DEFAULT_STATUS_TIMEOUT_MS,
82
+ } = {}) {
83
+ let installed = null;
84
+ const warned = new Set();
85
+ let unsubscribe = null;
86
+
87
+ const warnOnce = (reason) => {
88
+ if (warned.has(reason)) return;
89
+ warned.add(reason);
90
+ log(`background work: ${reason}`);
91
+ };
92
+
93
+ /** True once one of the extension's tools is registered; cached either way. */
94
+ function isInstalled() {
95
+ if (installed !== null) return installed;
96
+ let names = null;
97
+ try {
98
+ names = typeof getToolNames === "function" ? getToolNames() : null;
99
+ } catch (error) {
100
+ warnOnce(`tool list unreadable: ${error.message}`);
101
+ return false;
102
+ }
103
+ const list = Array.isArray(names) ? names : [];
104
+ installed = list.some(isBackgroundTool);
105
+ if (!installed) log("background work: no background-task extension in this session");
106
+ return installed;
107
+ }
108
+
109
+ /**
110
+ * One status round trip. The answer arrives on the response channel, so the
111
+ * listener lives exactly as long as the wait and the timer bounds it.
112
+ * @returns {Promise<boolean>}
113
+ */
114
+ function running() {
115
+ if (!isInstalled()) return Promise.resolve(false);
116
+ const bus = events;
117
+ if (!bus || typeof bus.emit !== "function" || typeof bus.on !== "function") {
118
+ warnOnce("event bus unavailable");
119
+ return Promise.resolve(false);
120
+ }
121
+ return new Promise((resolve) => {
122
+ const requestId = nextRequestId();
123
+ let settled = false;
124
+ let timer = null;
125
+ const finish = (answer) => {
126
+ if (settled) return;
127
+ settled = true;
128
+ clearTimeout(timer);
129
+ if (typeof unsubscribe === "function") unsubscribe();
130
+ unsubscribe = null;
131
+ resolve(answer);
132
+ };
133
+ try {
134
+ unsubscribe = bus.on(RESPONSE_CHANNEL, (frame) => {
135
+ if (!isResponseFor(frame, requestId)) return;
136
+ if (frame.ok !== true) {
137
+ warnOnce(`status query refused: ${String(frame.error ?? "unknown")}`);
138
+ finish(false);
139
+ return;
140
+ }
141
+ const tasks = frame.result?.tasks;
142
+ if (!Array.isArray(tasks)) {
143
+ warnOnce("status answer carried no task list");
144
+ finish(false);
145
+ return;
146
+ }
147
+ finish(tasks.some((task) => task?.status === LIVE_TASK_STATUS));
148
+ });
149
+ bus.emit(REQUEST_CHANNEL, {
150
+ schema_version: REQUEST_SCHEMA,
151
+ request_id: requestId,
152
+ operation: "status",
153
+ payload: {},
154
+ });
155
+ } catch (error) {
156
+ warnOnce(`status query failed: ${error.message}`);
157
+ finish(false);
158
+ return;
159
+ }
160
+ timer = setTimeout(() => {
161
+ warnOnce("status query timed out");
162
+ finish(false);
163
+ }, timeoutMs);
164
+ if (typeof timer?.unref === "function") timer.unref();
165
+ });
166
+ }
167
+
168
+ return {
169
+ installed: isInstalled,
170
+ running,
171
+ close() {
172
+ if (typeof unsubscribe === "function") unsubscribe();
173
+ unsubscribe = null;
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { createBackgroundProbe } from "./background-work.mjs";
5
+
6
+ const RESPONSE_CHANNEL = "pi-background-tasks:response:v1";
7
+ const RESPONSE_SCHEMA = "pi-background-tasks.extension-response.v1";
8
+
9
+ class FakeEventBus {
10
+ constructor(answer) {
11
+ this.answer = answer;
12
+ this.emits = [];
13
+ this.listeners = new Map();
14
+ this.drops = 0;
15
+ }
16
+
17
+ on(channel, handler) {
18
+ const handlers = this.listeners.get(channel) ?? new Set();
19
+ handlers.add(handler);
20
+ this.listeners.set(channel, handlers);
21
+ let active = true;
22
+ return () => {
23
+ if (!active) return;
24
+ active = false;
25
+ this.drops += 1;
26
+ handlers.delete(handler);
27
+ };
28
+ }
29
+
30
+ emit(channel, data) {
31
+ this.emits.push({ channel, data });
32
+ if (channel !== "pi-background-tasks:request:v1") return;
33
+ this.answer?.(data, (frame) => {
34
+ for (const handler of [...(this.listeners.get(RESPONSE_CHANNEL) ?? [])]) {
35
+ handler(frame);
36
+ }
37
+ });
38
+ }
39
+
40
+ listenerCount() {
41
+ return [...this.listeners.values()].reduce((total, handlers) => total + handlers.size, 0);
42
+ }
43
+ }
44
+
45
+ function response(request, body) {
46
+ return {
47
+ schema_version: RESPONSE_SCHEMA,
48
+ request_id: request.request_id,
49
+ ...body,
50
+ };
51
+ }
52
+
53
+ function probeFor(answer, timeoutMs = 20) {
54
+ const events = new FakeEventBus(answer);
55
+ const probe = createBackgroundProbe({
56
+ events,
57
+ getToolNames: () => ["bg_run"],
58
+ timeoutMs,
59
+ });
60
+ return { events, probe };
61
+ }
62
+
63
+ test("a session without a background-task tool makes no EventBus query", async () => {
64
+ const events = new FakeEventBus(() => {
65
+ assert.fail("an uninstalled background-task extension must not be queried");
66
+ });
67
+ const probe = createBackgroundProbe({
68
+ events,
69
+ getToolNames: () => ["read_file"],
70
+ });
71
+
72
+ assert.equal(await probe.running(), false);
73
+ assert.deepEqual(events.emits, []);
74
+ assert.equal(events.listenerCount(), 0);
75
+ });
76
+
77
+ test("a live background task answers true and drops the response listener", async () => {
78
+ const { events, probe } = probeFor((request, respond) => {
79
+ respond(response(request, { ok: true, result: { tasks: [{ status: "running" }] } }));
80
+ });
81
+
82
+ assert.equal(await probe.running(), true);
83
+ assert.equal(events.emits.length, 1);
84
+ assert.equal(events.listenerCount(), 0);
85
+ assert.equal(events.drops, 1);
86
+ });
87
+
88
+ test("a terminal background task list answers false and drops the response listener", async () => {
89
+ const { events, probe } = probeFor((request, respond) => {
90
+ respond(response(request, {
91
+ ok: true,
92
+ result: { tasks: [{ status: "completed" }, { status: "failed" }] },
93
+ }));
94
+ });
95
+
96
+ assert.equal(await probe.running(), false);
97
+ assert.equal(events.listenerCount(), 0);
98
+ assert.equal(events.drops, 1);
99
+ });
100
+
101
+ test("a refused status query answers false and drops the response listener", async () => {
102
+ const { events, probe } = probeFor((request, respond) => {
103
+ respond(response(request, { ok: false, error: "status unavailable" }));
104
+ });
105
+
106
+ assert.equal(await probe.running(), false);
107
+ assert.equal(events.listenerCount(), 0);
108
+ assert.equal(events.drops, 1);
109
+ });
110
+
111
+ test("a malformed status answer reads false and drops the response listener", async () => {
112
+ const { events, probe } = probeFor((request, respond) => {
113
+ respond(response(request, { ok: true, result: { tasks: "running" } }));
114
+ });
115
+
116
+ assert.equal(await probe.running(), false);
117
+ assert.equal(events.listenerCount(), 0);
118
+ assert.equal(events.drops, 1);
119
+ });
120
+
121
+ test("a background-task service that never answers reads false and drops the response listener", async () => {
122
+ const { events, probe } = probeFor(null, 10);
123
+
124
+ assert.equal(await probe.running(), false);
125
+ assert.equal(events.emits.length, 1);
126
+ assert.equal(events.listenerCount(), 0);
127
+ assert.equal(events.drops, 1);
128
+ });
package/src/config.mjs CHANGED
@@ -6,7 +6,8 @@
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.
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.
10
11
 
11
12
  import { readFileSync } from "node:fs";
12
13
  import { join } from "node:path";
@@ -14,15 +15,26 @@ import { join } from "node:path";
14
15
  /** Where the switch file lives, relative to the pi working directory. */
15
16
  export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
16
17
 
17
- /** Defaults: on, and connecting as soon as a session starts. */
18
- export const DEFAULT_CONFIG = Object.freeze({ enabled: true, autoStart: true });
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. */
26
+ export const DEFAULT_CONFIG = Object.freeze({
27
+ enabled: true,
28
+ autoStart: true,
29
+ idleReminders: DEFAULT_IDLE_REMINDERS,
30
+ });
19
31
 
20
32
  /**
21
33
  * Read `.pi/onlyne.json`.
22
34
  *
23
35
  * @param {string} cwd
24
36
  * @param {{ readFile?: (path: string) => string }} [options]
25
- * @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
37
+ * @returns {{ enabled: boolean, autoStart: boolean, idleReminders: number, path: string, warning: string | null, present: boolean }}
26
38
  */
27
39
  export function loadConfig(cwd, options = {}) {
28
40
  const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
@@ -48,11 +60,21 @@ export function loadConfig(cwd, options = {}) {
48
60
  return { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
49
61
  }
50
62
  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;
51
69
  return {
52
70
  enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
53
71
  autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
72
+ idleReminders: usable ? idleReminders : DEFAULT_CONFIG.idleReminders,
54
73
  path,
55
- warning: null,
74
+ warning:
75
+ idleReminders !== undefined && !usable
76
+ ? `${path} idleReminders must be a non-negative integer; using ${DEFAULT_CONFIG.idleReminders}`
77
+ : null,
56
78
  present: true,
57
79
  };
58
80
  }
package/src/index.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  //
10
10
  // session_start -> read env + .pi/onlyne.json, connect, register tools
11
11
  // turn_start -> heartbeat{running}
12
- // turn_end -> heartbeat{idle}
12
+ // turn_end -> one turn of a run ended; the phase is re-derived from pi
13
+ // and the settle window opens
13
14
  // message_end -> keep the last assistant text; a failed turn is `failed`
14
- // agent_settled -> completion exit: done|failed
15
+ // agent_settled -> heartbeat{idle} when the session waits for input, then
16
+ // the settle decision: the idle ladder, or `failed` at once
15
17
  // session_shutdown -> detach{reason}
16
18
 
17
19
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -68,6 +70,10 @@ interface PiSurface {
68
70
  status(text: string): void;
69
71
  welcome(welcome: WelcomeLike): void;
70
72
  isIdle(): boolean;
73
+ /** The phase rule: true only while the session waits for user input. */
74
+ waitingForInput(): Promise<boolean>;
75
+ /** Drops the background-task probe's EventBus subscription. */
76
+ closeBackground?(): void;
71
77
  exit(reason: string): void;
72
78
  }
73
79
 
@@ -165,10 +171,11 @@ export default function onlyne(pi: ExtensionAPI) {
165
171
  name: "onlyne_complete",
166
172
  label: "Onlyne complete",
167
173
  description:
168
- "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). Without this call the session still completes on its own: done, or failed when the turn errored. In a workspace whose relay policy (relay.toml) names the handoffs this session owes, the call is refused until each one has gone out.",
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.",
169
175
  promptSnippet: "Finish the current onlyne task with an outcome and a one-line summary",
170
176
  promptGuidelines: [
171
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.",
172
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.",
173
180
  ],
174
181
  parameters: Type.Object({
@@ -196,6 +203,36 @@ export default function onlyne(pi: ExtensionAPI) {
196
203
  } catch (error) {
197
204
  log(`registerTool(onlyne_complete) refused: ${error instanceof Error ? error.message : String(error)}`);
198
205
  }
206
+ try {
207
+ pi.registerTool(defineTool({
208
+ name: "onlyne_handoff",
209
+ label: "Onlyne handoff",
210
+ 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",
213
+ 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.",
217
+ ],
218
+ parameters: Type.Object({
219
+ to: Type.String({ description: "target role name, e.g. builder" }),
220
+ text: Type.String({ description: "handoff text for the next role of the run" }),
221
+ image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
222
+ }),
223
+ async execute(_toolCallId, params) {
224
+ if (!agent) throw new Error("onlyne: session is not connected");
225
+ const result = await agent.handoffFromTool({
226
+ to: params.to,
227
+ text: params.text,
228
+ imagePath: params.image ?? null,
229
+ });
230
+ return textResult(`handed on to ${result.to} as ${result.taskId} at hop ${result.hop}`, result);
231
+ },
232
+ }));
233
+ } catch (error) {
234
+ log(`registerTool(onlyne_handoff) refused: ${error instanceof Error ? error.message : String(error)}`);
235
+ }
199
236
  try {
200
237
  pi.registerCommand("onlyne", {
201
238
  description: "Onlyne session status: connection, task, reports",
@@ -256,6 +293,7 @@ export default function onlyne(pi: ExtensionAPI) {
256
293
  taskId: identity.taskId,
257
294
  surface,
258
295
  relay,
296
+ idleReminders: config.idleReminders,
259
297
  log,
260
298
  });
261
299
  log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
@@ -294,6 +332,7 @@ export default function onlyne(pi: ExtensionAPI) {
294
332
 
295
333
  pi.on("session_shutdown", async (event) => {
296
334
  agent?.stop(`pi:${event.reason ?? "quit"}`);
335
+ surface?.closeBackground?.();
297
336
  surface?.widget?.(undefined);
298
337
  agent = null;
299
338
  surface = null;
@@ -10,9 +10,13 @@
10
10
  // status ctx.ui.setStatus("onlyne", text)
11
11
  // exit ctx.shutdown()
12
12
  // isIdle ctx.isIdle()
13
+ // pending ctx.hasPendingMessages()
14
+ // toolNames pi.getAllTools() (background-work.mjs)
15
+ // eventBus pi.events (background-work.mjs)
13
16
  // registerTool / registerCommand are probed by index.ts itself.
14
17
 
15
18
  import { WIDGET_KEY } from "./activity.mjs";
19
+ import { createBackgroundProbe } from "./background-work.mjs";
16
20
 
17
21
  /**
18
22
  * @param {{ pi: any, log: (line: string) => void, context: () => any }} options
@@ -27,6 +31,30 @@ export function createSurface({ pi, log, context }) {
27
31
  }
28
32
  };
29
33
 
34
+ /**
35
+ * The one question behind every phase the plugin reports. pi answers it; a
36
+ * probe that is missing, throws, or arrives without a context answers `false`
37
+ * because an unwitnessed session is a running one as far as this plugin can
38
+ * prove (`background-work.mjs` carries the second half of the question).
39
+ */
40
+ const piWaitsForInput = () => {
41
+ const current = ctx();
42
+ if (!current) return false;
43
+ try {
44
+ if (!has(current.isIdle) || !current.isIdle()) return false;
45
+ if (has(current.hasPendingMessages) && current.hasPendingMessages()) return false;
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ const background = createBackgroundProbe({
53
+ events: pi.events ?? null,
54
+ getToolNames: has(pi.getAllTools) ? () => (pi.getAllTools() ?? []).map((tool) => tool?.name) : null,
55
+ log,
56
+ });
57
+
30
58
  const available = {
31
59
  wakeUser: has(pi.sendUserMessage),
32
60
  proseContext: has(pi.sendMessage),
@@ -139,6 +167,18 @@ export function createSurface({ pi, log, context }) {
139
167
  return true;
140
168
  }
141
169
  },
170
+ /**
171
+ * 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.
174
+ */
175
+ async waitingForInput() {
176
+ if (!piWaitsForInput()) return false;
177
+ return !(await background.running());
178
+ },
179
+ closeBackground() {
180
+ background.close();
181
+ },
142
182
  exit(reason) {
143
183
  log(`exiting pi: ${reason}`);
144
184
  try {
package/src/protocol.mjs CHANGED
@@ -111,9 +111,13 @@ export function readyReport({ taskId, sessionId, generation, seq }) {
111
111
  }
112
112
 
113
113
  /**
114
- * `report.heartbeat`. `observed` is a full `Observation` (`onlyne-session`'s
115
- * reducer type), not a loose status string: the host deserialises it and rejects
116
- * anything that is not a legal state tuple.
114
+ * `report.heartbeat`. `observed` is an `Observation` (`onlyne-session`'s reducer
115
+ * type), not a loose status string: the host deserialises the tuple, overwrites
116
+ * the six dimensions it owns — `delivery` and `recovery` from its intent drain
117
+ * and reducer history, `generation_live` and the reconcile tuning with its
118
+ * counter from the role's own records — and repairs whatever pairing that leaves
119
+ * before the reducer reads it. What
120
+ * this plugin puts into the body is `observationFor`'s exact key set.
117
121
  */
118
122
  export function heartbeatReport({ taskId, generation, seq, agent, host = null }) {
119
123
  return {
@@ -127,40 +131,6 @@ export function heartbeatReport({ taskId, generation, seq, agent, host = null })
127
131
  };
128
132
  }
129
133
 
130
- /**
131
- * The final observation of a settled session: `agent: idle` beside the outcome
132
- * the completion just stated.
133
- *
134
- * A session that only ever reported `running` and then completed leaves the
135
- * ledger's projection saying `running` forever, because nothing observes the
136
- * exit. This body is the tuple the host's own settle produces
137
- * (`onlyne-session/src/reconcile.rs::settle_body`) with the agent dimension
138
- * moved to `idle`, so `is_legal` accepts it: `outcome: done` requires
139
- * `delivery: accepted` and an idle agent requires `recovery: draining`, and any
140
- * other outcome carries the delivery unchanged.
141
- */
142
- export function settledReport({ taskId, outcome, generation, seq, host = null }) {
143
- const normalized = normalizeOutcome(outcome);
144
- const done = normalized === "done";
145
- const observed = {
146
- version: { generation, seq },
147
- generation_live: true,
148
- isolate_after: 1,
149
- terminate_after: 3,
150
- mismatch_count: 0,
151
- agent: "idle",
152
- delivery: done ? "accepted" : "none",
153
- resource: "attached",
154
- recovery: done ? "draining" : "none",
155
- outcome: normalized,
156
- // `project(idle, accepted, …, done)` is `exited`; every other outcome keeps
157
- // the session `working` until its resource closes.
158
- public: done ? "exited" : "working",
159
- };
160
- if (host) observed.host = host;
161
- return { kind: "heartbeat", data: { task_id: taskId, generation, seq, observed } };
162
- }
163
-
164
134
  /** `report.complete` — the terminal fact the ledger keeps. */
165
135
  export function completeReport({ taskId, outcome, head }) {
166
136
  const report = { kind: "complete", data: { task_id: taskId, outcome: normalizeOutcome(outcome) } };
@@ -191,17 +161,29 @@ export function detachArgs(reason) {
191
161
  }
192
162
 
193
163
  /**
194
- * A legal `Observation` for one agent state.
164
+ * The observation for one agent state: the plugin's own report, on the wire as
165
+ * the `observed` body of a heartbeat.
195
166
  *
196
- * `onlyne-session`'s `is_legal` requires `public` to be `project(...)` of the
197
- * other dimensions and non-zero reconcile policy, so the tuple is built rather
198
- * than passed through: the plugin owns the agent dimension (the host never
199
- * synthesises turn state), and leaves delivery at `none`/outcome `pending`,
200
- * which is its own truth until it reports a completion.
167
+ * The plugin states three things and only three: the `agent` dimension (its turn
168
+ * hooks are the only witness), `resource: attached` — the process is running in
169
+ * the pane, which is the attach the host's dispatch path recorded — and the
170
+ * `host` binding. `delivery`, `recovery`, `generation_live`, `isolate_after`,
171
+ * `terminate_after` and `mismatch_count` are placeholders with a reason:
172
+ * `Observation` has no optional dimensions, the body must deserialize, and the
173
+ * client rewrites all six from its own records before the reducer reads them
174
+ * (`crates/onlyne-client/src/session/dispatch/reports.rs`) — the completion
175
+ * intent and its recovery label are the client's, the reconcile tuning and the
176
+ * counter beside it are the role's — so what the plugin sends there is never
177
+ * believed. Neither the task's outcome nor a public view
178
+ * belongs in a tuple any more — the ledger owns the result, `project` derives
179
+ * the view — so neither is sent.
201
180
  *
202
- * `host` is where this process runs (`hostBinding`); it is attached only when
203
- * the environment names a pane, so a pi outside Orca reports a tuple with no
204
- * host field at all.
181
+ * `gone` travels as `booting` on purpose: only the host's reconnect-grace window
182
+ * declares a session dead, and a beat that pre-declared `gone` would bury the
183
+ * row's agent before that window has run.
184
+ *
185
+ * `host` is attached only when the environment names a pane, so a pi outside
186
+ * Orca reports a tuple with no host field at all.
205
187
  * @param {"booting"|"ready"|"running"|"idle"|"gone"} agent
206
188
  */
207
189
  export function observationFor(agent, { generation, seq, host = null }) {
@@ -217,8 +199,6 @@ export function observationFor(agent, { generation, seq, host = null }) {
217
199
  delivery: "none",
218
200
  resource: "attached",
219
201
  recovery: "none",
220
- outcome: "pending",
221
- public: state === "running" ? "working" : state === "ready" || state === "idle" ? "idle" : "created",
222
202
  };
223
203
  if (host) observed.host = host;
224
204
  return observed;
@@ -331,13 +311,25 @@ export function normalizeOutcome(value) {
331
311
  * session transcript shows where the instruction came from; the role prose
332
312
  * (identical in `welcome` and `assign`) is folded in only when it has not
333
313
  * already been delivered.
314
+ *
315
+ * The header also carries the family's own figures — the hop this assignment
316
+ * sits at and the hops the family may spend — so a role reads its position off
317
+ * the instruction. Both appear only when the causality names a hop budget: the
318
+ * budget is what marks a payload as a member of a bounded family, and a payload
319
+ * that names none injects exactly the bytes it produced before this header
320
+ * carried them.
334
321
  * @param {{ assign: any, proseIsNew: boolean, attachmentPaths?: string[] }} options
335
322
  */
336
323
  export function injectionText({ assign, proseIsNew, attachmentPaths = [] }) {
337
324
  const envelope = assign.envelope ?? {};
325
+ const causality = envelope.causality ?? {};
338
326
  const taskId = assign.task_id ?? envelope.causality?.task ?? "unknown";
327
+ const position =
328
+ typeof causality.hop_budget === "number"
329
+ ? `, hop ${causality.hop ?? 0}, hop budget ${causality.hop_budget}`
330
+ : "";
339
331
  const lines = [
340
- `[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"})`,
332
+ `[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"}${position})`,
341
333
  ];
342
334
  const prose = typeof assign.prose === "string" ? assign.prose.trim() : "";
343
335
  if (prose && proseIsNew) {