pi-durable-subagents 1.0.15 → 1.0.17
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 +27 -0
- package/README.md +3 -3
- package/dist/agent/child.js +2 -0
- package/dist/agent/main/snapshots.js +6 -2
- package/dist/agent/main/tool.js +1 -1
- package/dist/agent/main.js +1 -1
- package/dist/orchestrator/engine.js +4 -2
- package/dist/orchestrator/executor/index.js +119 -28
- package/dist/orchestrator/executor/observe.js +17 -6
- package/dist/orchestrator/snapshot.js +1 -1
- package/dist/ui/cards.js +35 -17
- package/dist/ui/index.js +5 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.17
|
|
4
|
+
|
|
5
|
+
- A silent subagent shows one warning instead of separate activity and
|
|
6
|
+
progress warnings. The message now says that no new output or tool
|
|
7
|
+
progress has been received and that the model may still be processing;
|
|
8
|
+
the ten-minute thresholds are unchanged.
|
|
9
|
+
- Warning cards update in place to `recovered` when activity resumes, or
|
|
10
|
+
`ended` when the execution ends. They refresh even when the status line
|
|
11
|
+
does not change or the dock is turned off.
|
|
12
|
+
- Execution checkpoints and warnings record the latest received stream
|
|
13
|
+
update's time and type for diagnosis, without recording its content.
|
|
14
|
+
|
|
15
|
+
## 1.0.16
|
|
16
|
+
|
|
17
|
+
- A used-up usage window is found while pi is still retrying: at the second
|
|
18
|
+
quota refusal in a row (`No available accounts`, usage limit, quota
|
|
19
|
+
exceeded), not after pi's retries end. A call from a pool moves to the
|
|
20
|
+
pool's next model that is not used up and has a free slot, in the same
|
|
21
|
+
execution and session, within pi's next retry or two (refused requests use
|
|
22
|
+
no quota); one with a single model waits for its provider. Before, a call kept retrying the used-up provider for as
|
|
23
|
+
long as pi's retry settings allowed (over ten minutes with ten retries).
|
|
24
|
+
- `send kind:"model"` and a follow-up's `model` accept a pool's name: the
|
|
25
|
+
first model of the pool that is not used up (for a running call, also with
|
|
26
|
+
a free slot). The reply names the model picked; a call from that pool stays
|
|
27
|
+
in it, so a later used-up window still moves it on. A follow-up naming a
|
|
28
|
+
pool starts its generation from the pool's order.
|
|
29
|
+
|
|
3
30
|
## 1.0.15
|
|
4
31
|
|
|
5
32
|
- The changes listed under 1.0.14, which was tagged but never published:
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ the npx cache, so `install-service` refuses to run from there.
|
|
|
55
55
|
| You steer a subagent while it is asking you a question | Your message reaches it, in order. Nothing is rejected or lost. |
|
|
56
56
|
| Two steers arrive out of order and the second replaces the first | Only the second one applies. |
|
|
57
57
|
| A step is refused, or a dependency fails | The workflow stops that branch cleanly. Nothing is retried in vain. |
|
|
58
|
-
| A provider's usage window runs out (`No available accounts`, usage limit, quota exceeded) | A call in a pool continues **in the same session** on the pool's next model; new calls skip that provider. After 15 minutes the next call that wants it tries it once; when it answers, new calls and new generations use it again. A call with a single model waits for it instead of failing. Billing errors (402, insufficient balance) still fail at once. |
|
|
58
|
+
| A provider's usage window runs out (`No available accounts`, usage limit, quota exceeded) | Found at the second refusal in a row, while pi is still retrying. A call in a pool continues **in the same session** on the pool's next model (within pi's next retry or two); new calls skip that provider. After 15 minutes the next call that wants it tries it once; when it answers, new calls and new generations use it again. A call with a single model waits for it instead of failing. Billing errors (402, insufficient balance) still fail at once. |
|
|
59
59
|
| Two subagents edit the same worktree | A reminder names both calls; neither is blocked or locked. Only observed `edit`/`write` calls count (bash-only writes are not seen). Calls with `isolation: "worktree"` have their own worktrees. |
|
|
60
60
|
| A subagent waits for an answer for a long time | It releases its model slot and memory, then resumes exactly once when you answer. |
|
|
61
61
|
|
|
@@ -98,9 +98,9 @@ it something. Each verb means one thing, and a refusal says what would work:
|
|
|
98
98
|
|---|---|---|
|
|
99
99
|
| `run` | — | Start one subagent, `tasks` in parallel, a `chain`, or a workflow script. An unknown agent name is refused before anything starts, with the list of agents. |
|
|
100
100
|
| `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`; To one waiting on its question: it interrupts the question, and the subagent usually asks again; `answer` answers it. |
|
|
101
|
-
| `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). With `model
|
|
101
|
+
| `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). With `model` (a model or a pool's name), that generation runs on it. |
|
|
102
102
|
| `send answer` | an open question | Answers it once. |
|
|
103
|
-
| `send model` | any subagent | A running one switches at its next request; one asking, hibernated or waiting for a slot launches on it when it runs again. |
|
|
103
|
+
| `send model` | any subagent | A running one switches at its next request; one asking, hibernated or waiting for a slot launches on it when it runs again. A pool's name picks its first model that is not used up (and, for a running call, has a free slot); the reply names the model picked, and a call from that pool stays in it. |
|
|
104
104
|
| `stop` | a subagent or a workflow | Final: `stopped`, usage kept, edits left as they are. |
|
|
105
105
|
| `drain` / `resume` | existing workflows | A reversible hold; runs started later are not held. |
|
|
106
106
|
|
package/dist/agent/child.js
CHANGED
|
@@ -98,6 +98,8 @@ export function registerChild(pi) {
|
|
|
98
98
|
return { action: 'defer' };
|
|
99
99
|
if (req.kind === 'model') {
|
|
100
100
|
const body = req.body;
|
|
101
|
+
if (body?.exec !== undefined && body.exec !== exec)
|
|
102
|
+
return { action: 'reject', reason: 'stale-execution' };
|
|
101
103
|
return body && ctx.modelRegistry.find(body.provider, body.model) ? { action: 'apply' } : { action: 'reject', reason: 'unknown-model' };
|
|
102
104
|
}
|
|
103
105
|
if (MESSAGES.includes(req.kind)) {
|
|
@@ -107,11 +107,15 @@ const resolutions = new WeakMap();
|
|
|
107
107
|
function resolvedIn(entries) {
|
|
108
108
|
let done = resolutions.get(entries);
|
|
109
109
|
if (!done) {
|
|
110
|
-
done = new
|
|
110
|
+
done = new Map(entries.filter(e => e.type === JT.attentionResolved).map(e => [JSON.stringify([e.id, e.rev]), typeof e.resolution === "string" ? e.resolution : "resolved"]));
|
|
111
111
|
resolutions.set(entries, done);
|
|
112
112
|
}
|
|
113
113
|
return done;
|
|
114
114
|
}
|
|
115
|
+
/** Preserve the reason so presentation distinguishes resumed output from a terminated execution. */
|
|
116
|
+
export function attentionResolution(home, item) {
|
|
117
|
+
return resolvedIn(readJournalSnapshot(journalPath(home, item.wid))).get(JSON.stringify([item.id, item.rev]));
|
|
118
|
+
}
|
|
115
119
|
const asks = new Map(), ASK_SCANS = 64, MARK = 64;
|
|
116
120
|
function ask(scan, line) {
|
|
117
121
|
if (!line.includes('"ask"'))
|
|
@@ -189,7 +193,7 @@ function answeredAsks(path) {
|
|
|
189
193
|
}
|
|
190
194
|
/** P15: Refresh a question against durable workflow and child receipts at request time. */
|
|
191
195
|
export function resolved(home, item) {
|
|
192
|
-
if (
|
|
196
|
+
if (attentionResolution(home, item) !== undefined)
|
|
193
197
|
return true;
|
|
194
198
|
if (item.kind !== "question" || !item.session || !item.qid)
|
|
195
199
|
return false;
|
package/dist/agent/main/tool.js
CHANGED
|
@@ -44,7 +44,7 @@ function call(value, cwd, where) {
|
|
|
44
44
|
* (a follow-up's new generation runs on it). From the orchestrator ledger's `send-note`. */
|
|
45
45
|
export function sendReceipt(ledger, rid) {
|
|
46
46
|
const note = ledger.find(e => e.type === "send-note" && e.rid === rid);
|
|
47
|
-
return note ? { model: String(note.model), effect: String(note.effect) } : {};
|
|
47
|
+
return note ? { model: String(note.model), effect: String(note.effect), ...(note.pool ? { pool: String(note.pool) } : {}) } : {};
|
|
48
48
|
}
|
|
49
49
|
export function request(args, cwd) {
|
|
50
50
|
// v12 §2: Infer run only when one launch form is present; never guess a control verb.
|
package/dist/agent/main.js
CHANGED
|
@@ -342,7 +342,7 @@ export function registerMain(pi, ui) {
|
|
|
342
342
|
"Durable asynchronous subagents; run returns {wid} when created (or {submitted:{rid}} while pending). A finished workflow (its notice carries every agent's result) or a question wakes you, so after starting work end your turn: never poll with sleep or repeated status. Crash recovery resumes sessions, not external side effects. Background helper processes (orchestrator, evaluator) exit by themselves about 10 s after all work ends: never kill processes or delete files to 'clean up'. When the user quits pi, this session's running workflows pause (nothing is spent); resume continues them.",
|
|
343
343
|
"run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
|
|
344
344
|
"agents: list names, descriptions, default models and source for this cwd; use these names for run.",
|
|
345
|
-
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn; follow-up model:'provider/id' runs that generation on it. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model: a running call switches at its next provider request; an asking, hibernated or queued call launches on it when it runs again; the reply's model/effect (next-request|next-execution|next-generation) says which. status model = model actually used by the last request; switching = requested, not used yet; switchFailed = refused. A provider content refusal (ToS/usage policy) fails the call at once, not retried. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
345
|
+
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn; follow-up model:'provider/id' or a pool name runs that generation on it. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model ('provider/id' or a pool name — its first model not used up): a running call switches at its next provider request; an asking, hibernated or queued call launches on it when it runs again; the reply's model/effect (next-request|next-execution|next-generation) says which. status model = model actually used by the last request; switching = requested, not used yet; switchFailed = refused. A provider content refusal (ToS/usage policy) fails the call at once, not retried. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
346
346
|
"stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status: without wid, what runs, asks (with its answer address; hibernated:true holds no slot) or failed, sharedWorktree names calls sharing observed edit/write roots (reminder only), finished workflows one line each, provider slots held/limit, the config in effect and providers whose usage window is used up (avoided until a probe finds them answering again), and the orchestrator version (versionNote when it differs from the loaded one); wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
|
|
347
347
|
"Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
|
|
348
348
|
...(agents ? [`Available agents: ${agents}.`] : []),
|
|
@@ -331,7 +331,9 @@ export class Engine {
|
|
|
331
331
|
const seal = wf.journal.entries().find(e => e.type === JT.sealed && e.call === from);
|
|
332
332
|
if (seal && send.kind === 'steer')
|
|
333
333
|
return { action: 'reject', reason: `finished:${seal.result.status} — use kind "follow-up" to continue it` };
|
|
334
|
-
|
|
334
|
+
// A pool's name is a model too: the call keeps the pool, and its order and failover apply to the new generation.
|
|
335
|
+
const pools = this.ledgers.config.pools, pool = send.model !== undefined && pools && Object.hasOwn(pools, send.model);
|
|
336
|
+
if (send.kind === 'follow-up' && send.model !== undefined && !pool) {
|
|
335
337
|
try {
|
|
336
338
|
if (!parseModel(send.model).provider)
|
|
337
339
|
throw new Error('missing provider');
|
|
@@ -346,7 +348,7 @@ export class Engine {
|
|
|
346
348
|
const spec = send.model !== undefined ? { ...entry.spec, model: send.model } : entry.spec;
|
|
347
349
|
if (send.model !== undefined)
|
|
348
350
|
await this.note(req.rid, send.model, 'next-generation');
|
|
349
|
-
const opened = await wf.journal.append('generation', { rid: req.rid, key: entry.key, gen, from, spec, revision: wf.revision, opening: { rid: req.rid, kind: send.kind, message: send.message ?? '' }, ...(send.model !== undefined ? { model: send.model } : {}) });
|
|
351
|
+
const opened = await wf.journal.append('generation', { rid: req.rid, key: entry.key, gen, from, spec, revision: wf.revision, opening: { rid: req.rid, kind: send.kind, message: send.message ?? '' }, ...(send.model !== undefined && !pool ? { model: send.model } : {}) });
|
|
350
352
|
this.dispatchGeneration(wf, opened);
|
|
351
353
|
return { action: 'apply' };
|
|
352
354
|
}
|
|
@@ -34,6 +34,8 @@ import { WorktreeIndex, worktreeCalls, worktreeLabel, worktreePair, worktreeRoot
|
|
|
34
34
|
const MEM_RECORD_MS = 30000;
|
|
35
35
|
/** A4, P29: A child admitted within this window may not show in MemAvailable yet; its share is reserved explicitly. */
|
|
36
36
|
const MEM_WARMUP_MS = 30000;
|
|
37
|
+
/** Quota refusals in a row that find a provider's usage window used up while pi is still retrying. */
|
|
38
|
+
const QUOTA_REFUSALS = 2;
|
|
37
39
|
const ignoreMissing = (error) => { if (error.code !== "ENOENT")
|
|
38
40
|
throw error; };
|
|
39
41
|
const callOf = (exec) => exec.slice(0, exec.lastIndexOf("#"));
|
|
@@ -61,7 +63,8 @@ export function requestedModel(journal, call, followUp) {
|
|
|
61
63
|
wanted = m;
|
|
62
64
|
}
|
|
63
65
|
for (const [index, e] of all.entries()) {
|
|
64
|
-
|
|
66
|
+
// A failover's switch is not a request: an execution that ends before applying it leaves the choice to the pool.
|
|
67
|
+
if (e.type !== "forward" || e.dest !== call || e.envelope?.kind !== "model" || e.failover)
|
|
65
68
|
continue;
|
|
66
69
|
const delivered = all.find(r => r.type === "forward-delivered" && r.call === call && r.rid2 === e.rid2);
|
|
67
70
|
if (delivered) {
|
|
@@ -300,9 +303,9 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
300
303
|
}
|
|
301
304
|
/** P7, P27: Record forward-delivered once when a forward's child receipt is first observed; serial sections only. */
|
|
302
305
|
/** The reply to a model send says which model and when it applies (orchestrator ledger `send-note`, once per rid). */
|
|
303
|
-
async function note(rid, model, effect) {
|
|
306
|
+
async function note(rid, model, effect, pool) {
|
|
304
307
|
if (!orch.entries().some(e => e.type === "send-note" && e.rid === rid))
|
|
305
|
-
await orch.append("send-note", { rid, model, effect });
|
|
308
|
+
await orch.append("send-note", { rid, model, effect, ...(pool ? { pool } : {}) });
|
|
306
309
|
}
|
|
307
310
|
async function forwardsDelivered(journal, call, entries) {
|
|
308
311
|
const all = journal.entries();
|
|
@@ -491,7 +494,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
491
494
|
const decision = await decide(), models = decision.candidates, pool = decision.pool;
|
|
492
495
|
continuation = decision.continuation;
|
|
493
496
|
for (const model of models) {
|
|
494
|
-
if (!continuation && pool && skipped(pool, model))
|
|
497
|
+
if (!continuation && pool && models.length > 1 && skipped(pool, model))
|
|
495
498
|
continue;
|
|
496
499
|
const provider = model.provider;
|
|
497
500
|
if (unavailable(provider))
|
|
@@ -559,14 +562,19 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
559
562
|
const candidate = recorded && candidates.some(m => m.provider === recorded.provider && m.id === recorded.id);
|
|
560
563
|
// Leave the session's model for the pool's others when its pool skips it after losses, or its provider's usage
|
|
561
564
|
// window is used up; and at a new generation, go back to the pool's order of preference.
|
|
562
|
-
|
|
565
|
+
// A new generation of a pool call starts from the pool also when the session's model is not one of its models
|
|
566
|
+
// (switched outside it, or the follow-up named the pool).
|
|
567
|
+
const skip = pool && (candidate ? previous && ownSegment && skipped(pool, recorded) || unavailable(recorded.provider) || !ownSegment && !!t.continueFrom
|
|
568
|
+
: !ownSegment && !!t.continueFrom);
|
|
563
569
|
// A model the call was asked to use replaces the session's: launched with it, and holding its provider's slot.
|
|
564
570
|
const wanted = requestedModel(t.journal, t.callId, t.model);
|
|
565
571
|
// It outranks the pool's order at a new generation too, also when it names the model the session already has.
|
|
572
|
+
// A requested model of the call's own pool keeps the pool: a used-up window still moves the call on.
|
|
573
|
+
const keep = pool && candidates.some(m => m.provider === wanted?.provider && m.id === wanted?.id) ? pool : undefined;
|
|
566
574
|
if (wanted)
|
|
567
575
|
return recorded && !freshFork && recorded.provider === wanted.provider && recorded.id === wanted.id
|
|
568
|
-
? { candidates: [recorded], continuation: true, pool:
|
|
569
|
-
: { candidates: [wanted], continuation: false, pool:
|
|
576
|
+
? { candidates: [recorded], continuation: true, pool: keep }
|
|
577
|
+
: { candidates: [wanted], continuation: false, pool: keep };
|
|
570
578
|
if (recorded && !freshFork && !skip)
|
|
571
579
|
return { candidates: [recorded], continuation: true, pool: candidate ? pool : undefined };
|
|
572
580
|
return { candidates, continuation: false, pool };
|
|
@@ -634,15 +642,87 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
634
642
|
// While a probe runs, its outcome alone decides: a late refusal of an execution admitted earlier changes nothing.
|
|
635
643
|
if (x && (x.probe ? x.probe !== exec : now < x.nextTry))
|
|
636
644
|
return;
|
|
637
|
-
|
|
645
|
+
// Once per execution and provider: an execution moved on by failover can find a second provider used up too.
|
|
646
|
+
if (orch.entries().some(e => e.type === "provider-exhausted" && e.exec === exec && e.provider === provider))
|
|
638
647
|
return;
|
|
639
648
|
await orch.append("provider-exhausted", { provider, exec, since: x?.since ?? now, nextTry: now + (config.k?.probeMs ?? 900_000), error: error.slice(0, 300) });
|
|
640
649
|
}
|
|
650
|
+
/** Quota refusals in a row per execution, from one provider (pi retries a refused request on its own). */
|
|
651
|
+
const refusals = new Map();
|
|
652
|
+
/** A used-up window shows while pi still retries: the second refusal in a row (the first for a probe) finds the
|
|
653
|
+
* provider used up, and a call launched from a pool switches to the pool's next model at its next request. */
|
|
654
|
+
async function refused(t, exec, provider, error) {
|
|
655
|
+
if (!quotaExhausted(error))
|
|
656
|
+
return;
|
|
657
|
+
const last = refusals.get(exec), count = last?.provider === provider ? last.count + 1 : 1;
|
|
658
|
+
refusals.set(exec, { provider, count });
|
|
659
|
+
const probe = folded().exhausted.get(provider)?.probe === exec;
|
|
660
|
+
if (count < (probe ? 1 : QUOTA_REFUSALS))
|
|
661
|
+
return;
|
|
662
|
+
await serial(async () => {
|
|
663
|
+
if (has(t.journal, JT.fenced, exec) || current(t.journal, t.callId) !== exec)
|
|
664
|
+
return;
|
|
665
|
+
await recordExhausted(provider, exec, error);
|
|
666
|
+
await failover(t, exec, provider);
|
|
667
|
+
});
|
|
668
|
+
wake();
|
|
669
|
+
}
|
|
670
|
+
/** Switch a running execution off a used-up provider: to the first model of its pool on another provider that is
|
|
671
|
+
* neither used up nor full, reserving that slot as a requested switch does. Without one, pi's retries go on and
|
|
672
|
+
* the call waits for the provider once they end. */
|
|
673
|
+
async function failover(t, exec, provider) {
|
|
674
|
+
if (pendingSwitch(exec))
|
|
675
|
+
return;
|
|
676
|
+
const pool = t.journal.entries().findLast(e => e.type === "selected" && e.exec === exec)?.pool, pools = settings().pools;
|
|
677
|
+
if (!pool || !pools?.[pool])
|
|
678
|
+
return;
|
|
679
|
+
let models;
|
|
680
|
+
try {
|
|
681
|
+
models = resolveModel(pool, pools);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
for (const m of models) {
|
|
687
|
+
if (!m.provider || m.provider === provider || unavailable(m.provider) || skipped(pool, m))
|
|
688
|
+
continue;
|
|
689
|
+
const rid = contentHash([exec, "failover", provider]);
|
|
690
|
+
if (t.journal.entries().some(e => e.type === "forward" && e.rid === rid))
|
|
691
|
+
return;
|
|
692
|
+
const probe = folded().exhausted.has(m.provider); // its next try is due (`unavailable` said so): this is its probe
|
|
693
|
+
if (!await reserveSwitch(exec, m.provider, rid))
|
|
694
|
+
continue;
|
|
695
|
+
if (probe)
|
|
696
|
+
await orch.append("provider-probe", { provider: m.provider, exec });
|
|
697
|
+
// Bound to this execution: replayed after it ended, a later execution (which chose its model at launch) refuses it.
|
|
698
|
+
const body = { provider: m.provider, model: m.id, ...(m.thinking ? { thinking: m.thinking } : {}), exec };
|
|
699
|
+
const envelope = { to: t.callId, kind: "model", body }, hash = contentHash(envelope);
|
|
700
|
+
const entry = await t.journal.append("forward", { rid, rid2: forwardRid(rid, t.callId.slice(0, t.callId.indexOf("/")), t.key, hash), dest: t.callId, hash, envelope, failover: provider });
|
|
701
|
+
await replayForward(entry);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** Hold a slot of `provider` for a running execution's switch, unless it holds one; false when it is full. */
|
|
706
|
+
async function reserveSwitch(exec, provider, rid) {
|
|
707
|
+
if (holdings().some(h => h.exec === exec && h.pool === provider))
|
|
708
|
+
return true;
|
|
709
|
+
const target = holdings().filter(h => h.pool === provider);
|
|
710
|
+
if (!capacity({ kind: "provider", holders: target.length, capacity: settings().providers?.[provider]?.slots ?? Infinity }))
|
|
711
|
+
return false;
|
|
712
|
+
let slot = 0;
|
|
713
|
+
while (target.some(h => h.slot === slot))
|
|
714
|
+
slot++;
|
|
715
|
+
await orch.append("hold", { pool: provider, slot, exec, reserved: true, rid });
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
641
718
|
/** An answer from a used-up provider, requested after it was found used up: available again. */
|
|
642
|
-
async function answered(exec, event) {
|
|
719
|
+
async function answered(t, exec, event) {
|
|
643
720
|
const message = event.message, provider = message?.provider;
|
|
644
|
-
if (message?.role !== "assistant" || !provider
|
|
721
|
+
if (message?.role !== "assistant" || !provider)
|
|
645
722
|
return;
|
|
723
|
+
if (message.stopReason === "error")
|
|
724
|
+
return refused(t, exec, provider, String(message.errorMessage ?? ""));
|
|
725
|
+
refusals.delete(exec);
|
|
646
726
|
await serial(async () => {
|
|
647
727
|
const x = folded().exhausted.get(provider);
|
|
648
728
|
if (x && (x.probe === exec || Number(message.timestamp) > x.since))
|
|
@@ -836,7 +916,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
836
916
|
});
|
|
837
917
|
}, recordUsage: values => recordUsage(t, values),
|
|
838
918
|
wrote: path => wrote(t, exec, cwd, path),
|
|
839
|
-
switched: event => switched(exec, journal, event), answered: event => answered(exec, event), pendingSwitch: () => pendingSwitch(exec),
|
|
919
|
+
switched: event => switched(exec, journal, event), answered: event => answered(t, exec, event), pendingSwitch: () => pendingSwitch(exec),
|
|
840
920
|
});
|
|
841
921
|
}
|
|
842
922
|
finally {
|
|
@@ -881,6 +961,9 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
881
961
|
for (const e of inUse.keys())
|
|
882
962
|
if (callOf(e) === ticket.callId)
|
|
883
963
|
inUse.delete(e);
|
|
964
|
+
for (const e of refusals.keys())
|
|
965
|
+
if (callOf(e) === ticket.callId)
|
|
966
|
+
refusals.delete(e);
|
|
884
967
|
forgetSession(callSession(home, ticket.wid, ticket.key, ticket.gen));
|
|
885
968
|
wake();
|
|
886
969
|
}
|
|
@@ -931,6 +1014,28 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
931
1014
|
/** P12: a model request to this call, recorded with the rid given; a reject has no effect. */
|
|
932
1015
|
const requestModel = async (rid, model, hash, cond) => {
|
|
933
1016
|
let body;
|
|
1017
|
+
const exec = current(ctx.journal, dest);
|
|
1018
|
+
// P28: with no live execution (not started yet, between executions, hibernated while asking) the model is
|
|
1019
|
+
// recorded and the next execution launches on it (`requestedModel`); its slot is acquired then, as for any launch.
|
|
1020
|
+
const idle = !exec || has(ctx.journal, JT.fenced, exec) || !has(ctx.journal, "selected", exec);
|
|
1021
|
+
// A pool's name asks for its first model that can take the call now: provider not used up, and (for a running
|
|
1022
|
+
// call) a free slot. The call's own pool stays, so a used-up window later moves it on as before.
|
|
1023
|
+
const pools = settings().pools, pool = pools && Object.hasOwn(pools, model) ? model : undefined;
|
|
1024
|
+
if (pool) {
|
|
1025
|
+
let models;
|
|
1026
|
+
try {
|
|
1027
|
+
models = resolveModel(pool, pools);
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
return { action: "reject", reason: "unknown-model" };
|
|
1031
|
+
}
|
|
1032
|
+
const free = (p) => idle || holdings().some(h => h.exec === exec && h.pool === p) ||
|
|
1033
|
+
capacity({ kind: "provider", holders: holdings().filter(h => h.pool === p).length, capacity: settings().providers?.[p]?.slots ?? Infinity });
|
|
1034
|
+
const m = models.find(m => m.provider && !unavailable(m.provider) && free(m.provider));
|
|
1035
|
+
if (!m)
|
|
1036
|
+
return { action: "reject", reason: "pool-unavailable" };
|
|
1037
|
+
model = `${m.provider}/${m.id}${m.thinking ? `:${m.thinking}` : ""}`;
|
|
1038
|
+
}
|
|
934
1039
|
try {
|
|
935
1040
|
const m = parseModel(model);
|
|
936
1041
|
if (!m.provider)
|
|
@@ -942,28 +1047,14 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
942
1047
|
}
|
|
943
1048
|
const envelope = { to: dest, kind: "model", body, ...(cond && Object.keys(cond).length ? { cond } : {}) };
|
|
944
1049
|
const rid2 = forwardRid(rid, ctx.widRev, ctx.key, hash);
|
|
945
|
-
const exec = current(ctx.journal, dest), provider = body.provider;
|
|
946
|
-
// P28: with no live execution (not started yet, between executions, hibernated while asking) the model is
|
|
947
|
-
// recorded and the next execution launches on it (`requestedModel`); its slot is acquired then, as for any launch.
|
|
948
1050
|
// Launching (`selected`, not `tracked` yet): the child may start on the old model; ask again in a moment.
|
|
949
|
-
const idle = !exec || has(ctx.journal, JT.fenced, exec) || !has(ctx.journal, "selected", exec);
|
|
950
1051
|
if (!idle && !has(ctx.journal, "tracked", exec))
|
|
951
1052
|
return { action: "reject", reason: "call-starting" };
|
|
952
1053
|
if (!idle && pendingSwitch(exec))
|
|
953
1054
|
return { action: "reject", reason: "switch-pending" };
|
|
954
|
-
if (!idle)
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
const target = holdings().filter(h => h.pool === provider);
|
|
958
|
-
if (!capacity({ kind: "provider", holders: target.length, capacity: settings().providers?.[provider]?.slots ?? Infinity }))
|
|
959
|
-
return { action: "reject", reason: "provider-full" };
|
|
960
|
-
let slot = 0;
|
|
961
|
-
while (target.some(h => h.slot === slot))
|
|
962
|
-
slot++;
|
|
963
|
-
await orch.append("hold", { pool: provider, slot, exec, reserved: true, rid });
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
await note(req.rid, model, idle ? "next-execution" : "next-request");
|
|
1055
|
+
if (!idle && !await reserveSwitch(exec, body.provider, rid))
|
|
1056
|
+
return { action: "reject", reason: "provider-full" };
|
|
1057
|
+
await note(req.rid, model, idle ? "next-execution" : "next-request", pool);
|
|
967
1058
|
const entry = await ctx.journal.append("forward", { rid, rid2, dest, hash, envelope });
|
|
968
1059
|
await replayForward(entry);
|
|
969
1060
|
if (idle) {
|
|
@@ -23,6 +23,8 @@ export async function observeExecution(d) {
|
|
|
23
23
|
const { home, config, ticket: t, exec, child, serial } = d;
|
|
24
24
|
const session = callSession(home, t.wid, t.key, t.gen), clock = new ActiveTime(), started = clock.last;
|
|
25
25
|
let progress = started, providerError;
|
|
26
|
+
// Diagnostic metadata only: checkpoint the latest received stream update, never its content.
|
|
27
|
+
let lastStream;
|
|
26
28
|
const tools = new Set();
|
|
27
29
|
// The tool calls running now, for the stall text: a silent long command and a stuck call read differently.
|
|
28
30
|
const running = new Map();
|
|
@@ -38,7 +40,7 @@ export async function observeExecution(d) {
|
|
|
38
40
|
const previous = t.journal.entries().findLast(e => e.type === "time" && e.exec === exec);
|
|
39
41
|
if (!monotoneTime({ active: Number(previous?.active ?? 0) }, { active: clock.active }))
|
|
40
42
|
throw new Error("Nonmonotone active time");
|
|
41
|
-
await t.journal.append("time", { exec, active: clock.active });
|
|
43
|
+
await t.journal.append("time", { exec, active: clock.active, ...(lastStream ? { lastStream } : {}) });
|
|
42
44
|
});
|
|
43
45
|
const limits = async () => {
|
|
44
46
|
if (t.spec.timeoutMs !== undefined && prior + clock.active >= t.spec.timeoutMs) {
|
|
@@ -49,6 +51,11 @@ export async function observeExecution(d) {
|
|
|
49
51
|
if (reached(totalUsage(t.journal.entries(), t.callId), t.spec.budget))
|
|
50
52
|
signal();
|
|
51
53
|
};
|
|
54
|
+
const openAlert = (id) => {
|
|
55
|
+
const last = attentionEntries(t.journal.entries(), id).at(-1)?.item;
|
|
56
|
+
return last && !t.journal.entries().some(e => e.type === JT.attentionResolved && e.id === id && e.rev === last.rev);
|
|
57
|
+
};
|
|
58
|
+
const progressDue = () => !clock.asking && !tools.size && performance.now() - progress >= (config.k?.progressMs ?? 600000);
|
|
52
59
|
const stall = () => serial(async () => {
|
|
53
60
|
const id = `stall:${t.callId}`;
|
|
54
61
|
const items = attentionEntries(t.journal.entries(), id), last = items.at(-1)?.item;
|
|
@@ -56,8 +63,8 @@ export async function observeExecution(d) {
|
|
|
56
63
|
const fresh = items.at(-1)?.exec === exec ? clock.last > Number(items.at(-1)?.horizon) : clock.last > started;
|
|
57
64
|
if (open && fresh)
|
|
58
65
|
await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "activity" });
|
|
59
|
-
else if (!open && !clock.asking && performance.now() - clock.last >= (config.k?.stallMs ?? 600000))
|
|
60
|
-
await t.journal.append(JT.attention, { exec, horizon: clock.last, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text: `${t.wid}/${t.key}: no execution activity for ${Math.floor((performance.now() - clock.last) / 60000)}m` + runningText(), wid: t.wid, call: t.callId } });
|
|
66
|
+
else if (!open && !clock.asking && !progressDue() && !openAlert(`noprogress:${t.callId}`) && performance.now() - clock.last >= (config.k?.stallMs ?? 600000))
|
|
67
|
+
await t.journal.append(JT.attention, { exec, horizon: clock.last, ...(lastStream ? { lastStream } : {}), item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text: `${t.wid}/${t.key}: no execution activity observed for ${Math.floor((performance.now() - clock.last) / 60000)}m` + runningText(), wid: t.wid, call: t.callId } });
|
|
61
68
|
});
|
|
62
69
|
/** "; running bash `make matrix` for 14m (no output or CPU use seen)": a silent long command, not a stuck model. */
|
|
63
70
|
const runningText = () => {
|
|
@@ -73,10 +80,10 @@ export async function observeExecution(d) {
|
|
|
73
80
|
const fresh = items.at(-1)?.exec === exec ? progress > Number(items.at(-1)?.horizon) : progress > started;
|
|
74
81
|
if (open && fresh)
|
|
75
82
|
await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "progress" });
|
|
76
|
-
else if (!open &&
|
|
77
|
-
const text = `${t.wid}/${t.key}:
|
|
83
|
+
else if (!open && progressDue() && !openAlert(`stall:${t.callId}`)) {
|
|
84
|
+
const text = `${t.wid}/${t.key}: no new output or tool progress received for ${Math.floor((performance.now() - progress) / 60000)}m; the model may still be processing` +
|
|
78
85
|
(providerError ? `; last provider error: ${providerError.slice(0, 200)}` : "");
|
|
79
|
-
await t.journal.append(JT.attention, { exec, horizon: progress, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text, wid: t.wid, call: t.callId } });
|
|
86
|
+
await t.journal.append(JT.attention, { exec, horizon: progress, ...(lastStream ? { lastStream } : {}), item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text, wid: t.wid, call: t.callId } });
|
|
80
87
|
}
|
|
81
88
|
});
|
|
82
89
|
child.stdin.on("error", () => { });
|
|
@@ -105,6 +112,10 @@ export async function observeExecution(d) {
|
|
|
105
112
|
// P18: Apply RPC boundaries at receipt, before any in-flight scan can resume.
|
|
106
113
|
// Durable observations remain queued; clock transitions never wait on I/O.
|
|
107
114
|
clock.event(event, performance.now());
|
|
115
|
+
if (event.type === "message_update") {
|
|
116
|
+
const type = event.assistantMessageEvent?.type;
|
|
117
|
+
lastStream = { type: typeof type === "string" && /^(thinking|text|toolcall)_(start|delta|end)$/.test(type) ? type : "message_update", receivedAt: Date.now() };
|
|
118
|
+
}
|
|
108
119
|
const message = event.message;
|
|
109
120
|
const error = event.type === "auto_retry_start" ? event.errorMessage : event.type === "auto_retry_end" ? event.finalError :
|
|
110
121
|
event.type === "message_end" && message?.stopReason === "error" ? message.errorMessage : undefined;
|
|
@@ -261,7 +261,7 @@ function snapshotReducer(wid, entries) {
|
|
|
261
261
|
const pending = forwarded.filter(pendingMessage).length;
|
|
262
262
|
if (pending)
|
|
263
263
|
c.pending = pending;
|
|
264
|
-
const last = c.phase !== "sealed" && !retired.has(c.callId) ? forwarded.findLast(s => s.kind === "model" && s.reason !== "withdrawn") : undefined;
|
|
264
|
+
const last = c.phase !== "sealed" && !retired.has(c.callId) ? forwarded.findLast(s => s.kind === "model" && s.reason !== "withdrawn" && s.reason !== "stale-execution") : undefined;
|
|
265
265
|
if (last?.reason !== undefined)
|
|
266
266
|
c.switchFailed = `${last.model} (${last.reason})`;
|
|
267
267
|
else if (last && last.state !== "retired" && last.model !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
|
package/dist/ui/cards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { CT } from "../types.js";
|
|
3
|
-
import { resolved } from "../agent/main/snapshots.js";
|
|
3
|
+
import { attentionResolution, resolved } from "../agent/main/snapshots.js";
|
|
4
4
|
const HEAD = {
|
|
5
5
|
question: { icon: "?", title: "asks the main agent", tone: "accent" },
|
|
6
6
|
finished: { icon: "✓", title: "finished", tone: "success" },
|
|
@@ -33,37 +33,48 @@ export function card(theme, tone, heading, body, width, expanded = false) {
|
|
|
33
33
|
}
|
|
34
34
|
/** P15, P16: Register renderers for attention presentations and watch-view notes in the main session. */
|
|
35
35
|
export function registerCards(pi, home) {
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const pending = new Map();
|
|
37
|
+
const done = new Map(); // resolution is monotone: once resolved, never re-read
|
|
38
|
+
// pi renders every visible message on each frame; open cards re-read receipts at most once a second.
|
|
38
39
|
const checked = new Map();
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const resolution = (item) => {
|
|
41
|
+
if (item.kind !== "question" && item.kind !== "stall")
|
|
42
|
+
return undefined;
|
|
43
|
+
const id = `${item.wid}:${item.id}@${item.rev}`;
|
|
44
|
+
if (done.has(id)) {
|
|
45
|
+
pending.delete(id);
|
|
46
|
+
return done.get(id);
|
|
47
|
+
}
|
|
43
48
|
const now = Date.now();
|
|
44
49
|
if (now - (checked.get(id) ?? -Infinity) < 1000)
|
|
45
|
-
return
|
|
50
|
+
return undefined;
|
|
46
51
|
checked.set(id, now);
|
|
47
52
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
const reason = item.kind === "question" ? (resolved(home, item) ? "answered" : undefined) : attentionResolution(home, item);
|
|
54
|
+
if (reason) {
|
|
55
|
+
done.set(id, reason);
|
|
50
56
|
checked.delete(id);
|
|
51
|
-
|
|
57
|
+
pending.delete(id);
|
|
58
|
+
return reason;
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
catch { /* display only */ }
|
|
55
|
-
return
|
|
62
|
+
return undefined;
|
|
56
63
|
};
|
|
57
64
|
pi.registerMessageRenderer(CT.attention, (message, options, theme) => {
|
|
58
65
|
const items = message.details?.items;
|
|
59
66
|
if (!items?.length)
|
|
60
67
|
return undefined;
|
|
61
|
-
|
|
68
|
+
for (const item of items)
|
|
69
|
+
if (item.kind === "stall" || item.kind === "question")
|
|
70
|
+
pending.set(`${item.wid}:${item.id}@${item.rev}`, item);
|
|
71
|
+
// Reuse lines until width, theme, expansion or an attention resolution changes.
|
|
62
72
|
let last;
|
|
63
73
|
const draw = (width) => items.flatMap(item => {
|
|
64
|
-
const h = HEAD[item.kind] ?? HEAD.unknown,
|
|
65
|
-
const title = item.kind === "stall" && item.id.startsWith("noprogress:") ? "
|
|
66
|
-
const
|
|
74
|
+
const h = HEAD[item.kind] ?? HEAD.unknown, reason = resolution(item), closed = !!reason;
|
|
75
|
+
const title = item.kind === "stall" && item.id.startsWith("noprogress:") ? "awaiting progress" : h.title;
|
|
76
|
+
const status = item.kind === "question" ? "answered" : reason === "activity" || reason === "progress" ? "recovered" : reason === "ended" || reason === "retired" ? "ended" : "resolved";
|
|
77
|
+
const heading = `${closed ? "✓" : h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? `— ${status}` : title}`;
|
|
67
78
|
// v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
|
|
68
79
|
const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
|
|
69
80
|
const body = item.kind === "finished" && !closed
|
|
@@ -72,7 +83,7 @@ export function registerCards(pi, home) {
|
|
|
72
83
|
return card(theme, closed ? "muted" : h.tone, heading, body, width, options.expanded);
|
|
73
84
|
});
|
|
74
85
|
return { invalidate() { last = undefined; }, render: (width) => {
|
|
75
|
-
const key = `${width}|${items.map(item => item
|
|
86
|
+
const key = `${width}|${items.map(item => resolution(item) ?? "").join("|")}`;
|
|
76
87
|
if (last?.key !== key)
|
|
77
88
|
last = { key, lines: draw(width) };
|
|
78
89
|
return last.lines;
|
|
@@ -87,4 +98,11 @@ export function registerCards(pi, home) {
|
|
|
87
98
|
return last.lines;
|
|
88
99
|
} };
|
|
89
100
|
});
|
|
101
|
+
// The existing UI refresh loop repaints receipts even when dock text did not change.
|
|
102
|
+
return () => {
|
|
103
|
+
const before = done.size;
|
|
104
|
+
for (const item of pending.values())
|
|
105
|
+
resolution(item);
|
|
106
|
+
return done.size !== before;
|
|
107
|
+
};
|
|
90
108
|
}
|
package/dist/ui/index.js
CHANGED
|
@@ -7,8 +7,7 @@ import { registerCards } from "./cards.js";
|
|
|
7
7
|
import { toolRenderers } from "./tool.js";
|
|
8
8
|
/** UI §1–3, P16, P21: Register journal-backed list/watch surfaces only in interactive pi. */
|
|
9
9
|
export function registerUi(pi, deps) {
|
|
10
|
-
|
|
11
|
-
registerCards(pi, deps.home); // P21: cards are optional
|
|
10
|
+
const refreshCards = typeof pi.registerMessageRenderer === "function" ? registerCards(pi, deps.home) : () => false; // P21: cards are optional
|
|
12
11
|
// UI §5: compact tool calls and results (optional surface; without it pi shows the raw JSON).
|
|
13
12
|
if (typeof pi.registerToolRenderer === "function")
|
|
14
13
|
pi.registerToolRenderer((name, next) => {
|
|
@@ -38,7 +37,7 @@ export function registerUi(pi, deps) {
|
|
|
38
37
|
const state = { folded: new Set(), done: new Map(), viewed: new Set(), finished: false };
|
|
39
38
|
let screen, opening = false, stopped = false, closeScreen;
|
|
40
39
|
let unsubscribe, timer;
|
|
41
|
-
let dockRows = () => [], dockAt = null,
|
|
40
|
+
let dockRows = () => [], dockAt = null, lastDock = "";
|
|
42
41
|
// ↓ opens the list only from pi's own input editor. Another surface — /model's selector, a dialog, another
|
|
43
42
|
// extension's overlay — has the focus instead, and its ↓ belongs to it. pi's editor (and any editor built on
|
|
44
43
|
// CustomEditor) carries the app action map; the TUI comes from the dock widget, so without a dock the check
|
|
@@ -86,11 +85,9 @@ export function registerUi(pi, deps) {
|
|
|
86
85
|
const at = dock === "off" ? undefined : data.dockAt;
|
|
87
86
|
if (at !== dockAt) {
|
|
88
87
|
dockAt = at;
|
|
89
|
-
dockTui = undefined;
|
|
90
88
|
// With the dock off, an empty widget below the editor still lends the TUI to the ↓ focus check; pi adds no
|
|
91
89
|
// spacer for it, so it takes no line.
|
|
92
90
|
ctx.ui.setWidget("durable-subagents", !at ? tui => { keysTui = tui; return { invalidate() { }, render: () => [] }; } : (tui, theme) => {
|
|
93
|
-
dockTui = tui;
|
|
94
91
|
keysTui = tui;
|
|
95
92
|
return {
|
|
96
93
|
invalidate() { },
|
|
@@ -106,9 +103,10 @@ export function registerUi(pi, deps) {
|
|
|
106
103
|
}, { placement: at === "above" ? "aboveEditor" : "belowEditor" });
|
|
107
104
|
}
|
|
108
105
|
const shown = dockRows(200).join("\n");
|
|
109
|
-
|
|
106
|
+
const cardsChanged = refreshCards();
|
|
107
|
+
if (shown !== lastDock || cardsChanged) {
|
|
110
108
|
lastDock = shown;
|
|
111
|
-
|
|
109
|
+
keysTui?.requestRender?.();
|
|
112
110
|
}
|
|
113
111
|
screen?.refresh();
|
|
114
112
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-durable-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.17",
|
|
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",
|