@stacknyu/pi-flow 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # pi-flow changelog
2
2
 
3
+ ## 1.5.1
4
+
5
+ - Support Pi Web 0.15.0 session-history results, including compaction, fresh-answer validation and nested subagent completion/abort checks; preserve native CLI workers.
6
+ - Isolate live queue widgets per session, honor Web cleanup refusals, retain retryable cleanup, and batch delete/archive requests within Web's 500-session limit.
7
+ - Explain cancelled or unsupported session navigation and add native/RPC/Web compatibility regressions.
8
+
3
9
  ## 1.5.0
4
10
 
5
11
  - First standalone public npm package, `@stacknyu/pi-flow`, maintained in the Stack Pi Plugins monorepo. pi-check remains optional and is not installed as a dependency.
package/extension.ts CHANGED
@@ -191,30 +191,6 @@ function liveLines(cwd: string): string[] {
191
191
  return [...lines, ...log];
192
192
  }
193
193
 
194
- const g = globalThis as { __flowWatch?: ReturnType<typeof setInterval> };
195
-
196
- function stopWatch(ctx?: any) {
197
- if (g.__flowWatch) clearInterval(g.__flowWatch);
198
- g.__flowWatch = undefined;
199
- try {
200
- ctx?.ui.setWidget("queue", undefined);
201
- } catch {}
202
- }
203
-
204
- function startWatch(ctx: any) {
205
- stopWatch();
206
- const cwd = ctx.cwd;
207
- const tick = () => {
208
- try {
209
- ctx.ui.setWidget("queue", liveLines(cwd));
210
- } catch {
211
- stopWatch();
212
- }
213
- };
214
- tick();
215
- g.__flowWatch = setInterval(tick, 4000);
216
- }
217
-
218
194
  /** Start the background runner. Returns an error text, or "" when it started (or already ran to the end). */
219
195
  async function startRunner(cwd: string, resume = false): Promise<string> {
220
196
  await ensureDir(cwd);
@@ -433,7 +409,6 @@ async function handoff(cwd: string, slug: string, ctx: any): Promise<{ ok: boole
433
409
  const web = await piWeb(cwd);
434
410
  if (web) await web.ui({ action: "set", ids: [sid], pinned: true }).catch(() => {});
435
411
  }
436
- if (ctx?.ui) startWatch(ctx);
437
412
  return {
438
413
  ok: true,
439
414
  text: `Queued ${items.length} items for task ${slug} (up to ${parallel} in parallel, adaptive). Checks: ${checks.join(" · ")}.${warnings.length ? `\nWarnings: ${warnings.join(" ")}` : ""}\nThe live panel below shows progress; /task status for a summary.`,
@@ -472,6 +447,7 @@ async function cleanup(cwd: string, all: boolean, currentSession: string): Promi
472
447
  for (const t of tasks) {
473
448
  const ids = [...new Set([...(t.planningSessions ?? []), ...(fs[t.slug]?.planningSessions ?? [])])];
474
449
  const files: Record<string, string> = fs[t.slug]?.planningFiles ?? {};
450
+ let pending = false;
475
451
  for (const id of ids) {
476
452
  if (!id) continue;
477
453
  if (id === currentSession) {
@@ -481,12 +457,14 @@ async function cleanup(cwd: string, all: boolean, currentSession: string): Promi
481
457
  }
482
458
  let gone = false;
483
459
  if (web) gone = await web.del([id]).then((r: any) => (r.deleted ?? []).includes(id) || (r.missing ?? []).includes(id), () => false);
484
- if (!gone && files[id] && existsSync(files[id])) {
460
+ if (!web && files[id]) {
485
461
  rmSync(files[id], { force: true });
486
462
  gone = true;
487
463
  }
488
464
  if (gone) planned++;
465
+ else { pending = true; notes.push(`kept planning chat ${id}: deletion was not confirmed`); }
489
466
  }
467
+ if (!pending) setFlowStatus(cwd, t.slug, { status: taskStatus(cwd, t), cleanedAt: new Date().toISOString() });
490
468
  }
491
469
  if (planned) notes.push(`deleted ${planned} planning chat(s)`);
492
470
  if (keptCurrent) notes.push("kept this chat (you are in it); delete it from the sidebar or /resume list when you leave it");
@@ -507,7 +485,6 @@ async function cleanup(cwd: string, all: boolean, currentSession: string): Promi
507
485
  notes.push(`removing ${wts.length} worktrees in the background (the next task reinstalls them)`);
508
486
  }
509
487
  }
510
- for (const t of tasks) setFlowStatus(cwd, t.slug, { status: taskStatus(cwd, t), cleanedAt: new Date().toISOString() });
511
488
  return notes.join("\n");
512
489
  }
513
490
 
@@ -603,7 +580,30 @@ export { cleanup, handoff, planPrompt, control, startRunner };
603
580
  // ---------- the extension ----------
604
581
 
605
582
  export default function (pi: ExtensionAPI) {
606
- pi.on("session_shutdown", async () => stopWatch());
583
+ let watch: ReturnType<typeof setInterval> | undefined;
584
+ function stopWatch(ctx?: any) {
585
+ clearInterval(watch);
586
+ watch = undefined;
587
+ try {
588
+ ctx?.ui.setWidget("queue", undefined);
589
+ } catch {}
590
+ }
591
+ function startWatch(ctx: any) {
592
+ stopWatch();
593
+ if (!ctx.hasUI) return;
594
+ const cwd = ctx.cwd;
595
+ const tick = () => {
596
+ try {
597
+ ctx.ui.setWidget("queue", liveLines(cwd));
598
+ } catch {
599
+ stopWatch();
600
+ }
601
+ };
602
+ tick();
603
+ watch = setInterval(tick, 4000);
604
+ watch.unref?.();
605
+ }
606
+ pi.on("session_shutdown", async (_event, ctx) => stopWatch(ctx));
607
607
 
608
608
  pi.registerTool({
609
609
  name: "task_handoff",
@@ -616,6 +616,7 @@ export default function (pi: ExtensionAPI) {
616
616
  const blocked = guard(ctx);
617
617
  if (blocked) return blocked;
618
618
  const r = await handoff(ctx.cwd, String(params.slug).trim(), ctx);
619
+ if (r.ok) startWatch(ctx);
619
620
  return { content: [{ type: "text", text: r.text }], details: { ok: r.ok } };
620
621
  },
621
622
  } as any);
@@ -847,7 +848,8 @@ export default function (pi: ExtensionAPI) {
847
848
  return;
848
849
  }
849
850
  stopWatch(ctx);
850
- await ctx.switchSession(file);
851
+ const switched = await ctx.switchSession(file);
852
+ if (switched.cancelled) ctx.ui.notify(`queue: session switch cancelled or unsupported by this host. Open it from the session list, or run pi --session ${JSON.stringify(file)} in a terminal.`, "info");
851
853
  return;
852
854
  }
853
855
  if (cmd === "problem") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stacknyu/pi-flow",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Plan a large task, answer its questions, and let a queue of fresh Pi sessions build it — with verified checks, adaptive parallelism and session cleanup.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/runner.mjs CHANGED
@@ -637,9 +637,32 @@ export function webClient(c) {
637
637
  };
638
638
  return {
639
639
  available: () => call("GET", "/api/agent/running", undefined, 3000).then(() => true, () => false),
640
- running: () => call("GET", "/api/agent/running", undefined, 15_000),
640
+ running: async (id) => {
641
+ const snap = await call("GET", "/api/agent/running", undefined, 15_000);
642
+ const active = [...(snap.runningSessionIds ?? []), ...(snap.subagentsActiveSessionIds ?? [])];
643
+ if (id && !active.includes(id) && active.length) {
644
+ const { sessions } = await call("GET", "/api/sessions?summary=1");
645
+ const family = new Set([id]);
646
+ for (let grew = true; grew;) {
647
+ grew = false;
648
+ for (const s of sessions) if (s.relation?.kind === "subagent" && family.has(s.relation.parentSessionId) && !family.has(s.id)) {
649
+ family.add(s.id); grew = true;
650
+ }
651
+ }
652
+ if (active.some((sid) => family.has(sid))) snap.subagentsActiveSessionIds = [...(snap.subagentsActiveSessionIds ?? []), id];
653
+ }
654
+ return snap;
655
+ },
641
656
  create: (body) => call("POST", "/api/agent/new", body, 300_000),
642
657
  send: (id, body) => call("POST", `/api/agent/${encodeURIComponent(id)}`, body, 120_000),
658
+ history: async (id) => {
659
+ let before = "";
660
+ while (true) {
661
+ const response = await call("GET", `/api/sessions/${encodeURIComponent(id)}${before ? "/context" : ""}?tree=summary&tail=1&deferMedia=1&deferThinking=1${before ? `&before=${encodeURIComponent(before)}` : ""}`);
662
+ if (response.context.messages.some((m) => m.role === "assistant") || !response.context.hasMore) return response;
663
+ before = response.context.oldestEntryId;
664
+ }
665
+ },
643
666
  del: (ids) => call("POST", "/api/sessions/delete", { ids }, 60_000),
644
667
  ui: (body) => call("POST", "/api/sessions/ui-state", body, 30_000),
645
668
  };
@@ -704,6 +727,7 @@ async function webTurn(ctx, cwd, ref, name, prompt, minutes, onActivity = () =>
704
727
  const deadline = Date.now() + minutes * 60_000;
705
728
  const [provider, ...rest] = (c.model || "").split("/");
706
729
  const modelId = rest.join("/");
730
+ let previousAssistantId;
707
731
  try {
708
732
  if (!ref.id) {
709
733
  const r = await web.create({
@@ -717,6 +741,8 @@ async function webTurn(ctx, cwd, ref, name, prompt, minutes, onActivity = () =>
717
741
  ctx.onWorker?.({ sessionId: ref.id, web: true });
718
742
  await web.send(ref.id, { type: "set_session_name", name }).catch(() => {});
719
743
  }
744
+ const { context } = await web.history(ref.id);
745
+ previousAssistantId = context.entryIds[context.messages.findLastIndex((m) => m.role === "assistant")];
720
746
  await web.send(ref.id, { type: "prompt", message: prompt });
721
747
  } catch (e) {
722
748
  return { status: "error", reply: "", err: String(e?.message ?? e), code: -1, quiescent: false };
@@ -731,13 +757,13 @@ async function webTurn(ctx, cwd, ref, name, prompt, minutes, onActivity = () =>
731
757
  await new Promise((r) => setTimeout(r, 3000));
732
758
  let snap;
733
759
  try {
734
- snap = await web.running();
760
+ snap = await web.running(ref.id);
735
761
  } catch {
736
762
  if (!stopRequested() && Date.now() <= deadline) continue;
737
763
  await web.send(ref.id, { type: "abort" }).catch(() => {});
738
764
  return { status: "error", reply: "", err: "Pi Web unreachable; worker termination is unconfirmed", code: -1, quiescent: false };
739
765
  }
740
- const running = (snap.runningSessionIds ?? []).includes(ref.id);
766
+ const running = [...(snap.runningSessionIds ?? []), ...(snap.subagentsActiveSessionIds ?? [])].includes(ref.id);
741
767
  const awaiting = (snap.awaitingInputSessionIds ?? []).includes(ref.id);
742
768
  if (!running) {
743
769
  if (++notRunning >= 2) break;
@@ -772,8 +798,8 @@ async function webTurn(ctx, cwd, ref, name, prompt, minutes, onActivity = () =>
772
798
  let quiescent = false;
773
799
  for (let i = 0; i < 20; i++) {
774
800
  await new Promise((r) => setTimeout(r, 3000));
775
- const s2 = await web.running().catch(() => null);
776
- if (s2 && !(s2.runningSessionIds ?? []).includes(ref.id)) { quiescent = true; break; }
801
+ const s2 = await web.running(ref.id).catch(() => null);
802
+ if (s2 && ![...(s2.runningSessionIds ?? []), ...(s2.subagentsActiveSessionIds ?? [])].includes(ref.id)) { quiescent = true; break; }
777
803
  }
778
804
  if (!quiescent) return { status: "error", reply: "", err: "worker did not confirm termination", code: -1, quiescent: false };
779
805
  if (why === "error") return { status, reply: "", err: "the agent waited for a person's answer for 2 minutes", code: -1 };
@@ -781,10 +807,11 @@ async function webTurn(ctx, cwd, ref, name, prompt, minutes, onActivity = () =>
781
807
  }
782
808
  }
783
809
  try {
784
- const response = await web.send(ref.id, { type: "get_messages" });
785
- const last = response.data?.messages?.filter((m) => m.role === "assistant").at(-1);
786
- if (!last) return { status: "error", reply: "", err: "Pi Web returned no assistant result", code: -1 };
787
- const err = ["error", "aborted", "length"].includes(last.stopReason) ? last.errorMessage || `assistant stopped: ${last.stopReason}` : "";
810
+ const { context } = await web.history(ref.id);
811
+ const index = context.messages.findLastIndex((m) => m.role === "assistant");
812
+ const last = context.messages[index];
813
+ if (!last || context.entryIds[index] === previousAssistantId) return { status: "error", reply: "", err: "Pi Web returned no new assistant result", code: -1 };
814
+ const err = last.stopReason === "stop" ? "" : last.errorMessage || `assistant stopped: ${last.stopReason}`;
788
815
  return { status: status === "ok" && err ? "error" : status, reply: textOf(last), err, code: err ? -1 : 0 };
789
816
  } catch (e) { return { status: "error", reply: "", err: String(e), code: -1 }; }
790
817
  }
@@ -979,16 +1006,17 @@ export async function deleteSessions(ctx, ids, mode = "delete") {
979
1006
  const webIds = ids.filter((id) => sessions[id]?.web);
980
1007
  const cliIds = ids.filter((id) => sessions[id] && !sessions[id].web);
981
1008
  let n = 0;
982
- if (webIds.length && ctx.web) {
1009
+ for (let i = 0; ctx.web && i < webIds.length; i += 500) {
1010
+ const batch = webIds.slice(i, i + 500);
983
1011
  try {
984
1012
  if (mode === "archive") {
985
- await ctx.web.ui({ action: "set", ids: webIds, archived: true });
986
- for (const id of webIds) sessions[id].archived = true;
987
- n += webIds.length;
1013
+ await ctx.web.ui({ action: "set", ids: batch, archived: true });
1014
+ for (const id of batch) sessions[id].archived = true;
1015
+ n += batch.length;
988
1016
  } else {
989
- const r = await ctx.web.del(webIds);
1017
+ const r = await ctx.web.del(batch);
990
1018
  for (const id of [...(r.deleted ?? []), ...(r.missing ?? [])]) if (sessions[id]) sessions[id].deleted = true;
991
- n += (r.deleted ?? []).filter((id) => webIds.includes(id)).length;
1019
+ n += (r.deleted ?? []).filter((id) => batch.includes(id)).length;
992
1020
  }
993
1021
  } catch (e) {
994
1022
  ctx.log(`CLEANUP failed: ${e?.message ?? e}`);