pi-durable-subagents 0.0.0-stage → 1.0.2
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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +318 -2
- package/agents/LICENSE +21 -0
- package/agents/delegate.md +17 -0
- package/agents/evidence-auditor.md +31 -0
- package/agents/oracle.md +32 -0
- package/agents/researcher.md +35 -0
- package/agents/reviewer.md +35 -0
- package/agents/scout.md +25 -0
- package/agents/worker.md +35 -0
- package/dist/agent/capabilities.js +32 -0
- package/dist/agent/child/history.js +49 -0
- package/dist/agent/child/live.js +62 -0
- package/dist/agent/child/schema.js +86 -0
- package/dist/agent/child.js +305 -0
- package/dist/agent/extension.js +56 -0
- package/dist/agent/main/snapshots.js +95 -0
- package/dist/agent/main/tool.js +118 -0
- package/dist/agent/main.js +307 -0
- package/dist/cli/chaos/host.js +17 -0
- package/dist/cli/chaos/index.js +74 -0
- package/dist/cli/chaos/observer.js +14 -0
- package/dist/cli/chaos/provider.js +52 -0
- package/dist/cli/chaos/scenario.js +175 -0
- package/dist/cli/chaos/stack.js +176 -0
- package/dist/cli/control.js +119 -0
- package/dist/cli/doctor.js +147 -0
- package/dist/cli/main.js +207 -0
- package/dist/cli/service.js +87 -0
- package/dist/cli/smoke.js +121 -0
- package/dist/compat/agents.js +287 -0
- package/dist/compat/fanout.js +39 -0
- package/dist/compat/frontmatter.js +64 -0
- package/dist/compat/model.js +29 -0
- package/dist/compat/pi-args.js +29 -0
- package/dist/compat/result.js +7 -0
- package/dist/compat/spec.js +74 -0
- package/dist/evaluator/host.js +150 -0
- package/dist/evaluator/worker.js +178 -0
- package/dist/kernel/guards.js +38 -0
- package/dist/kernel/ids.js +37 -0
- package/dist/kernel/journal.js +146 -0
- package/dist/kernel/lifecycle.js +103 -0
- package/dist/kernel/mailbox.js +153 -0
- package/dist/orchestrator/contract.js +1 -0
- package/dist/orchestrator/engine.js +732 -0
- package/dist/orchestrator/evaluator-client.js +134 -0
- package/dist/orchestrator/executor/effects/gate.js +195 -0
- package/dist/orchestrator/executor/effects/index.js +35 -0
- package/dist/orchestrator/executor/effects/output.js +82 -0
- package/dist/orchestrator/executor/effects/prepare.js +146 -0
- package/dist/orchestrator/executor/generation.js +24 -0
- package/dist/orchestrator/executor/hibernate.js +32 -0
- package/dist/orchestrator/executor/index.js +965 -0
- package/dist/orchestrator/executor/memory.js +21 -0
- package/dist/orchestrator/executor/observe.js +165 -0
- package/dist/orchestrator/executor/session.js +112 -0
- package/dist/orchestrator/executor/sweep.js +95 -0
- package/dist/orchestrator/executor/time.js +50 -0
- package/dist/orchestrator/executor/usage.js +55 -0
- package/dist/orchestrator/main.js +64 -0
- package/dist/orchestrator/snapshot.js +422 -0
- package/dist/orchestrator/store.js +375 -0
- package/dist/paths.js +20 -0
- package/dist/platform/containment.js +108 -0
- package/dist/platform/lock.js +78 -0
- package/dist/platform/proctable.js +120 -0
- package/dist/types.js +60 -0
- package/dist/ui/cards.js +68 -0
- package/dist/ui/data.js +124 -0
- package/dist/ui/frame.js +27 -0
- package/dist/ui/index.js +136 -0
- package/dist/ui/screen.js +599 -0
- package/dist/ui/session.js +151 -0
- package/dist/ui/thinking.js +17 -0
- package/dist/ui/tool.js +72 -0
- package/dist/ui/view.js +292 -0
- package/index.js +8 -0
- package/package.json +75 -4
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Upgrade safety (robustness.md layer 3, AC5): probe every pi surface we use before registering anything.
|
|
2
|
+
// A missing tier-0 surface disables Durable Subagents with one exact message; a missing UI surface disables only
|
|
3
|
+
// the UI. Never a silent failure, never a crash of pi.
|
|
4
|
+
export const SURFACES = [
|
|
5
|
+
...["on", "registerTool", "appendEntry", "sendMessage", "setModel", "setThinkingLevel"].map(name => ({ tier: 0, where: "ExtensionAPI", name })),
|
|
6
|
+
{ tier: 0, where: "@earendil-works/pi-coding-agent", name: "defineTool" },
|
|
7
|
+
{ tier: 0, where: "@earendil-works/pi-ai", name: "Type" },
|
|
8
|
+
...["AssistantMessageComponent", "UserMessageComponent", "ToolExecutionComponent", "getMarkdownTheme", "getSelectListTheme"]
|
|
9
|
+
.map(name => ({ tier: 2, where: "@earendil-works/pi-coding-agent", name })),
|
|
10
|
+
{ tier: 2, where: "ExtensionAPI", name: "registerMessageRenderer" },
|
|
11
|
+
{ tier: 2, where: "@earendil-works/pi-tui", name: "wrapTextWithAnsi" },
|
|
12
|
+
...["Input", "SelectList", "fuzzyFilter", "matchesKey", "truncateToWidth", "visibleWidth"].map(name => ({ tier: 2, where: "@earendil-works/pi-tui", name })),
|
|
13
|
+
];
|
|
14
|
+
/** AC5, P21: Check the surfaces present in pi's modules and extension API; produce exact degradation messages. */
|
|
15
|
+
export function checkCapabilities(modules, api, version) {
|
|
16
|
+
const has = (s) => s.where === "ExtensionAPI" ? typeof api[s.name] === "function" : modules[s.where]?.[s.name] !== undefined;
|
|
17
|
+
const missing = SURFACES.filter(s => !has(s));
|
|
18
|
+
const name = (list) => list.map(s => `${s.where}.${s.name}`).join(", ");
|
|
19
|
+
const pi = version ? `pi ${version}` : "this pi version";
|
|
20
|
+
// The evaluator's CPU limit needs worker.cpuUsage and process.threadCpuUsage (Node 22.19); without them a runaway
|
|
21
|
+
// script would never be stopped, so an older Node disables execution rather than run unguarded.
|
|
22
|
+
const node = typeof process.threadCpuUsage === "function";
|
|
23
|
+
const execution = node && !missing.some(s => s.tier === 0), ui = execution && !missing.some(s => s.tier === 2);
|
|
24
|
+
const messages = [];
|
|
25
|
+
if (!node)
|
|
26
|
+
messages.push(`Durable Subagents disabled: it needs Node.js 22.19 or later (this is ${process.version}).`);
|
|
27
|
+
else if (!execution)
|
|
28
|
+
messages.push(`Durable Subagents disabled: ${pi} no longer provides ${name(missing.filter(s => s.tier === 0))}. Running workflows are untouched; run \`pi-durable-subagents smoke\` for details.`);
|
|
29
|
+
else if (!ui)
|
|
30
|
+
messages.push(`Durable Subagents: native watch view disabled (${pi} no longer exports ${name(missing.filter(s => s.tier === 2))}); use \`pi-durable-subagents tail\`.`);
|
|
31
|
+
return { ...(version ? { version } : {}), missing, execution, ui, messages };
|
|
32
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CT } from "../../types.js";
|
|
2
|
+
/** A1, P4, P8, P37: Recover receipts and question revisions for this call generation. A generation continues the
|
|
3
|
+
* session of the previous one (P37), but request sequences are per recipient (P5): records addressed to an earlier
|
|
4
|
+
* generation stay in history and are skipped here: segments opened by another call's execution marker are foreign. */
|
|
5
|
+
export function recover(entries, call) {
|
|
6
|
+
const records = [], questions = new Map(), answered = new Set();
|
|
7
|
+
let foreign = false;
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
if (entry.type === 'custom' && entry.customType === CT.exec) {
|
|
10
|
+
const owner = /^(.*)#\d+\.\d+$/.exec(String(entry.data?.exec ?? ''));
|
|
11
|
+
foreign = call !== undefined && !!owner && owner[1] !== call;
|
|
12
|
+
}
|
|
13
|
+
if (foreign)
|
|
14
|
+
continue;
|
|
15
|
+
if (entry.type === 'custom') {
|
|
16
|
+
const data = entry.data;
|
|
17
|
+
if (!data)
|
|
18
|
+
continue;
|
|
19
|
+
if (entry.customType === CT.admitted)
|
|
20
|
+
records.push({ ...data, type: 'admitted' });
|
|
21
|
+
if (entry.customType === CT.rejected)
|
|
22
|
+
records.push({ type: 'rejected', rid: data.rid, reason: data.reason });
|
|
23
|
+
if (entry.customType === CT.withdrawn) {
|
|
24
|
+
records.push({ type: 'withdrawn', rids: data.rids });
|
|
25
|
+
if (data.rid)
|
|
26
|
+
records.push({ type: 'applied', rid: data.rid });
|
|
27
|
+
}
|
|
28
|
+
if (entry.customType === CT.model)
|
|
29
|
+
records.push({ type: 'applied', rid: data.rid });
|
|
30
|
+
if (entry.customType === CT.question)
|
|
31
|
+
questions.set(data.qid, data);
|
|
32
|
+
}
|
|
33
|
+
else if (entry.type === 'custom_message' && entry.customType === CT.msg) {
|
|
34
|
+
const details = entry.details;
|
|
35
|
+
records.push({ type: 'applied', rid: details.rid });
|
|
36
|
+
// P28: a hibernation resume delivers the answer as a continue message; its receipt answers qid@rev.
|
|
37
|
+
if (details.qid && details.rev)
|
|
38
|
+
answered.add(`${details.qid}@${details.rev}`);
|
|
39
|
+
}
|
|
40
|
+
else if (entry.type === 'message' && entry.message.role === 'toolResult' && entry.message.toolName === 'ask') {
|
|
41
|
+
const details = entry.message.details;
|
|
42
|
+
if (details?.rid)
|
|
43
|
+
records.push({ type: 'applied', rid: details.rid });
|
|
44
|
+
if (details?.qid && details.rev)
|
|
45
|
+
answered.add(`${details.qid}@${details.rev}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { records, questions, answered };
|
|
49
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// UI §3 "be pi": the child's in-flight model response, for watching only. pi persists an assistant message when it
|
|
2
|
+
// ends; until then the session file cannot say whether the model is thinking, writing or has not answered yet.
|
|
3
|
+
// live.json (next to the session) holds that ephemeral state. It is not durable state: written without fsync,
|
|
4
|
+
// replaced atomically (temp + rename), ignored when stale, and never read by the orchestrator.
|
|
5
|
+
import { renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
export const LIVE_FILE = "live.json";
|
|
8
|
+
const TAIL = 4000, EVERY_MS = 250;
|
|
9
|
+
const tail = (text) => text.length > TAIL ? text.slice(-TAIL) : text;
|
|
10
|
+
/** UI §3: Publish waiting/streaming/idle while a provider request is in flight (at most four writes a second). */
|
|
11
|
+
export function registerLive(pi, enabled) {
|
|
12
|
+
let since = 0, last = 0, pending, path, state;
|
|
13
|
+
const flush = () => {
|
|
14
|
+
pending = undefined;
|
|
15
|
+
last = Date.now();
|
|
16
|
+
if (!path || !state)
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
20
|
+
writeFileSync(tmp, JSON.stringify({ ...state, at: last }));
|
|
21
|
+
renameSync(tmp, path);
|
|
22
|
+
}
|
|
23
|
+
catch { /* Watching is best effort; the session file stays the truth. */ }
|
|
24
|
+
};
|
|
25
|
+
const publish = (ctx, next, now = false) => {
|
|
26
|
+
if (!enabled())
|
|
27
|
+
return;
|
|
28
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
29
|
+
if (!file)
|
|
30
|
+
return;
|
|
31
|
+
path = join(dirname(file), LIVE_FILE);
|
|
32
|
+
const changed = state?.phase !== next.phase;
|
|
33
|
+
state = { ...next, at: Date.now() };
|
|
34
|
+
if (changed || now || Date.now() - last >= EVERY_MS) {
|
|
35
|
+
if (pending)
|
|
36
|
+
clearTimeout(pending);
|
|
37
|
+
flush();
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
pending ??= setTimeout(flush, EVERY_MS - (Date.now() - last));
|
|
41
|
+
};
|
|
42
|
+
// A turn is one model call: from its start (or the provider request) until the first streamed token, it is waiting.
|
|
43
|
+
const waiting = (_event, ctx) => { if (state?.phase !== "waiting")
|
|
44
|
+
since = Date.now(); publish(ctx, { phase: "waiting", since }); };
|
|
45
|
+
pi.on("turn_start", waiting);
|
|
46
|
+
pi.on("before_provider_request", waiting);
|
|
47
|
+
pi.on("message_update", (event, ctx) => {
|
|
48
|
+
const m = event.message;
|
|
49
|
+
if (m.role !== "assistant" || !Array.isArray(m.content))
|
|
50
|
+
return;
|
|
51
|
+
const thinking = m.content.filter(b => b.type === "thinking").map(b => b.thinking ?? "").join("\n");
|
|
52
|
+
const text = m.content.filter(b => b.type === "text").map(b => b.text ?? "").join("\n");
|
|
53
|
+
const tool = m.content.filter(b => b.type === "toolCall").at(-1)?.name;
|
|
54
|
+
publish(ctx, { phase: "streaming", since: since || Date.now(), ...(thinking ? { thinking: tail(thinking) } : {}), ...(text ? { text: tail(text) } : {}), ...(tool ? { tool } : {}) });
|
|
55
|
+
});
|
|
56
|
+
pi.on("message_end", (event, ctx) => {
|
|
57
|
+
if (event.message.role === "assistant") {
|
|
58
|
+
since = 0;
|
|
59
|
+
publish(ctx, { phase: "idle", since: Date.now() }, true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
const keywords = new Set(['type', 'enum', 'items', 'properties', 'required', 'additionalProperties', 'description', 'title']);
|
|
3
|
+
const types = new Set(['null', 'boolean', 'object', 'array', 'number', 'integer', 'string']);
|
|
4
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
/** P24, T4: List unsupported keywords and malformed shapes so a schema that would not constrain fails loudly. */
|
|
6
|
+
export function schemaProblems(schema, path = '$') {
|
|
7
|
+
if (typeof schema === 'boolean')
|
|
8
|
+
return [];
|
|
9
|
+
if (!isObject(schema))
|
|
10
|
+
return [`${path}: must be an object or boolean`];
|
|
11
|
+
const problems = [];
|
|
12
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
13
|
+
const at = `${path}.${key}`;
|
|
14
|
+
if (!keywords.has(key))
|
|
15
|
+
problems.push(`${path}: unsupported keyword "${key}"`);
|
|
16
|
+
else if (key === 'type') {
|
|
17
|
+
const names = Array.isArray(value) ? value : [value];
|
|
18
|
+
if (!names.length || !names.every(n => typeof n === 'string' && types.has(n)))
|
|
19
|
+
problems.push(`${at}: must be one of ${[...types].join(', ')} or a nonempty array of them`);
|
|
20
|
+
}
|
|
21
|
+
else if (key === 'enum') {
|
|
22
|
+
if (!Array.isArray(value))
|
|
23
|
+
problems.push(`${at}: must be an array`);
|
|
24
|
+
}
|
|
25
|
+
else if (key === 'items' || key === 'additionalProperties')
|
|
26
|
+
problems.push(...schemaProblems(value, at));
|
|
27
|
+
else if (key === 'properties') {
|
|
28
|
+
if (!isObject(value))
|
|
29
|
+
problems.push(`${at}: must be an object of schemas`);
|
|
30
|
+
else
|
|
31
|
+
for (const [name, item] of Object.entries(value))
|
|
32
|
+
problems.push(...schemaProblems(item, `${at}.${name}`));
|
|
33
|
+
}
|
|
34
|
+
else if (key === 'required') {
|
|
35
|
+
if (!Array.isArray(value) || !value.every(n => typeof n === 'string'))
|
|
36
|
+
problems.push(`${at}: must be an array of strings`);
|
|
37
|
+
}
|
|
38
|
+
else if (typeof value !== 'string')
|
|
39
|
+
problems.push(`${at}: must be a string`);
|
|
40
|
+
}
|
|
41
|
+
return problems;
|
|
42
|
+
}
|
|
43
|
+
/** P24: Validate the supported JSON schema vocabulary without coercion or dependencies. */
|
|
44
|
+
export function validate(schema, value, path = '$') {
|
|
45
|
+
if (schema === true)
|
|
46
|
+
return [];
|
|
47
|
+
if (schema === false)
|
|
48
|
+
return [`${path}: forbidden`];
|
|
49
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
|
|
50
|
+
throw new Error('Invalid report schema');
|
|
51
|
+
const s = schema, errors = [];
|
|
52
|
+
const matches = (type) => {
|
|
53
|
+
if (type === 'null')
|
|
54
|
+
return value === null;
|
|
55
|
+
if (type === 'array')
|
|
56
|
+
return Array.isArray(value);
|
|
57
|
+
if (type === 'object')
|
|
58
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
59
|
+
if (type === 'integer')
|
|
60
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
61
|
+
if (type === 'number')
|
|
62
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
63
|
+
return typeof value === type;
|
|
64
|
+
};
|
|
65
|
+
if (s.type && !(Array.isArray(s.type) ? s.type : [s.type]).some(matches))
|
|
66
|
+
errors.push(`${path}: expected ${s.type}`);
|
|
67
|
+
if (s.enum && !s.enum.some((item) => isDeepStrictEqual(item, value)))
|
|
68
|
+
errors.push(`${path}: not in enum`);
|
|
69
|
+
if (Array.isArray(value) && s.items !== undefined)
|
|
70
|
+
value.forEach((item, i) => errors.push(...validate(s.items, item, `${path}[${i}]`)));
|
|
71
|
+
if (matches('object')) {
|
|
72
|
+
const obj = value, properties = s.properties ?? {};
|
|
73
|
+
for (const key of s.required ?? [])
|
|
74
|
+
if (!Object.hasOwn(obj, key))
|
|
75
|
+
errors.push(`${path}.${key}: required`);
|
|
76
|
+
for (const [key, item] of Object.entries(obj)) {
|
|
77
|
+
if (Object.hasOwn(properties, key))
|
|
78
|
+
errors.push(...validate(properties[key], item, `${path}.${key}`));
|
|
79
|
+
else if (s.additionalProperties === false)
|
|
80
|
+
errors.push(`${path}.${key}: additional property`);
|
|
81
|
+
else if (s.additionalProperties && typeof s.additionalProperties === 'object')
|
|
82
|
+
errors.push(...validate(s.additionalProperties, item, `${path}.${key}`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return errors;
|
|
86
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { Type } from '@earendil-works/pi-ai';
|
|
4
|
+
import { CT, ENV, JT } from "../types.js";
|
|
5
|
+
import { readJournalSnapshot } from "../kernel/journal.js";
|
|
6
|
+
import { scanInbox } from "../kernel/mailbox.js";
|
|
7
|
+
import { contentHash } from "../kernel/ids.js";
|
|
8
|
+
import { planDecisions } from "../kernel/lifecycle.js";
|
|
9
|
+
import { openness } from "../kernel/guards.js";
|
|
10
|
+
import { recover } from "./child/history.js";
|
|
11
|
+
import { validate } from "./child/schema.js";
|
|
12
|
+
import { registerLive } from "./child/live.js";
|
|
13
|
+
const MESSAGES = ['task', 'steer', 'follow-up', 'continue'];
|
|
14
|
+
/** P31, P33, P37: Usage of this call only — assistant messages in segments opened by this call's executions,
|
|
15
|
+
* deduplicated by entry id. Context inherited from an earlier generation or a forked origin is not charged. */
|
|
16
|
+
export function usage(entries, call) {
|
|
17
|
+
let tokens = 0, costUsd = 0, own = call === undefined;
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
if (e.type === 'custom' && e.customType === CT.exec)
|
|
21
|
+
own = call === undefined || String(e.data?.exec ?? '').startsWith(`${call}#`) || !/#\d+\.\d+$/.test(String(e.data?.exec ?? ''));
|
|
22
|
+
if (!own)
|
|
23
|
+
continue;
|
|
24
|
+
const u = e.type === 'message' && e.message?.role === 'assistant' ? e.message.usage : undefined;
|
|
25
|
+
if (!u || (e.id && seen.has(e.id)))
|
|
26
|
+
continue;
|
|
27
|
+
if (e.id)
|
|
28
|
+
seen.add(e.id);
|
|
29
|
+
tokens += u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
30
|
+
costUsd += u.cost?.total ?? 0;
|
|
31
|
+
}
|
|
32
|
+
return { tokens, costUsd };
|
|
33
|
+
}
|
|
34
|
+
/** P4, P8, P23, P24: Consume the child mailbox at serialized pi boundaries, retaining native session receipts. */
|
|
35
|
+
export function registerChild(pi) {
|
|
36
|
+
let target = process.env[ENV.model];
|
|
37
|
+
const exec = process.env[ENV.exec], call = process.env[ENV.call], inbox = process.env[ENV.inbox], journal = process.env[ENV.journal];
|
|
38
|
+
let active = false, watcher, queue = Promise.resolve();
|
|
39
|
+
let state = recover([]), blocked;
|
|
40
|
+
const serial = (fn) => {
|
|
41
|
+
const result = queue.then(fn);
|
|
42
|
+
queue = result.catch(() => { });
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
const fail = (ctx, error) => {
|
|
46
|
+
console.error('Child mailbox failed:', error);
|
|
47
|
+
active = false;
|
|
48
|
+
watcher?.close();
|
|
49
|
+
blocked?.reject(error instanceof Error ? error : new Error(String(error)));
|
|
50
|
+
blocked = undefined;
|
|
51
|
+
ctx.shutdown();
|
|
52
|
+
};
|
|
53
|
+
/** C8: Make the model the executor holds a slot for (or a model request named) the active one before a turn starts.
|
|
54
|
+
* pi captures a turn's model when the turn begins, so a correction in the `context` hook comes too late for that
|
|
55
|
+
* turn: seen on CI as one "Unknown provider: unknown" reply (pi's placeholder model) after a resume. The registry
|
|
56
|
+
* can briefly lack an extension provider at startup, so wait for it up to 5 s. */
|
|
57
|
+
async function ensureModel(ctx) {
|
|
58
|
+
const slash = target?.indexOf('/') ?? -1;
|
|
59
|
+
if (!target || slash <= 0 || `${ctx.model?.provider}/${ctx.model?.id}` === target)
|
|
60
|
+
return;
|
|
61
|
+
let model = ctx.modelRegistry.find(target.slice(0, slash), target.slice(slash + 1));
|
|
62
|
+
for (let waited = 0; !model && waited < 5000; waited += 50) {
|
|
63
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
64
|
+
model = ctx.modelRegistry.find(target.slice(0, slash), target.slice(slash + 1));
|
|
65
|
+
}
|
|
66
|
+
if (model)
|
|
67
|
+
await pi.setModel(model);
|
|
68
|
+
else
|
|
69
|
+
console.error(`durable-subagents: model ${target} is not registered in this pi (have: ${[...new Set(ctx.modelRegistry.getAll().map(m => m.provider))].join(', ')})`);
|
|
70
|
+
}
|
|
71
|
+
async function consume(ctx, mode) {
|
|
72
|
+
if (!active || (mode === 'idle' && !ctx.isIdle()))
|
|
73
|
+
return [];
|
|
74
|
+
if (mode === 'idle')
|
|
75
|
+
await ensureModel(ctx); // an idle delivery starts a turn
|
|
76
|
+
const candidates = await scanInbox(inbox), byRid = new Map(candidates.map(req => [req.rid, req]));
|
|
77
|
+
// Scan is asynchronous: recheck before using the idle delivery API.
|
|
78
|
+
if (!active || (mode === 'idle' && !ctx.isIdle()))
|
|
79
|
+
return [];
|
|
80
|
+
let delivered = false;
|
|
81
|
+
const plans = planDecisions(state.records, candidates, req => {
|
|
82
|
+
if (req.to !== call)
|
|
83
|
+
return { action: 'reject', reason: 'wrong-recipient' };
|
|
84
|
+
if ([...MESSAGES, 'answer'].includes(req.kind) && typeof req.body?.message !== 'string')
|
|
85
|
+
return { action: 'reject', reason: 'malformed' };
|
|
86
|
+
if (req.kind === 'answer') {
|
|
87
|
+
const qid = req.cond?.qid, rev = req.cond?.rev, question = qid ? state.questions.get(qid) : undefined;
|
|
88
|
+
if (state.answered.has(`${qid}@${rev}`))
|
|
89
|
+
return { action: 'reject', reason: 'already-answered' };
|
|
90
|
+
if (!question || question.rev !== rev)
|
|
91
|
+
return { action: 'reject', reason: 'stale-rev' };
|
|
92
|
+
if (mode !== 'ask' || delivered || !openness({ open: [...state.questions.values()], blocked }, { qid: qid, rev: rev }))
|
|
93
|
+
return { action: 'defer' };
|
|
94
|
+
delivered = true;
|
|
95
|
+
return { action: 'apply' };
|
|
96
|
+
}
|
|
97
|
+
if (mode === 'ask' && (req.kind !== 'steer' || delivered))
|
|
98
|
+
return { action: 'defer' };
|
|
99
|
+
if (req.kind === 'model') {
|
|
100
|
+
const body = req.body;
|
|
101
|
+
return body && ctx.modelRegistry.find(body.provider, body.model) ? { action: 'apply' } : { action: 'reject', reason: 'unknown-model' };
|
|
102
|
+
}
|
|
103
|
+
if (MESSAGES.includes(req.kind)) {
|
|
104
|
+
if ((mode === 'idle' && delivered) || (req.kind === 'follow-up' && mode === 'boundary'))
|
|
105
|
+
return { action: 'defer' };
|
|
106
|
+
delivered = true;
|
|
107
|
+
return { action: 'apply' };
|
|
108
|
+
}
|
|
109
|
+
return { action: 'reject', reason: 'unsupported' };
|
|
110
|
+
});
|
|
111
|
+
// Idle delivery starts pi immediately. Commit only through that message's receipt;
|
|
112
|
+
// later decisions must be reconsidered at the next boundary, in history order.
|
|
113
|
+
if (mode === 'idle') {
|
|
114
|
+
const firstMessage = plans.findIndex(d => d.type === 'applied' && MESSAGES.includes(byRid.get(d.rid).kind));
|
|
115
|
+
if (firstMessage >= 0)
|
|
116
|
+
plans.splice(firstMessage + 1);
|
|
117
|
+
}
|
|
118
|
+
const entries = [];
|
|
119
|
+
const record = (customType, data) => entries.push({ type: 'custom', customType, data });
|
|
120
|
+
let answer, waiter = blocked;
|
|
121
|
+
for (let i = 0; i < plans.length; i++) {
|
|
122
|
+
const decision = plans[i];
|
|
123
|
+
if (decision.type === 'admitted' || decision.type === 'rejected') {
|
|
124
|
+
const { type, ...data } = decision;
|
|
125
|
+
record(type === 'admitted' ? CT.admitted : CT.rejected, data);
|
|
126
|
+
}
|
|
127
|
+
else if (decision.type === 'withdrawn') {
|
|
128
|
+
// The kernel emits target rejections between tombstones and the withdrawal receipt.
|
|
129
|
+
const receipt = plans.slice(i + 1).find(d => d.type === 'applied' && byRid.get(d.rid)?.kind === 'withdraw');
|
|
130
|
+
record(CT.withdrawn, { rid: receipt.rid, rids: decision.rids });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const req = byRid.get(decision.rid);
|
|
134
|
+
if (req.kind === 'withdraw')
|
|
135
|
+
continue;
|
|
136
|
+
if (req.kind === 'model') {
|
|
137
|
+
const body = req.body, model = ctx.modelRegistry.find(body.provider, body.model);
|
|
138
|
+
if (!(await pi.setModel(model)))
|
|
139
|
+
throw new Error(`Cannot activate model ${body.provider}/${body.model}`);
|
|
140
|
+
if (body.thinking)
|
|
141
|
+
pi.setThinkingLevel(body.thinking);
|
|
142
|
+
target = `${body.provider}/${body.model}`;
|
|
143
|
+
record(CT.model, { rid: req.rid, provider: body.provider, model: body.model, ...(body.thinking ? { thinking: body.thinking } : {}) });
|
|
144
|
+
}
|
|
145
|
+
else if (mode === 'ask' && waiter) {
|
|
146
|
+
const text = req.body.message;
|
|
147
|
+
answer = req.kind === 'answer'
|
|
148
|
+
? { content: [{ type: 'text', text }], details: { rid: req.rid, qid: waiter.qid, rev: waiter.rev } }
|
|
149
|
+
: { content: [{ type: 'text', text: `${JSON.stringify({ interrupted_by: 'steer', open: waiter.qid })}\n${text}` }], details: { rid: req.rid, kind: 'steer' } };
|
|
150
|
+
if (req.kind === 'answer')
|
|
151
|
+
state.answered.add(`${waiter.qid}@${waiter.rev}`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// P28: a continue bound to a question (hibernation resume) is that question's answer receipt.
|
|
155
|
+
const bound = req.kind === 'continue' && req.cond?.qid && req.cond.rev ? { qid: req.cond.qid, rev: req.cond.rev } : undefined;
|
|
156
|
+
if (bound)
|
|
157
|
+
state.answered.add(`${bound.qid}@${bound.rev}`);
|
|
158
|
+
entries.push({ type: 'custom_message', customType: CT.msg, content: req.body.message, display: true, details: { rid: req.rid, kind: req.kind, from: req.from, ...bound } });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
state.records.push(...plans);
|
|
163
|
+
if (mode !== 'boundary' && mode !== 'settle') {
|
|
164
|
+
// A single idle message is last: once it starts pi, further work waits for a boundary.
|
|
165
|
+
const message = entries.find(e => e.type === 'custom_message');
|
|
166
|
+
for (const entry of entries)
|
|
167
|
+
if (entry.type === 'custom')
|
|
168
|
+
pi.appendEntry(entry.customType, entry.data);
|
|
169
|
+
if (message?.type === 'custom_message')
|
|
170
|
+
pi.sendMessage(message, { triggerTurn: true });
|
|
171
|
+
if (answer && waiter) {
|
|
172
|
+
blocked = undefined;
|
|
173
|
+
waiter.resolve(answer);
|
|
174
|
+
}
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
return entries;
|
|
178
|
+
}
|
|
179
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
180
|
+
await serial(async () => {
|
|
181
|
+
const entries = await readJournalSnapshot(journal);
|
|
182
|
+
if (entries.filter(e => e.type === JT.exec && e.call === call).at(-1)?.exec !== exec || entries.some(e => e.type === JT.fenced && e.exec === exec)) {
|
|
183
|
+
ctx.shutdown();
|
|
184
|
+
// P23: pi 1.0.2 rpc-mode.js:256-257/:632 honors shutdown only after another
|
|
185
|
+
// RPC command. This owned child is inert and has written nothing; exit now.
|
|
186
|
+
setImmediate(() => process.exit(0));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
state = recover(ctx.sessionManager.getEntries(), call);
|
|
190
|
+
active = true;
|
|
191
|
+
pi.appendEntry(CT.exec, { exec });
|
|
192
|
+
// C8: pi can resolve the session's model before an extension-registered provider exists ("Unknown provider").
|
|
193
|
+
// The executor names the model it holds a slot for; apply it before any provider request if pi did not.
|
|
194
|
+
// Observed with pi 1.0.2 on resumed sessions: the registry briefly lacks an extension provider at startup.
|
|
195
|
+
// Nothing is delivered (so no provider request starts) until the held model is resolvable, for up to 5 s.
|
|
196
|
+
await ensureModel(ctx);
|
|
197
|
+
watcher = watch(inbox, () => { void serial(async () => { await consume(ctx, blocked ? 'ask' : 'idle'); }).catch(error => fail(ctx, error)); });
|
|
198
|
+
watcher.on('error', error => fail(ctx, error));
|
|
199
|
+
await consume(ctx, 'idle');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
const boundary = (mode) => async (_event, ctx) => {
|
|
203
|
+
try {
|
|
204
|
+
return await serial(async () => { const entries = await consume(ctx, mode); return { entries, continue: entries.some(e => e.type === 'custom_message' || (e.type === 'custom' && e.customType === CT.model)) }; });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
fail(ctx, error);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
pi.on('turn_end', boundary('boundary'));
|
|
211
|
+
registerLive(pi, () => active); // UI §3: in-flight response for watching (ephemeral, not durable state)
|
|
212
|
+
pi.on('agent_before_settle', boundary('settle'));
|
|
213
|
+
// P31b, V8: refuse the next provider request once the per-call budget is reached (one in-flight overshoot at most).
|
|
214
|
+
const budget = process.env[ENV.budget] ? JSON.parse(process.env[ENV.budget]) : undefined;
|
|
215
|
+
// C8: last line of defence before each provider request — pi 1.0.2 occasionally starts a resumed turn with its
|
|
216
|
+
// placeholder model ("unknown"); re-apply the model the executor holds a slot for.
|
|
217
|
+
// A model request applied by this child (P12) replaces the target.
|
|
218
|
+
// turn_start runs before the request is prepared (pi reads the model then); `context` runs after it, too late for
|
|
219
|
+
// that request (seen on CI: model_change after the delivered message, then "Unknown provider: unknown"). Keep both.
|
|
220
|
+
const guard = async (_event, ctx) => {
|
|
221
|
+
const slash = target?.indexOf('/') ?? -1;
|
|
222
|
+
if (!target || slash <= 0 || `${ctx.model?.provider}/${ctx.model?.id}` === target)
|
|
223
|
+
return;
|
|
224
|
+
const model = ctx.modelRegistry.find(target.slice(0, slash), target.slice(slash + 1));
|
|
225
|
+
if (model)
|
|
226
|
+
await pi.setModel(model);
|
|
227
|
+
else
|
|
228
|
+
console.error(`durable-subagents: model ${target} is not registered before a provider request`);
|
|
229
|
+
};
|
|
230
|
+
pi.on('turn_start', guard);
|
|
231
|
+
pi.on('context', guard);
|
|
232
|
+
if (budget)
|
|
233
|
+
pi.on('context', async (_event, ctx) => {
|
|
234
|
+
if (!active)
|
|
235
|
+
return;
|
|
236
|
+
const used = usage(ctx.sessionManager.getEntries(), call);
|
|
237
|
+
if ((budget.tokens === undefined || used.tokens < budget.tokens) && (budget.costUsd === undefined || used.costUsd < budget.costUsd))
|
|
238
|
+
return;
|
|
239
|
+
await serial(async () => {
|
|
240
|
+
if (!active)
|
|
241
|
+
return;
|
|
242
|
+
active = false;
|
|
243
|
+
watcher?.close();
|
|
244
|
+
pi.appendEntry(CT.budget, { exec, usage: used });
|
|
245
|
+
ctx.abort();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
pi.on('session_shutdown', async () => {
|
|
249
|
+
active = false;
|
|
250
|
+
watcher?.close();
|
|
251
|
+
blocked?.reject(new Error('Session shut down'));
|
|
252
|
+
blocked = undefined;
|
|
253
|
+
await queue;
|
|
254
|
+
});
|
|
255
|
+
pi.registerTool({
|
|
256
|
+
name: 'ask', label: 'Ask supervisor', description: 'Ask a question and wait for an answer or steering message.',
|
|
257
|
+
parameters: Type.Object({ question: Type.String() }), executionMode: 'sequential', exposure: 'model-only',
|
|
258
|
+
async execute(_id, { question }, signal, _update, ctx) {
|
|
259
|
+
let resolve, reject;
|
|
260
|
+
const result = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
261
|
+
// Attach immediately so abort during the queued intake never produces an unhandled rejection.
|
|
262
|
+
void result.catch(() => { });
|
|
263
|
+
const abort = () => { void serial(async () => { if (blocked?.resolve === resolve)
|
|
264
|
+
blocked = undefined; reject(new Error('Ask aborted')); }); };
|
|
265
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
266
|
+
try {
|
|
267
|
+
await serial(async () => {
|
|
268
|
+
if (!active || signal?.aborted)
|
|
269
|
+
throw new Error('Ask aborted');
|
|
270
|
+
if (blocked)
|
|
271
|
+
throw new Error('Another question is already blocked');
|
|
272
|
+
const qid = contentHash({ exec, question }), rev = (state.questions.get(qid)?.rev ?? 0) + 1;
|
|
273
|
+
const q = { qid, rev, question };
|
|
274
|
+
state.questions.set(qid, q);
|
|
275
|
+
pi.appendEntry(CT.question, q);
|
|
276
|
+
blocked = { ...q, resolve, reject };
|
|
277
|
+
await consume(ctx, 'ask');
|
|
278
|
+
});
|
|
279
|
+
return await result;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
signal?.removeEventListener('abort', abort);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
if (process.env[ENV.schema])
|
|
287
|
+
pi.registerTool({
|
|
288
|
+
name: 'report', label: 'Report result', description: 'Submit a schema-validated final report and terminate the run.',
|
|
289
|
+
parameters: Type.Object({ outcome: Type.Union([Type.Literal('ok'), Type.Literal('failed')]), summary: Type.Optional(Type.String()), data: Type.Optional(Type.Unknown()) }),
|
|
290
|
+
executionMode: 'sequential', exposure: 'model-only',
|
|
291
|
+
async execute(_id, payload, _signal, _update, _ctx) {
|
|
292
|
+
return serial(async () => {
|
|
293
|
+
if (!active)
|
|
294
|
+
throw new Error('Execution is not current');
|
|
295
|
+
const schema = JSON.parse(await readFile(process.env[ENV.schema], 'utf8')), errors = validate(schema, payload.data);
|
|
296
|
+
if (errors.length)
|
|
297
|
+
throw new Error(`Invalid report: ${errors.join('; ')}`);
|
|
298
|
+
pi.appendEntry(CT.report, { exec, ...payload });
|
|
299
|
+
active = false;
|
|
300
|
+
watcher?.close();
|
|
301
|
+
return { content: [{ type: 'text', text: payload.summary ?? JSON.stringify(payload.data) ?? payload.outcome }], details: { exec, ...payload }, terminate: true };
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// pi extension entry (P2): the same extension runs in every pi session.
|
|
2
|
+
// Child mode when DSA_EXEC is set (spawned by the orchestrator), main mode otherwise.
|
|
3
|
+
// The orchestrator always passes `-e <this file>` to children; if the package is also installed,
|
|
4
|
+
// pi may load it twice in one load pass, so a process-global guard keeps registration single. The guard is
|
|
5
|
+
// cleared on session_shutdown, which pi emits before /reload replaces the extension runtime.
|
|
6
|
+
// Upgrade safety (AC5): pi surfaces are probed through namespace imports (never a link error) before the modules
|
|
7
|
+
// that use them are loaded; DSA_PROBE=<file> only writes the capability report (used by `smoke`).
|
|
8
|
+
import { writeFileSync, writeSync } from "node:fs";
|
|
9
|
+
import * as coding from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import * as tui from "@earendil-works/pi-tui";
|
|
11
|
+
import * as ai from "@earendil-works/pi-ai";
|
|
12
|
+
import { ENV } from "../types.js";
|
|
13
|
+
import { checkCapabilities } from "./capabilities.js";
|
|
14
|
+
const LOADED = Symbol.for("pi-durable-subagents.loaded");
|
|
15
|
+
/** P21: A child (DSA_EXEC set) without its execution surfaces cannot do its call; it reports the exact message on
|
|
16
|
+
* stderr and exits 3, so the executor records the message per call and counts a loss (failed after K2) instead of
|
|
17
|
+
* waiting on a child that will never act. Returns the stderr text, or undefined when the child may proceed. */
|
|
18
|
+
export function childRefusal(report, env) {
|
|
19
|
+
if (!env[ENV.exec] || report.execution)
|
|
20
|
+
return undefined;
|
|
21
|
+
return report.messages.join("\n");
|
|
22
|
+
}
|
|
23
|
+
export default async function (pi) {
|
|
24
|
+
const modules = { "@earendil-works/pi-coding-agent": coding, "@earendil-works/pi-tui": tui, "@earendil-works/pi-ai": ai };
|
|
25
|
+
const report = checkCapabilities(modules, pi, typeof coding.VERSION === "string" ? String(coding.VERSION) : undefined);
|
|
26
|
+
if (process.env.DSA_PROBE) {
|
|
27
|
+
writeFileSync(process.env.DSA_PROBE, JSON.stringify(report));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const refusal = childRefusal(report, process.env);
|
|
31
|
+
// Synchronous write: process.exit must not drop the message on an asynchronous pipe.
|
|
32
|
+
if (refusal !== undefined) {
|
|
33
|
+
writeSync(2, `${refusal}\n`);
|
|
34
|
+
process.exit(3);
|
|
35
|
+
}
|
|
36
|
+
const g = globalThis;
|
|
37
|
+
if (g[LOADED])
|
|
38
|
+
return;
|
|
39
|
+
g[LOADED] = true;
|
|
40
|
+
for (const message of report.messages)
|
|
41
|
+
console.error(message);
|
|
42
|
+
if (typeof pi.on === "function") {
|
|
43
|
+
pi.on("session_shutdown", () => { delete g[LOADED]; });
|
|
44
|
+
if (report.messages.length && !process.env[ENV.exec])
|
|
45
|
+
pi.on("session_start", (_event, ctx) => {
|
|
46
|
+
for (const message of report.messages)
|
|
47
|
+
ctx.ui?.notify?.(message, report.execution ? "warning" : "error");
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (!report.execution)
|
|
51
|
+
return;
|
|
52
|
+
if (process.env[ENV.exec])
|
|
53
|
+
(await import("./child.js")).registerChild(pi);
|
|
54
|
+
else
|
|
55
|
+
(await import("./main.js")).registerMain(pi, report.ui ? (await import("../ui/index.js")).registerUi : undefined);
|
|
56
|
+
}
|