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.
- package/README.md +377 -43
- package/README.zh.md +73 -33
- package/package.json +1 -1
- package/src/agent.live.test.mjs +1 -0
- package/src/agent.mjs +233 -97
- package/src/agent.test.mjs +360 -86
- package/src/background-work.mjs +176 -0
- package/src/background-work.test.mjs +128 -0
- package/src/config.mjs +27 -5
- package/src/index.ts +42 -3
- package/src/pi-surface.mjs +40 -0
- package/src/protocol.mjs +41 -49
- package/src/protocol.test.mjs +58 -8
|
@@ -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
|
-
/**
|
|
18
|
-
|
|
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:
|
|
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 ->
|
|
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 ->
|
|
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).
|
|
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;
|
package/src/pi-surface.mjs
CHANGED
|
@@ -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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
-
* `
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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) {
|