pi-durable-subagents 1.0.18 → 1.0.21
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 +48 -0
- package/README.md +111 -12
- package/dist/agent/main/tool.js +26 -4
- package/dist/agent/main.js +36 -16
- package/dist/cli/control.js +43 -20
- package/dist/cli/main.js +54 -17
- package/dist/cli/requests.js +348 -0
- package/dist/cli/restart.js +34 -16
- package/dist/kernel/lifecycle.js +2 -1
- package/dist/orchestrator/engine.js +34 -20
- package/dist/orchestrator/executor/index.js +42 -0
- package/dist/orchestrator/ledger.js +9 -3
- package/dist/orchestrator/restart.js +57 -0
- package/dist/orchestrator/snapshot.js +7 -4
- package/dist/orchestrator/store.js +89 -11
- package/dist/requests.js +87 -0
- package/package.json +1 -1
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// its seal has a finished attention independent of workflow completion.
|
|
8
8
|
// resumed {rid,n} supersedes a terminal park. emit {pos,value} records script outputs.
|
|
9
9
|
// stop-requested {rid,call?} marks a call or workflow stop as taking effect, so its replay is applied, not already-sealed.
|
|
10
|
-
// Orch entry pruned {rid,wid,endedAt,bytes} is the decisive record of a prune: appended before
|
|
11
|
-
// closed and w/<wid> and its staging dirs are removed (bytes = footprint measured just before
|
|
10
|
+
// Orch entry pruned {rid,wid,endedAt,bytes,status,request?,spec_digest?} is the decisive record of a prune: appended before
|
|
11
|
+
// the journal handle is closed and w/<wid> and its staging dirs are removed (bytes = footprint measured just before;
|
|
12
|
+
// status = the final workflow status; request/spec_digest when the workflow was created by a request id, R1 tombstone).
|
|
13
|
+
// Nothing is rewritten (A1): the admitted request and created entries stay, so a retried id still resolves to this wid.
|
|
12
14
|
import { watch } from 'node:fs';
|
|
13
15
|
import { mkdir, readdir, unlink } from 'node:fs/promises';
|
|
14
16
|
import { resolve, join } from 'node:path';
|
|
@@ -17,7 +19,9 @@ import { contentHash } from "../kernel/ids.js";
|
|
|
17
19
|
import { planDecisions, reduceLifecycle } from "../kernel/lifecycle.js";
|
|
18
20
|
import { scanInbox } from "../kernel/mailbox.js";
|
|
19
21
|
import { orchInbox, pinnedDir } from "../paths.js";
|
|
22
|
+
import { requestId, specDigest } from "../requests.js";
|
|
20
23
|
import { JT, attentionEntries, isEntry } from "../types.js";
|
|
24
|
+
import { isForceRestart, restartRefusal } from "./restart.js";
|
|
21
25
|
import { EvaluatorClient } from "./evaluator-client.js";
|
|
22
26
|
import { Store, revisionEntries, terminalEntry } from "./store.js";
|
|
23
27
|
import { formatUsage, holdOf, refusedResult, snapshotFromEntries } from "./snapshot.js";
|
|
@@ -32,6 +36,10 @@ const wakeStatus = (status) => {
|
|
|
32
36
|
return 'unknown';
|
|
33
37
|
return status;
|
|
34
38
|
};
|
|
39
|
+
/** A follow-up still going when its workflow ended has no result yet: say so instead of calling it unknown. */
|
|
40
|
+
/** A follow-up generation still going when its workflow ended is named by its phase; anything else without a result is unknown. */
|
|
41
|
+
const going = (c) => !c.result && c.phase !== 'sealed' && c.gen > 1;
|
|
42
|
+
const callStatus = (c) => going(c) ? c.phase : wakeStatus(c.result?.status);
|
|
35
43
|
/** v12 §6: Name available agents in the refusal instead of making the caller guess. */
|
|
36
44
|
export function unknownAgent(name, agents) {
|
|
37
45
|
return `unknown agent ${JSON.stringify(name)}; available agents: ${agents.map(a => a.name).sort().join(", ") || "none"}`;
|
|
@@ -43,11 +51,11 @@ export function finishedText(wid, entries, call) {
|
|
|
43
51
|
const calls = call ? snap.calls.filter(c => c.callId === call) : [...latest.values()];
|
|
44
52
|
const counts = new Map();
|
|
45
53
|
for (const c of calls) {
|
|
46
|
-
const status =
|
|
54
|
+
const status = callStatus(c);
|
|
47
55
|
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
48
56
|
}
|
|
49
57
|
const parts = [...counts].map(([status, count]) => `${count} ${status}`);
|
|
50
|
-
const heading = call ? `${label}/${calls[0]?.key ?? call}@${calls[0]?.gen ?? '?'} (follow-up) ${
|
|
58
|
+
const heading = call ? `${label}/${calls[0]?.key ?? call}@${calls[0]?.gen ?? '?'} (follow-up) ${calls[0] ? callStatus(calls[0]) : 'unknown'}:` :
|
|
51
59
|
`${label} (${wid}) ${snap.status}${parts.length ? `: ${parts.join('; ')}` : ''}`;
|
|
52
60
|
const footer = `${snap.error ? `\nError: ${tail(snap.error, 500)}` : ''}${charged(snap.usage) ? `\nUsage: ${charged(snap.usage)}` : ''}\nFull output: subagents status wid:${wid}`;
|
|
53
61
|
const prefix = tail(heading, Math.max(1, 6000 - footer.length - 1));
|
|
@@ -70,7 +78,8 @@ export function finishedText(wid, entries, call) {
|
|
|
70
78
|
continue;
|
|
71
79
|
const result = c.result;
|
|
72
80
|
// A stop leaves the agent's edits where they are; say so, so nobody mistakes a stopped agent for a clean undo.
|
|
73
|
-
const
|
|
81
|
+
const still = going(c) ? ' (a follow-up still going; you are told when it ends)' : '';
|
|
82
|
+
const name = `${c.key}${c.gen > 1 ? `@${c.gen}` : ''}: ${callStatus(c)}${still}${result?.status === 'stopped' ? ' (edits it made so far are left in place)' : ''}`;
|
|
74
83
|
const title = `\n${tail(name, allowance - 1)}`;
|
|
75
84
|
const error = result?.error && allowance - title.length > 10 ? `\n Error: ${tail(result.error, Math.min(300, allowance - title.length - 9))}` : '';
|
|
76
85
|
const space = allowance - title.length - error.length;
|
|
@@ -446,13 +455,23 @@ export class Engine {
|
|
|
446
455
|
else if (req.kind === 'restart') {
|
|
447
456
|
// A replay (the restart was recorded, then the process ended before its resolution) applies without restarting again.
|
|
448
457
|
if (!this.ledgers.orch.entries().some(e => e.type === 'restart' && e.rid === req.rid)) {
|
|
449
|
-
|
|
458
|
+
// Only a restart that fenced something is recorded as a force (a token with nothing running fences nothing).
|
|
459
|
+
const body = req.body ?? {}, force = isForceRestart(body);
|
|
460
|
+
// A claimed subagent call only restricts (it cannot force); otherwise the main session's sender is authoritative.
|
|
461
|
+
const initiator = body.initiator && 'call' in body.initiator ? body.initiator : req.from.startsWith('main:') ? { origin: req.from } : body.initiator ?? { origin: req.from };
|
|
450
462
|
const gate = this.executor.quiesce?.() ?? { live: [], resume() { } };
|
|
451
|
-
|
|
463
|
+
const reason = restartRefusal(this.ledgers.home, gate.live.map(l => ({ ...l, origin: this.store.workflows.get(l.wid)?.origin })), body, req.from.startsWith('main:'));
|
|
464
|
+
if (reason) {
|
|
452
465
|
gate.resume();
|
|
453
|
-
return { action: 'reject', reason
|
|
466
|
+
return { action: 'reject', reason };
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
await this.ledgers.orch.append('restart', { rid: req.rid, force: force && gate.live.length > 0, reason: body.reason, initiator, from: req.from, live: gate.live.map(l => l.exec) });
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
gate.resume();
|
|
473
|
+
throw error;
|
|
454
474
|
}
|
|
455
|
-
await this.ledgers.orch.append('restart', { rid: req.rid, force, live: gate.live.map(l => l.exec) });
|
|
456
475
|
this.restarting = true;
|
|
457
476
|
}
|
|
458
477
|
}
|
|
@@ -462,15 +481,6 @@ export class Engine {
|
|
|
462
481
|
return { action: 'reject', reason: 'unsupported-kind' };
|
|
463
482
|
return { action: 'apply' };
|
|
464
483
|
}
|
|
465
|
-
/** Restart refused: which executions run now, whose they are and for how long, and the way to proceed. */
|
|
466
|
-
busyReason(live) {
|
|
467
|
-
const now = Date.now(), minutes = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
|
|
468
|
-
const shown = live.slice(0, 8).map(l => {
|
|
469
|
-
const origin = this.store.workflows.get(l.wid)?.origin;
|
|
470
|
-
return `${l.wid}/${l.key}${l.phase === 'gate' ? ' gate' : ''} ${minutes(now - l.since)}${origin ? ` from ${origin}` : ''}`;
|
|
471
|
-
});
|
|
472
|
-
return `busy: ${live.length} running execution${live.length === 1 ? '' : 's'}: ${shown.join('; ')}${live.length > shown.length ? `; +${live.length - shown.length} more` : ''} — retry when they finish (or drain first), or force to fence them; they resume on the new orchestrator`;
|
|
473
|
-
}
|
|
474
484
|
/** Set by an applied restart: the loop ends and the process exits; its successor recovers every workflow. */
|
|
475
485
|
get restartRequested() { return this.restarting; }
|
|
476
486
|
/** A1, housekeeping: Prune finished workflows (named, or all ended more than olderThanDays ago); a replay of a
|
|
@@ -518,7 +528,11 @@ export class Engine {
|
|
|
518
528
|
/** A1, housekeeping: The pruned entry commits first; then the handle closes and the files go (recovery finishes them). */
|
|
519
529
|
async pruneWorkflow(rid, wf) {
|
|
520
530
|
const bytes = await this.store.footprint(wf.wid);
|
|
521
|
-
|
|
531
|
+
const done = this.terminal(wf), entries = this.ledgers.orch.entries();
|
|
532
|
+
const createdBy = String(entries.find(e => e.type === JT.created && e.wid === wf.wid)?.rid ?? '');
|
|
533
|
+
const admitted = requestId(createdBy) !== undefined ? entries.find(e => e.type === 'request' && e.request.rid === createdBy)?.request : undefined;
|
|
534
|
+
const identity = admitted ? { request: requestId(createdBy), spec_digest: specDigest(admitted) } : {};
|
|
535
|
+
await this.ledgers.orch.append('pruned', { rid, wid: wf.wid, endedAt: done.ts, bytes, status: String(done.status), ...identity });
|
|
522
536
|
this.states.delete(wf.wid);
|
|
523
537
|
await this.store.drop(wf.wid);
|
|
524
538
|
try {
|
|
@@ -557,7 +571,7 @@ export class Engine {
|
|
|
557
571
|
throw new Error(`Unknown pinned agent: ${spec.agent}`);
|
|
558
572
|
return { wid: st.wf.wid, widRev: `${st.wf.wid}@${st.wf.revision}`, key: entry.key, gen: entry.gen,
|
|
559
573
|
callId: `${st.wf.wid}@${st.wf.revision}/${entry.key}@${entry.gen}`, spec, agent, workflowBudget: st.wf.pins.usageBudget, cwd: resolve(st.wf.cwd, spec.cwd ?? '.'), journal: st.wf.journal,
|
|
560
|
-
...(st.wf.
|
|
574
|
+
...(st.wf.originPath !== undefined ? { originSession: st.wf.originPath } : {}),
|
|
561
575
|
...(entry.type === 'generation' ? { continueFrom: entry.from, opening: entry.opening } : {}),
|
|
562
576
|
...(entry.type === 'generation' && typeof entry.model === 'string' ? { model: entry.model } : {}) };
|
|
563
577
|
}
|
|
@@ -879,6 +879,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
879
879
|
if (exec && sleeping?.exec === exec) {
|
|
880
880
|
await fence(journal, exec, { park: a });
|
|
881
881
|
await release(exec);
|
|
882
|
+
await adoptAnswer(journal, t.callId, sleeping);
|
|
882
883
|
for (;;) {
|
|
883
884
|
if (interrupted(a) || has(journal, "timeout-intent", exec) || t.spec.timeoutMs !== undefined && activeTotal(journal.entries(), t.callId) >= t.spec.timeoutMs)
|
|
884
885
|
break;
|
|
@@ -908,6 +909,16 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
908
909
|
if (ev.budget || reached(totalUsage(journal.entries(), t.callId), t.spec.budget))
|
|
909
910
|
return finish(journal, t.callId, exec, makeResult("budget"));
|
|
910
911
|
if (!bound) {
|
|
912
|
+
// P28: an execution cut off while its only unfinished tool call is the open question's `ask` (before it
|
|
913
|
+
// hibernated) was waiting, not working: it hibernates now and resumes with the answer, like a planned
|
|
914
|
+
// hibernation; it is neither a loss nor, for `once`, an unknown outcome.
|
|
915
|
+
const asking = openQuestion(entries);
|
|
916
|
+
const segment = entries.findLastIndex(e => e.type === "custom" && e.customType === CT.exec && e.data?.exec === exec);
|
|
917
|
+
const ownQuestion = !!asking && segment >= 0 && entries.slice(segment + 1).some(e => e.customType === CT.question && e.data?.qid === asking.qid);
|
|
918
|
+
if (asking && ownQuestion && dangling.length && dangling.every(d => d.startsWith("ask (")) && hibernation(journal, t.callId)?.exec !== exec) {
|
|
919
|
+
await serial(() => journal.append("hibernated", { call: t.callId, exec, qid: asking.qid, rev: asking.rev }));
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
911
922
|
if (ev.report)
|
|
912
923
|
return finish(journal, t.callId, exec, buildCallResult({ key: t.key, gen: t.gen, status: ev.report.outcome, output: ev.text, usage: ev.usage,
|
|
913
924
|
...(Object.hasOwn(ev.report, "data") ? { report: { data: ev.report.data } } : {}), ...(Array.isArray(ev.report.artifacts) ? { artifacts: ev.report.artifacts } : {}) }));
|
|
@@ -1038,6 +1049,37 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
1038
1049
|
}
|
|
1039
1050
|
}
|
|
1040
1051
|
}
|
|
1052
|
+
/**
|
|
1053
|
+
* P28: an answer forwarded to the child while the question was still open but before the call hibernated (a restart
|
|
1054
|
+
* or crash cut the asker off, or the planned hibernation fenced it first) never reached a live child. Once the call
|
|
1055
|
+
* hibernates it is bound to the question like an answer given during hibernation; the forward is retired, so the
|
|
1056
|
+
* reply "apply" already given (and any retry with the same rid) stays true.
|
|
1057
|
+
*/
|
|
1058
|
+
async function adoptAnswer(journal, call, sleeping) {
|
|
1059
|
+
const entries = await readCall(journal, call);
|
|
1060
|
+
await serial(async () => {
|
|
1061
|
+
const all = journal.entries();
|
|
1062
|
+
if (all.some(e => e.type === "answer-bound" && e.call === call && e.qid === sleeping.qid && e.rev === sleeping.rev))
|
|
1063
|
+
return;
|
|
1064
|
+
const receipts = new Set(entries.map(receiptId).filter(rid => rid !== undefined));
|
|
1065
|
+
const f = all.find(e => {
|
|
1066
|
+
if (e.type !== "forward" || e.dest !== call)
|
|
1067
|
+
return false;
|
|
1068
|
+
const env = e.envelope;
|
|
1069
|
+
return env.kind === "answer" && env.cond?.qid === sleeping.qid && env.cond?.rev === sleeping.rev && !receipts.has(String(e.rid2)) &&
|
|
1070
|
+
!all.some(r => r.rid2 === e.rid2 && (r.type === "forward-retired" || r.type === "forward-delivered"));
|
|
1071
|
+
});
|
|
1072
|
+
if (!f)
|
|
1073
|
+
return;
|
|
1074
|
+
const item = attentionEntries(all).find(e => e.item.call === call && e.item.qid === sleeping.qid && e.item.rev === sleeping.rev)?.item;
|
|
1075
|
+
const message = String(f.envelope.body?.message ?? "");
|
|
1076
|
+
// A new identity: the stale answer may still sit in the child's inbox under the forward's rid2.
|
|
1077
|
+
await journal.append("answer-bound", { call, qid: sleeping.qid, rev: sleeping.rev, rid: f.rid, rid2: contentHash([String(f.rid2), "hibernated"]), hash: f.hash,
|
|
1078
|
+
message: `${HIBERNATED_NOTE}\n\nQuestion: ${item?.text ?? sleeping.qid}\nAnswer: ${message}` });
|
|
1079
|
+
await journal.append("forward-retired", { rid: f.rid, rid2: f.rid2, reason: "bound-to-hibernation" });
|
|
1080
|
+
await (await outbox).markResolved(String(f.rid2));
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1041
1083
|
async function replayForward(e) {
|
|
1042
1084
|
const envelope = e.envelope;
|
|
1043
1085
|
await (await outbox).send(envelope.to, envelope.kind, envelope.body, envelope.cond, { rid: String(e.rid2) });
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// config{hash,config}: the orchestrator settings in effect from here; config-rejected{error,hash?}: a change of
|
|
7
7
|
// config.json refused, while the earlier settings stay (cleared by the next config record).
|
|
8
8
|
// orchestrator{version,pid} / orchestrator-exit{pid}: the orchestrator running (its package version) and its exit.
|
|
9
|
+
// restart{rid,force,reason?,initiator?,from?,live}: accepted restart; the next start consumes its status provenance.
|
|
10
|
+
// Old records lack reason/initiator/from and remain readable (A1).
|
|
9
11
|
// writer-hold/writer-release{root,call}: the writer lock of a worktree root, held by one call until it ends.
|
|
10
12
|
import { foldExhaustion } from "./providers.js";
|
|
11
13
|
import { isEntry } from "../types.js";
|
|
@@ -31,8 +33,12 @@ export function applyLedger(state, e) {
|
|
|
31
33
|
}
|
|
32
34
|
else if (isEntry(e, "config-rejected"))
|
|
33
35
|
state.rejected = { error: String(e.error), ts: e.ts };
|
|
34
|
-
else if (
|
|
35
|
-
state.
|
|
36
|
+
else if (e.type === "restart")
|
|
37
|
+
state.pendingRestart = e.force === true ? { ts: e.ts, reason: e.reason, initiator: e.initiator, from: e.from } : undefined;
|
|
38
|
+
else if (isEntry(e, "orchestrator")) {
|
|
39
|
+
state.orchestrator = { version: String(e.version), pid: Number(e.pid), ...(e.start ? { start: String(e.start) } : {}), ts: e.ts, ...(e.restart === true ? { restart: true } : {}), ...(state.pendingRestart ? { forceRestart: state.pendingRestart } : {}) };
|
|
40
|
+
delete state.pendingRestart;
|
|
41
|
+
}
|
|
36
42
|
else if (e.type === "writer-hold") {
|
|
37
43
|
if (!state.writers.has(String(e.root)))
|
|
38
44
|
state.writers.set(String(e.root), { call: String(e.call), ts: e.ts });
|
|
@@ -52,7 +58,7 @@ export function applyLedger(state, e) {
|
|
|
52
58
|
export function foldLedger(state, entries) {
|
|
53
59
|
// Journal readers keep the entry objects of a ledger as it grows (kernel/journal.ts), so identity tells them apart.
|
|
54
60
|
if (state.seen && entries[state.seen - 1] !== state.last)
|
|
55
|
-
Object.assign(state, emptyLedger(), { config: undefined, rejected: undefined, orchestrator: undefined, last: undefined });
|
|
61
|
+
Object.assign(state, emptyLedger(), { config: undefined, rejected: undefined, orchestrator: undefined, pendingRestart: undefined, last: undefined });
|
|
56
62
|
for (; state.seen < entries.length; state.seen++)
|
|
57
63
|
applyLedger(state, entries[state.seen]);
|
|
58
64
|
state.last = entries[state.seen - 1];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Restart guards shared by the orchestrator and the legacy client. The caller owns the launch gate;
|
|
2
|
+
// these checks never change durable state. A1: only an accepted restart is appended to the ledger.
|
|
3
|
+
import { contentHash } from "../kernel/ids.js";
|
|
4
|
+
import { leaseCalls, leaseState } from "../platform/lease.js";
|
|
5
|
+
export const subagentRestartError = "a subagent cannot force a restart: it would fence itself and other sessions' work; ask the user";
|
|
6
|
+
export const restartToken = (live) => contentHash(live.map(l => l.exec).sort()).slice(0, 12);
|
|
7
|
+
/** Old senders' boolean force is read only to refuse it when executions are live. */
|
|
8
|
+
export const isForceRestart = (body) => body.token !== undefined || body.force === true;
|
|
9
|
+
export function restartInputError(body, subagent = false) {
|
|
10
|
+
const force = isForceRestart(body);
|
|
11
|
+
if (force && (subagent || body.initiator && "call" in body.initiator))
|
|
12
|
+
return subagentRestartError;
|
|
13
|
+
// An old client's bare `force: true` carries no reason: it is never a force (no token) and is refused with the
|
|
14
|
+
// list of running executions, so it is not rejected for the missing reason first.
|
|
15
|
+
if ((body.token !== undefined || body.reason !== undefined) && (typeof body.reason !== "string" || !body.reason.trim() || body.reason.length > 500))
|
|
16
|
+
return "restart reason must be non-empty and at most 500 characters (force requires --reason)";
|
|
17
|
+
if (body.token !== undefined && (typeof body.token !== "string" || !/^[a-f0-9]{12}$/.test(body.token)))
|
|
18
|
+
return "restart force needs the 12-hex token from a refused restart; first show the user the running executions";
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export function restartRefusal(home, live, body, tool = false, now = Date.now()) {
|
|
22
|
+
const invalid = restartInputError(body);
|
|
23
|
+
if (invalid)
|
|
24
|
+
return invalid;
|
|
25
|
+
if (!live.length)
|
|
26
|
+
return undefined;
|
|
27
|
+
const token = restartToken(live);
|
|
28
|
+
if (body.token === token)
|
|
29
|
+
return undefined;
|
|
30
|
+
const leases = leaseCalls(leaseState(home).map(r => ({ ...r, waiters: [] })), now);
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
for (const l of live) {
|
|
33
|
+
const origin = l.origin ?? "unknown";
|
|
34
|
+
groups.set(origin, [...(groups.get(origin) ?? []), l]);
|
|
35
|
+
}
|
|
36
|
+
const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
|
|
37
|
+
return [
|
|
38
|
+
...(body.token ? [`the running executions changed since ${body.token}`] : isForceRestart(body) ? ["force without a token is refused; first show the user the running executions"] : []),
|
|
39
|
+
`busy: ${live.length} running execution${live.length === 1 ? "" : "s"} — fencing them interrupts these sessions:`,
|
|
40
|
+
...[...groups].sort(([a], [b]) => a.localeCompare(b)).flatMap(([origin, executions]) => [
|
|
41
|
+
`${origin}:`, ...executions.map(l => ` ${l.wid}/${l.key} ${age(now - l.since)}${l.phase === "gate" ? " gate" : ""}${leases.has(l.callId) ? ` ${leases.get(l.callId)}` : ""}`),
|
|
42
|
+
]),
|
|
43
|
+
`token: ${token}`,
|
|
44
|
+
tool ? `to fence exactly these: subagents {action:"restart", force:"${token}", reason:"<why>"}` : `to fence exactly these: pi-durable-subagents restart --force ${token} --reason "<why>"`,
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
/** Status is one line even if the reason or parent command contained newlines. */
|
|
48
|
+
export const restartLine = (s) => s.replace(/\s+/g, " ").trim();
|
|
49
|
+
export function initiatorSummary(initiator, from) {
|
|
50
|
+
if (initiator && "call" in initiator)
|
|
51
|
+
return restartLine(initiator.call);
|
|
52
|
+
if (initiator && "origin" in initiator)
|
|
53
|
+
return restartLine(initiator.origin);
|
|
54
|
+
if (initiator && "cli" in initiator)
|
|
55
|
+
return restartLine(`cli:${initiator.cli.user}@${initiator.cli.host}`);
|
|
56
|
+
return restartLine(from ?? "unknown");
|
|
57
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Read-only status snapshots (P25: `status` is always a fresh snapshot). Pure readers of committed journals:
|
|
2
2
|
// never depend on orchestrator memory, so the UI, the CLI and the main agent see the same durable state.
|
|
3
|
+
import { initiatorSummary, restartLine } from "./restart.js";
|
|
3
4
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { compileFanout } from "../compat/fanout.js";
|
|
@@ -492,11 +493,13 @@ const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()]
|
|
|
492
493
|
const ledgerStates = new Map();
|
|
493
494
|
/** The orchestrator running now (its last `orchestrator` record, without an exit, whose process lives), and a note when
|
|
494
495
|
* its version is not the one this process loaded: running work stays on the version it started with. */
|
|
495
|
-
export function orchestratorView(state, loaded = packageVersion()) {
|
|
496
|
+
export function orchestratorView(state, loaded = packageVersion(), now = Date.now()) {
|
|
496
497
|
const o = state.orchestrator;
|
|
497
498
|
if (!o || o.exited || !processAlive(o.pid, o.start))
|
|
498
499
|
return {};
|
|
499
|
-
|
|
500
|
+
const r = o.forceRestart, note = r && now - r.ts < 24 * 60 * 60_000
|
|
501
|
+
? `; restarted by force ${age(now - r.ts)} ago by ${initiatorSummary(r.initiator, r.from)}: ${restartLine(r.reason ?? "no reason recorded")}` : "";
|
|
502
|
+
return { orchestrator: `${o.version} (pid ${o.pid})${note}`, ...(o.version === loaded ? {} : { versionNote: versionNote(o.version, loaded) }) };
|
|
500
503
|
}
|
|
501
504
|
/** Whether the recorded orchestrator still runs. On Linux its start time also tells it from a later process given the
|
|
502
505
|
* same pid after a crash (elsewhere the pid alone is checked). */
|
|
@@ -532,7 +535,7 @@ export function versionNote(running, loaded) {
|
|
|
532
535
|
: `the orchestrator runs durable-subagents ${running}, this pi loaded ${loaded}: running work stays on ${running}. ` +
|
|
533
536
|
`It exits about 10 s after all work ends and starts again on the installed version. To switch sooner: ` +
|
|
534
537
|
`restart (\`pi-durable-subagents restart\` or the subagents tool's restart action) — refused while an execution runs, ` +
|
|
535
|
-
`calls waiting for an answer or a slot do not block it; force fences running executions, which resume on the new version`;
|
|
538
|
+
`calls waiting for an answer or a slot do not block it; force with the refusal's token, a reason and explicit user approval fences running executions, which resume on the new version`;
|
|
536
539
|
}
|
|
537
540
|
/** orchestratorView of the home's orchestrator ledger. */
|
|
538
541
|
export function runningOrchestrator(home) {
|
|
@@ -553,7 +556,7 @@ export function slotsView(home, now = Date.now()) {
|
|
|
553
556
|
holders.set(e.pool, (holders.get(e.pool) ?? 0) + 1);
|
|
554
557
|
const names = [...new Set([...Object.keys(limits), ...holders.keys()])].sort();
|
|
555
558
|
const slots = names.map(p => { const n = holders.get(p) ?? 0, limit = limits[p]?.slots; return typeof limit === "number" ? `${p} ${n}/${limit}` : `${p} ${n} (no limit)`; });
|
|
556
|
-
return { ...orchestratorView(state), ...(slots.length ? { slots } : {}), ...(config ? { config: `${config.hash} since ${age(now - config.ts)} ago` } : {}),
|
|
559
|
+
return { ...orchestratorView(state, packageVersion(), now), ...(slots.length ? { slots } : {}), ...(config ? { config: `${config.hash} since ${age(now - config.ts)} ago` } : {}),
|
|
557
560
|
...(exhausted.length ? { exhausted } : {}),
|
|
558
561
|
...(rejected ? { configRejected: `${clip(rejected.error, 200)} (${age(now - rejected.ts)} ago); ${config ? config.hash : "the start settings"} stay in effect` } : {}) };
|
|
559
562
|
}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// revised {rid,revision,snapshot} commit publications; old pins remain immutable.
|
|
6
6
|
// pruned {rid,wid,endedAt,bytes} (written by the engine) is decisive: the wid is gone, its create-intent and
|
|
7
7
|
// created entries stay for identity (A1), and recovery finishes removing w/<wid> and its staging dirs.
|
|
8
|
-
import { lstat, readdir, readFile, stat, rm } from 'node:fs/promises';
|
|
9
|
-
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { lstat, open, readdir, readFile, rename, stat, rm } from 'node:fs/promises';
|
|
9
|
+
import { createReadStream, readFileSync } from 'node:fs';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
10
11
|
import { basename, extname, join, relative } from 'node:path';
|
|
11
12
|
import { discoverAgents } from "../compat/agents.js";
|
|
12
13
|
import { parseFrontmatter } from "../compat/frontmatter.js";
|
|
@@ -16,6 +17,36 @@ import { openJournal, syncDirectory } from "../kernel/journal.js";
|
|
|
16
17
|
import { publishFile } from "../kernel/mailbox.js";
|
|
17
18
|
import { journalPath, pinnedDir, workflowDir } from "../paths.js";
|
|
18
19
|
import { JT } from "../types.js";
|
|
20
|
+
/** Every file publishPins writes for one revision, relative to its directory (inputs given relative too). */
|
|
21
|
+
function publishedFiles(inputs, origin) {
|
|
22
|
+
return [...new Set(['script.js', 'args.json', 'agents.json', 'inputs.json', ...inputs, ...(origin ? ['origin.jsonl'] : [])])];
|
|
23
|
+
}
|
|
24
|
+
/** sha256 of a file, streamed. */
|
|
25
|
+
async function fileHash(path) {
|
|
26
|
+
const hash = createHash('sha256');
|
|
27
|
+
for await (const chunk of createReadStream(path))
|
|
28
|
+
hash.update(chunk);
|
|
29
|
+
return hash.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
/** Replace `name` in `dir` atomically and durably (a cache record: last writer wins). */
|
|
32
|
+
async function replaceFile(dir, name, text) {
|
|
33
|
+
const temp = join(dir, `.${ulid()}.tmp`), file = await open(temp, 'wx', 0o600);
|
|
34
|
+
try {
|
|
35
|
+
await file.writeFile(text);
|
|
36
|
+
await file.sync();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await file.close();
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await rename(temp, join(dir, name));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await rm(temp, { force: true });
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
await syncDirectory(dir);
|
|
49
|
+
}
|
|
19
50
|
// E3: errors of the machine rather than of the request; only these propagate, so the next intake retries the request.
|
|
20
51
|
// Every other staging error (validation, missing input, ENAMETOOLONG, EACCES, ...) is deterministic: failure.json.
|
|
21
52
|
// Conditions that clear without changing the request: retry at the next intake. Everything else is deterministic.
|
|
@@ -190,8 +221,10 @@ export class Store {
|
|
|
190
221
|
body = req.body;
|
|
191
222
|
snapshot = { hash: contentHash(req), pins: await prepareRun({ ...body, maxCalls: body.maxCalls ?? this.ledgers.config.k?.spawnBudget ?? 300 }, discovery, warnings),
|
|
192
223
|
...(warnings.length ? { warnings } : {}) };
|
|
193
|
-
if (req.kind === 'revise')
|
|
194
|
-
|
|
224
|
+
if (req.kind === 'revise') {
|
|
225
|
+
const prior = this.workflows.get(req.body.wid)?.originPath;
|
|
226
|
+
snapshot.pins.origin = prior === undefined ? undefined : await readFile(prior, 'utf8');
|
|
227
|
+
}
|
|
195
228
|
}
|
|
196
229
|
catch (error) {
|
|
197
230
|
if (transient(error))
|
|
@@ -279,6 +312,10 @@ export class Store {
|
|
|
279
312
|
async publishPins(wid, revision, pins) {
|
|
280
313
|
const root = pinnedDir(this.ledgers.home, wid), dir = revision === 1 ? root : join(root, `r${revision}`);
|
|
281
314
|
const publish = async (directory, name, bytes) => {
|
|
315
|
+
// Already published (a restart): compare without writing and syncing another copy.
|
|
316
|
+
const existing = await readFile(join(directory, name)).catch(() => undefined);
|
|
317
|
+
if (existing?.equals(typeof bytes === 'string' ? Buffer.from(bytes) : bytes))
|
|
318
|
+
return;
|
|
282
319
|
if (await publishFile(directory, name, bytes) === 'conflict')
|
|
283
320
|
throw new Error(`Pinned content conflict: ${directory}/${name}`);
|
|
284
321
|
};
|
|
@@ -294,15 +331,57 @@ export class Store {
|
|
|
294
331
|
inputs[name] = join(dir, 'inputs', file);
|
|
295
332
|
}
|
|
296
333
|
await publish(dir, 'inputs.json', JSON.stringify(inputs));
|
|
297
|
-
|
|
334
|
+
const { origin, inputs: _bytes, ...kept } = pins;
|
|
335
|
+
return { pins: { ...kept, inputs: {} }, scriptPath: join(dir, 'script.js'), inputs, originPath: origin === undefined ? undefined : join(dir, 'origin.jsonl') };
|
|
336
|
+
}
|
|
337
|
+
/** A1: The pinned files of one revision. The first publication verifies the staged snapshot and ends with `pins.json`:
|
|
338
|
+
* the in-memory pins and the sha256 of every published file, keyed by the snapshot hash. Later starts read that small
|
|
339
|
+
* record instead of parsing and re-publishing the snapshot (which can hold a multi-megabyte origin branch per
|
|
340
|
+
* workflow), and use it only when it is well formed, names this snapshot, its pins hash to its digest and every file
|
|
341
|
+
* hashes as recorded — otherwise they take the snapshot path, whose byte comparison reports a changed file as a
|
|
342
|
+
* conflict, and record it again. */
|
|
343
|
+
async pinnedFiles(wid, revision, intent) {
|
|
344
|
+
const dir = revision === 1 ? pinnedDir(this.ledgers.home, wid) : join(pinnedDir(this.ledgers.home, wid), `r${revision}`);
|
|
345
|
+
const ref = intent.pins ? undefined : intent.snapshot;
|
|
346
|
+
if (ref) {
|
|
347
|
+
const files = await this.recorded(dir, ref.hash).catch(() => undefined);
|
|
348
|
+
if (files)
|
|
349
|
+
return files;
|
|
350
|
+
}
|
|
351
|
+
const files = await this.publishPins(wid, revision, await this.pinned(intent));
|
|
352
|
+
if (ref) {
|
|
353
|
+
const hashes = {};
|
|
354
|
+
for (const name of publishedFiles(Object.values(files.inputs).map(path => relative(dir, path)), files.originPath !== undefined))
|
|
355
|
+
hashes[name] = await fileHash(join(dir, name));
|
|
356
|
+
const body = { snapshot: ref.hash, pins: files.pins,
|
|
357
|
+
inputs: Object.fromEntries(Object.entries(files.inputs).map(([name, path]) => [name, relative(dir, path)])), origin: files.originPath !== undefined, files: hashes };
|
|
358
|
+
await replaceFile(dir, 'pins.json', JSON.stringify({ ...body, digest: contentHash(body) }));
|
|
359
|
+
}
|
|
360
|
+
return files;
|
|
361
|
+
}
|
|
362
|
+
/** The files `pins.json` in `dir` records for snapshot `hash`, when the record and every file verify; else undefined. */
|
|
363
|
+
async recorded(dir, hash) {
|
|
364
|
+
const { digest, ...body } = JSON.parse(await readFile(join(dir, 'pins.json'), 'utf8'));
|
|
365
|
+
// The digest covers everything the record decides (pins, input names → files, origin, file hashes).
|
|
366
|
+
if (body.snapshot !== hash || typeof digest !== 'string' || contentHash(body) !== digest)
|
|
367
|
+
return undefined;
|
|
368
|
+
if (!body.pins || typeof body.pins !== 'object' || !body.inputs || typeof body.inputs !== 'object' || !body.files || typeof body.files !== 'object')
|
|
369
|
+
return undefined;
|
|
370
|
+
const names = publishedFiles(Object.values(body.inputs).map(String), body.origin === true);
|
|
371
|
+
if (names.length !== Object.keys(body.files).length || names.some(name => !Object.hasOwn(body.files, name)))
|
|
372
|
+
return undefined;
|
|
373
|
+
for (const name of names)
|
|
374
|
+
if (body.files[name] !== await fileHash(join(dir, name)))
|
|
375
|
+
return undefined;
|
|
376
|
+
return { pins: body.pins, scriptPath: join(dir, 'script.js'), inputs: Object.fromEntries(Object.entries(body.inputs).map(([name, file]) => [name, join(dir, String(file))])),
|
|
377
|
+
originPath: body.origin === true ? join(dir, 'origin.jsonl') : undefined };
|
|
298
378
|
}
|
|
299
379
|
/** P14, A2: Publish a new revision only after the engine has retired its predecessor. */
|
|
300
380
|
async revise(intent) {
|
|
301
381
|
const wf = this.workflows.get(intent.wid);
|
|
302
382
|
if (wf.revision >= Number(intent.revision))
|
|
303
383
|
return;
|
|
304
|
-
const
|
|
305
|
-
const files = await this.publishPins(wf.wid, revision, pins);
|
|
384
|
+
const revision = Number(intent.revision), files = await this.pinnedFiles(wf.wid, revision, intent);
|
|
306
385
|
await wf.journal.append('revised', { rid: intent.rid, revision, snapshot: intent.snapshot });
|
|
307
386
|
Object.assign(wf, files, { revision });
|
|
308
387
|
}
|
|
@@ -315,8 +394,7 @@ export class Store {
|
|
|
315
394
|
: wf.journal.entries().find(e => e.type === 'revised' && e.revision === revision);
|
|
316
395
|
if (!intent)
|
|
317
396
|
throw new Error(`Missing pinned revision: ${wf.wid}@${revision}`);
|
|
318
|
-
|
|
319
|
-
return { ...wf, revision, ...await this.publishPins(wf.wid, revision, pins) };
|
|
397
|
+
return { ...wf, revision, ...await this.pinnedFiles(wf.wid, revision, intent) };
|
|
320
398
|
}
|
|
321
399
|
/** A1, housekeeping: Wids with a committed pruned entry; they are gone and never materialize again. */
|
|
322
400
|
pruned() { return new Set(this.ledgers.orch.entries().filter(e => e.type === 'pruned').map(e => String(e.wid))); }
|
|
@@ -368,7 +446,7 @@ export class Store {
|
|
|
368
446
|
return existing;
|
|
369
447
|
if (this.pruned().has(wid))
|
|
370
448
|
throw new Error(`Workflow ${wid} was pruned`);
|
|
371
|
-
const
|
|
449
|
+
const files = await this.pinnedFiles(wid, 1, intent);
|
|
372
450
|
const journal = await openJournal(journalPath(this.ledgers.home, wid));
|
|
373
451
|
const wf = { wid, revision: 1, origin: intent.origin, cwd: intent.cwd, journal, ...files };
|
|
374
452
|
this.workflows.set(wid, wf);
|
|
@@ -377,7 +455,7 @@ export class Store {
|
|
|
377
455
|
...(intent.name !== undefined ? { name: intent.name } : {}) });
|
|
378
456
|
const revised = journal.entries().findLast(e => e.type === 'revised');
|
|
379
457
|
if (revised)
|
|
380
|
-
Object.assign(wf, await this.
|
|
458
|
+
Object.assign(wf, await this.pinnedFiles(wid, Number(revised.revision), revised), { revision: Number(revised.revision) });
|
|
381
459
|
if (!this.ledgers.orch.entries().some(e => e.type === JT.created && e.rid === intent.rid))
|
|
382
460
|
await this.ledgers.orch.append(JT.created, { rid: intent.rid, wid, origin: intent.origin });
|
|
383
461
|
return wf;
|
package/dist/requests.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// R1: Caller-chosen request ids. A program names a run/send/stop with `<id>`; its kernel rid is `req:<id>` (fits the
|
|
2
|
+
// mailbox rid pattern), unique per DSA_HOME across kinds and senders. A retry with the same id and the same content
|
|
3
|
+
// (spec_digest) gets the first outcome; a different content is a request-conflict and is never published.
|
|
4
|
+
// The check-and-send is serialized per home by the OS lock <home>/requests.lock (CLI and tool senders alike); no
|
|
5
|
+
// journal entry type is added: the admitted orch `request` entry and the senders' outbox `sent` entries are the record.
|
|
6
|
+
import { readdir } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
|
+
import { contentHash } from "./kernel/ids.js";
|
|
10
|
+
import { readJournalSnapshot } from "./kernel/journal.js";
|
|
11
|
+
import { publishRequest } from "./kernel/mailbox.js";
|
|
12
|
+
import { OsLock } from "./platform/lock.js";
|
|
13
|
+
import { orchInbox, orchLedger, outboxRoot } from "./paths.js";
|
|
14
|
+
export const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,123}$/;
|
|
15
|
+
const PREFIX = 'req:';
|
|
16
|
+
/** R1: `<id>` → its rid `req:<id>`; ids are 1–124 chars `[A-Za-z0-9][A-Za-z0-9._:-]*`. */
|
|
17
|
+
export function requestRid(id) {
|
|
18
|
+
if (!REQUEST_ID.test(id))
|
|
19
|
+
throw new Error(`invalid request id ${JSON.stringify(id)}: 1-124 characters [A-Za-z0-9][A-Za-z0-9._:-]*`);
|
|
20
|
+
return PREFIX + id;
|
|
21
|
+
}
|
|
22
|
+
/** R1: The request id of a `req:<id>` rid; undefined for any other rid (ULIDs never contain ':'). */
|
|
23
|
+
export function requestId(rid) { return rid.startsWith(PREFIX) ? rid.slice(PREFIX.length) : undefined; }
|
|
24
|
+
/** R1: spec_digest = contentHash({kind, body, cond}) (cond omitted when absent). A run's body.origin (the pi session
|
|
25
|
+
* branch offered for context:"fork") is delivery metadata, not spec: it differs on every turn, so it is not hashed. */
|
|
26
|
+
export function specDigest(req) {
|
|
27
|
+
let body = req.body;
|
|
28
|
+
if (req.kind === 'run' && body && typeof body === 'object' && !Array.isArray(body)) {
|
|
29
|
+
const { origin: _, ...rest } = body;
|
|
30
|
+
body = rest;
|
|
31
|
+
}
|
|
32
|
+
return contentHash({ kind: req.kind, body, cond: req.cond });
|
|
33
|
+
}
|
|
34
|
+
/** R1: The envelope recorded for `rid`: the orchestrator's admitted copy (ledger `request`, kept after prune), else any
|
|
35
|
+
* sender's outbox `sent` entry (published or about to be). Read-only. */
|
|
36
|
+
export async function findRequest(home, rid) {
|
|
37
|
+
const admitted = readJournalSnapshot(orchLedger(home)).find(e => e.type === 'request' && e.request.rid === rid);
|
|
38
|
+
if (admitted)
|
|
39
|
+
return { request: admitted.request, admitted: true };
|
|
40
|
+
const dir = join(outboxRoot(home), 'outbox');
|
|
41
|
+
const names = await readdir(dir).catch(error => { if (error.code === 'ENOENT')
|
|
42
|
+
return []; throw error; });
|
|
43
|
+
for (const name of names.filter(n => n.endsWith('.jsonl')).sort()) {
|
|
44
|
+
const sent = readJournalSnapshot(join(dir, name)).find(e => e.type === 'sent' && e.request.rid === rid);
|
|
45
|
+
if (sent)
|
|
46
|
+
return { request: sent.request, admitted: false };
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** A lock was not free within 10 s: nothing was submitted by this attempt; a retry with the same id is safe. */
|
|
51
|
+
export class RequestsBusy extends Error {
|
|
52
|
+
name = 'RequestsBusy';
|
|
53
|
+
}
|
|
54
|
+
/** R1, P5: Check-then-send under the home-wide request-id lock (innermost: taken after a sender's own lock), so no two
|
|
55
|
+
* senders publish one rid and no second envelope with an existing rid and other content reaches the inbox (it would
|
|
56
|
+
* stall its sender's sequence). Same content: the recorded envelope stands (republished when it is this sender's and
|
|
57
|
+
* pending; `sent` false). */
|
|
58
|
+
export async function sendIdentified(home, outbox, sender, rid, kind, body, cond) {
|
|
59
|
+
const digest = specDigest({ kind, body, cond }), path = join(home, 'requests.lock'), locker = new OsLock(), deadline = performance.now() + 10_000;
|
|
60
|
+
let lock = await locker.tryAcquire(path);
|
|
61
|
+
while (!lock && performance.now() < deadline) {
|
|
62
|
+
await delay(25);
|
|
63
|
+
lock = await locker.tryAcquire(path);
|
|
64
|
+
}
|
|
65
|
+
if (!lock)
|
|
66
|
+
throw new RequestsBusy('request ids are busy; retry the command');
|
|
67
|
+
try {
|
|
68
|
+
const prior = await findRequest(home, rid);
|
|
69
|
+
if (prior && specDigest(prior.request) !== digest)
|
|
70
|
+
return { conflict: prior.request, digest: specDigest(prior.request) };
|
|
71
|
+
if (prior) {
|
|
72
|
+
// Its own envelope is re-offered verbatim (Outbox.send returns it and republishes it while pending).
|
|
73
|
+
// Another sender's envelope not admitted yet is published verbatim (publishing is idempotent per rid): a sender
|
|
74
|
+
// that died between recording and publishing it would otherwise leave the id pending for ever.
|
|
75
|
+
let request = prior.request;
|
|
76
|
+
if (prior.request.from === sender)
|
|
77
|
+
request = await outbox.send(prior.request.to, prior.request.kind, prior.request.body, prior.request.cond, { rid });
|
|
78
|
+
else if (!prior.admitted)
|
|
79
|
+
await publishRequest(orchInbox(home), prior.request);
|
|
80
|
+
return { request, digest, sent: false };
|
|
81
|
+
}
|
|
82
|
+
return { request: await outbox.send('orch', kind, body, cond, { rid }), digest, sent: true };
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await lock.release();
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-durable-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|