@pify/swarm 0.7.2 → 0.7.4
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/extensions/swarm.ts +6 -3
- package/package.json +1 -1
- package/src/ui-lock.ts +52 -0
- package/src/widget-clamp.ts +35 -0
- package/src/widget.ts +8 -3
package/extensions/swarm.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
25
25
|
import { Type } from "typebox";
|
|
26
26
|
|
|
27
27
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
28
|
+
import { withUiLock } from "../src/ui-lock.ts";
|
|
28
29
|
import {
|
|
29
30
|
consentQuestion,
|
|
30
31
|
decideConsent,
|
|
@@ -542,9 +543,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
542
543
|
});
|
|
543
544
|
if (verdict !== "ask") return verdict === "allow";
|
|
544
545
|
|
|
545
|
-
const approved = await
|
|
546
|
-
|
|
547
|
-
|
|
546
|
+
const approved = await withUiLock(() =>
|
|
547
|
+
ctx.ui.confirm(
|
|
548
|
+
"Load this project's agent definitions?",
|
|
549
|
+
consentQuestion("its own agent definitions, which override the builtins of the same name", dir),
|
|
550
|
+
),
|
|
548
551
|
);
|
|
549
552
|
try {
|
|
550
553
|
writeFileSync(file, `${JSON.stringify(writeConsent(store, ctx.cwd, "agents", approved), null, 2)}
|
package/package.json
CHANGED
package/src/ui-lock.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dialog at a time, across every @pify extension in the process.
|
|
3
|
+
*
|
|
4
|
+
* pi's interactive host has no queue for extension dialogs: opening a second
|
|
5
|
+
* `ui.select`/`ui.confirm`/`ui.input` while one is already up displaces the
|
|
6
|
+
* first component without ever calling its cancel callback, so the first
|
|
7
|
+
* dialog's promise never settles — the turn hangs (measured in pi's
|
|
8
|
+
* interactive-mode source: showExtensionSelector clears and replaces the
|
|
9
|
+
* active selector, and only hideExtensionSelector disposes it). This is a
|
|
10
|
+
* real collision in this suite: ask-question opens a questionnaire while a
|
|
11
|
+
* consent prompt (memory/swarm/workflow/subagent) or a yolo confirm can fire
|
|
12
|
+
* in the same turn, and parallel tool calls make that ordinary.
|
|
13
|
+
*
|
|
14
|
+
* The fix is to never let a second dialog open until the first has closed.
|
|
15
|
+
* A timeout would not help — releasing the lock while the dialog is still on
|
|
16
|
+
* screen just re-creates the collision — so this is a plain FIFO mutex with
|
|
17
|
+
* no deadline. It is deliberately keyed on a cross-realm `Symbol.for`, so all
|
|
18
|
+
* eighteen packages share ONE queue even though each ships its own copy of
|
|
19
|
+
* this file: the state lives on `globalThis`, not in any one module.
|
|
20
|
+
*
|
|
21
|
+
* Vendored per package, like consent.ts, so the suite keeps zero runtime
|
|
22
|
+
* dependencies.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const LOCK_KEY = Symbol.for("pify.ui-lock.tail");
|
|
26
|
+
|
|
27
|
+
interface LockGlobal {
|
|
28
|
+
[LOCK_KEY]?: Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `fn` only once every dialog queued before it has finished, and hold the
|
|
33
|
+
* queue until `fn` settles. A rejection in an earlier holder never wedges the
|
|
34
|
+
* chain — the next waiter proceeds regardless.
|
|
35
|
+
*/
|
|
36
|
+
export async function withUiLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
37
|
+
const g = globalThis as unknown as LockGlobal;
|
|
38
|
+
const prev = g[LOCK_KEY] ?? Promise.resolve();
|
|
39
|
+
|
|
40
|
+
let release!: () => void;
|
|
41
|
+
const mine = new Promise<void>((resolve) => {
|
|
42
|
+
release = resolve;
|
|
43
|
+
});
|
|
44
|
+
g[LOCK_KEY] = prev.then(() => mine);
|
|
45
|
+
|
|
46
|
+
await prev.catch(() => {});
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
} finally {
|
|
50
|
+
release();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a widget from overrunning the screen.
|
|
3
|
+
*
|
|
4
|
+
* pi caps string-array widgets at ten lines, but a Text-factory widget — the
|
|
5
|
+
* pattern every @pify widget uses because it caches and renders efficiently —
|
|
6
|
+
* bypasses that guard entirely (measured in pi's interactive-mode source). So
|
|
7
|
+
* the bound is ours to keep: an unbounded row count pushes the editor off
|
|
8
|
+
* screen, and a single line wider than the terminal can crash the renderer.
|
|
9
|
+
*
|
|
10
|
+
* Two rules, applied before any colour escape is added (slicing a coloured
|
|
11
|
+
* string would cut through an escape sequence):
|
|
12
|
+
* - `clampWidth` truncates one segment's *visible* text.
|
|
13
|
+
* - `clampRows` caps the row list and, per the suite's no-silent-caps rule,
|
|
14
|
+
* replaces the overflow with a visible "+N more" line rather than dropping
|
|
15
|
+
* it in silence.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A widget line's variable segment never exceeds this many characters. */
|
|
19
|
+
export const MAX_SEGMENT = 44;
|
|
20
|
+
/** A widget never shows more than this many item rows. */
|
|
21
|
+
export const MAX_WIDGET_ROWS = 8;
|
|
22
|
+
|
|
23
|
+
export function clampWidth(text: string, max = MAX_SEGMENT): string {
|
|
24
|
+
const t = text.replace(/[\r\n]+/g, " ");
|
|
25
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return at most `max` rows; when more were supplied, `more(hidden)` renders
|
|
30
|
+
* the summary line that stands in for the rest.
|
|
31
|
+
*/
|
|
32
|
+
export function clampRows(rows: string[], max: number, more: (hidden: number) => string): string[] {
|
|
33
|
+
if (rows.length <= max) return rows;
|
|
34
|
+
return [...rows.slice(0, max), more(rows.length - max)];
|
|
35
|
+
}
|
package/src/widget.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clampRows, clampWidth, MAX_WIDGET_ROWS } from "./widget-clamp.ts";
|
|
1
2
|
import type { SwarmRun, ThemeLike } from "./types.ts";
|
|
2
3
|
|
|
3
4
|
const WIDTH = 54;
|
|
@@ -29,7 +30,9 @@ export function buildWidgetLines(run: SwarmRun | null, theme: ThemeLike, now: nu
|
|
|
29
30
|
const pad = Math.max(1, WIDTH - title.length - hint.length);
|
|
30
31
|
lines.push(dim(`╭${title}${"─".repeat(pad)}${hint}╮`));
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
// Cap the rows: a large swarm would otherwise push the editor off screen,
|
|
34
|
+
// since a Text-factory widget bypasses pi's ten-line guard.
|
|
35
|
+
const rows = run.items.map((item) => {
|
|
33
36
|
const paint =
|
|
34
37
|
item.status === "running"
|
|
35
38
|
? (s: string) => theme.fg("warning", s)
|
|
@@ -38,8 +41,10 @@ export function buildWidgetLines(run: SwarmRun | null, theme: ThemeLike, now: nu
|
|
|
38
41
|
: item.status === "queued"
|
|
39
42
|
? dim
|
|
40
43
|
: (s: string) => theme.fg("error", s);
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
return `${dim("│ ")}${paint(`${icon(item.status)} ${clampWidth(item.agent, 24)}`)}${dim(` ${clampWidth(item.item, 32)}`)}`;
|
|
45
|
+
});
|
|
46
|
+
for (const row of clampRows(rows, MAX_WIDGET_ROWS, (hidden) => dim(`│ … +${hidden} more`))) {
|
|
47
|
+
lines.push(row);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
lines.push(dim(`╰${"─".repeat(WIDTH)}╯`));
|