pi-squad 0.16.2 → 0.16.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/README.md +18 -12
- package/package.json +1 -1
- package/src/agent-pool.ts +123 -44
- package/src/index.ts +161 -158
- package/src/panel/message-view.ts +58 -50
- package/src/panel/squad-widget.ts +35 -10
- package/src/panel/task-list.ts +13 -3
- package/src/router.ts +37 -35
- package/src/scheduler.ts +292 -65
- package/src/skills/squad-supervisor/SKILL.md +2 -1
- package/src/store.ts +195 -1
- package/src/types.ts +26 -0
package/src/index.ts
CHANGED
|
@@ -504,31 +504,49 @@ export default function (pi: ExtensionAPI) {
|
|
|
504
504
|
pi.registerTool({
|
|
505
505
|
name: "squad_message",
|
|
506
506
|
label: "Squad Message",
|
|
507
|
-
description: "Send a
|
|
507
|
+
description: "Send a durable request to a specific task. Existing tasks reopen their original Pi session; only a currently running task may be selected by agent name.",
|
|
508
508
|
parameters: Type.Object({
|
|
509
509
|
message: Type.String({ description: "Message to send" }),
|
|
510
510
|
taskId: Type.Optional(Type.String({ description: "Target task ID" })),
|
|
511
511
|
agent: Type.Optional(Type.String({ description: "Target agent name" })),
|
|
512
|
+
expectReply: Type.Optional(Type.Boolean({ description: "Forward the agent's next substantive response back to main Pi and wake it (default true)" })),
|
|
512
513
|
}),
|
|
513
514
|
|
|
514
515
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
515
|
-
|
|
516
|
-
if (!activeScheduler || !activeSquadId) {
|
|
516
|
+
if (!activeSquadId) {
|
|
517
517
|
return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
let activeScheduler = getActiveScheduler();
|
|
520
521
|
let taskId = params.taskId;
|
|
521
522
|
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
523
|
+
// Agent name is safe shorthand only when it identifies one live task.
|
|
524
|
+
// Multiple concurrent tasks can share a role, so never guess between them.
|
|
525
|
+
if (!taskId && params.agent && activeScheduler) {
|
|
526
|
+
const liveMatches = store.loadAllTasks(activeSquadId).filter(
|
|
527
|
+
(task) => task.agent === params.agent && activeScheduler!.getPool().isRunning(task.id),
|
|
528
|
+
);
|
|
529
|
+
if (liveMatches.length === 1) taskId = liveMatches[0].id;
|
|
525
530
|
}
|
|
526
531
|
|
|
527
532
|
if (!taskId) {
|
|
528
533
|
return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
|
|
529
534
|
}
|
|
535
|
+
if (!store.loadTask(activeSquadId, taskId)) {
|
|
536
|
+
return { content: [{ type: "text" as const, text: `Task not found: ${taskId}` }], details: undefined };
|
|
537
|
+
}
|
|
530
538
|
|
|
531
|
-
|
|
539
|
+
// Review/done squads have no live scheduler after a process restart. An
|
|
540
|
+
// exact task ID is enough to reconstruct it from disk and reopen only that
|
|
541
|
+
// task; the task's immutable session binding supplies --session.
|
|
542
|
+
if (!activeScheduler) {
|
|
543
|
+
activeScheduler = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
|
|
544
|
+
schedulers.set(activeSquadId, activeScheduler);
|
|
545
|
+
wireSchedulerEvents(pi, activeScheduler, activeSquadId);
|
|
546
|
+
await activeScheduler.start();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const sent = await activeScheduler.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
|
|
532
550
|
const status = sent ? "delivered" : "queued for when the agent starts";
|
|
533
551
|
|
|
534
552
|
return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
|
|
@@ -594,49 +612,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
594
612
|
widgetState.enabled = true;
|
|
595
613
|
widgetControls?.requestUpdate();
|
|
596
614
|
|
|
597
|
-
|
|
598
|
-
scheduler.onEvent((event: SchedulerEvent) => {
|
|
599
|
-
forceWidgetUpdate();
|
|
600
|
-
switch (event.type) {
|
|
601
|
-
case "squad_review_required": {
|
|
602
|
-
const tasks = store.loadAllTasks(squadId);
|
|
603
|
-
const summary = buildCompletionSummary(tasks);
|
|
604
|
-
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
605
|
-
const s = schedulers.get(squadId); if (s) s.updateContext();
|
|
606
|
-
const squad = store.loadSquad(squadId)!;
|
|
607
|
-
// Wake the main agent into an explicit acceptance gate. Agent reports
|
|
608
|
-
// are untrusted inputs; only independent orchestrator evidence can pass it.
|
|
609
|
-
pi.sendMessage({
|
|
610
|
-
customType: "squad-review-required",
|
|
611
|
-
content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\nSquad claims (review inputs only):\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
|
|
612
|
-
display: true,
|
|
613
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
614
|
-
schedulers.delete(squadId);
|
|
615
|
-
forceWidgetUpdate();
|
|
616
|
-
break;
|
|
617
|
-
}
|
|
618
|
-
case "squad_failed": {
|
|
619
|
-
const tasks = store.loadAllTasks(squadId);
|
|
620
|
-
const failed = tasks.filter((t) => t.status === "failed");
|
|
621
|
-
const done = tasks.filter((t) => t.status === "done");
|
|
622
|
-
pi.sendMessage({
|
|
623
|
-
customType: "squad-failed",
|
|
624
|
-
content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${buildFailureSummary(tasks)}`,
|
|
625
|
-
display: true,
|
|
626
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
627
|
-
forceWidgetUpdate();
|
|
628
|
-
break;
|
|
629
|
-
}
|
|
630
|
-
case "escalation": {
|
|
631
|
-
pi.sendMessage({
|
|
632
|
-
customType: "squad-escalation",
|
|
633
|
-
content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n${event.message}`,
|
|
634
|
-
display: true,
|
|
635
|
-
}, { triggerTurn: true });
|
|
636
|
-
break;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
});
|
|
615
|
+
wireSchedulerEvents(pi, scheduler, squadId);
|
|
640
616
|
}
|
|
641
617
|
|
|
642
618
|
const resumeSched = schedulers.get(squadId)!;
|
|
@@ -786,6 +762,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
786
762
|
}
|
|
787
763
|
}
|
|
788
764
|
|
|
765
|
+
// Mailbox recovery is automatic after extension/main-process restart. Scan
|
|
766
|
+
// every project squad (including accepted done squads) because a crash can
|
|
767
|
+
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
768
|
+
const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
|
|
769
|
+
.filter((squad) => store.loadAllTasks(squad.id)
|
|
770
|
+
.some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
771
|
+
.sort((a, b) => b.created.localeCompare(a.created));
|
|
772
|
+
for (const squad of pendingMailSquads) {
|
|
773
|
+
let scheduler = schedulers.get(squad.id);
|
|
774
|
+
if (!scheduler) {
|
|
775
|
+
scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
|
|
776
|
+
schedulers.set(squad.id, scheduler);
|
|
777
|
+
wireSchedulerEvents(pi, scheduler, squad.id);
|
|
778
|
+
}
|
|
779
|
+
await scheduler.start();
|
|
780
|
+
}
|
|
781
|
+
if (pendingMailSquads.length > 0) {
|
|
782
|
+
activeSquadId = pendingMailSquads[0].id;
|
|
783
|
+
widgetState.squadId = activeSquadId;
|
|
784
|
+
widgetState.enabled = true;
|
|
785
|
+
widgetControls?.requestUpdate();
|
|
786
|
+
}
|
|
787
|
+
|
|
789
788
|
// Notify about paused squads only if they have real completed work
|
|
790
789
|
const paused = store.findActiveSquads()
|
|
791
790
|
.filter((s) => s.cwd === ctx.cwd && s.status === "paused");
|
|
@@ -842,7 +841,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
842
841
|
}
|
|
843
842
|
|
|
844
843
|
if (activeSquadId) {
|
|
845
|
-
openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
|
|
844
|
+
openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
|
|
846
845
|
}
|
|
847
846
|
return { consume: true };
|
|
848
847
|
}
|
|
@@ -876,7 +875,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
876
875
|
{ value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
|
|
877
876
|
{ value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
|
|
878
877
|
{ value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
|
|
879
|
-
{ value: "msg", label: "msg", description: "
|
|
878
|
+
{ value: "msg", label: "msg", description: "Message a task: /squad msg [task-id|running-agent] text" },
|
|
880
879
|
{ value: "widget", label: "widget", description: "Toggle live widget" },
|
|
881
880
|
{ value: "panel", label: "panel", description: "Toggle overlay panel" },
|
|
882
881
|
{ value: "cancel", label: "cancel", description: "Cancel running squad" },
|
|
@@ -969,7 +968,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
969
968
|
}
|
|
970
969
|
if (activeSquadId) {
|
|
971
970
|
const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
|
|
972
|
-
openPanel(ctx, sched, activeSquadId);
|
|
971
|
+
openPanel(pi, ctx, sched, activeSquadId);
|
|
973
972
|
}
|
|
974
973
|
return;
|
|
975
974
|
}
|
|
@@ -980,11 +979,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
980
979
|
return;
|
|
981
980
|
}
|
|
982
981
|
const msgSquad = store.loadSquad(activeSquadId);
|
|
983
|
-
if (!msgSquad
|
|
984
|
-
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
// Parse: /squad msg [agent] message text
|
|
982
|
+
if (!msgSquad) return;
|
|
983
|
+
// Parse: /squad msg [task-id|running-agent] message text
|
|
988
984
|
const msgParts = parts.slice(1);
|
|
989
985
|
let targetAgent: string | undefined;
|
|
990
986
|
let msgText: string;
|
|
@@ -1005,40 +1001,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
1005
1001
|
}
|
|
1006
1002
|
}
|
|
1007
1003
|
|
|
1008
|
-
// Find target task
|
|
1004
|
+
// Find one exact target task. A task ID can address completed or
|
|
1005
|
+
// stopped work; an agent name remains shorthand for a live task only.
|
|
1009
1006
|
const msgTasks = store.loadAllTasks(activeSquadId);
|
|
1010
1007
|
let targetTaskId: string | undefined;
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1008
|
+
const explicitTask = msgParts.length > 1
|
|
1009
|
+
? msgTasks.find((task) => task.id === msgParts[0])
|
|
1010
|
+
: undefined;
|
|
1011
|
+
if (explicitTask) {
|
|
1012
|
+
targetTaskId = explicitTask.id;
|
|
1013
|
+
targetAgent = explicitTask.agent;
|
|
1014
|
+
msgText = msgParts.slice(1).join(" ");
|
|
1015
|
+
} else if (targetAgent) {
|
|
1016
|
+
const liveMatches = msgTasks.filter(
|
|
1017
|
+
(task) => task.agent === targetAgent && getActiveScheduler()?.getPool().isRunning(task.id),
|
|
1018
|
+
);
|
|
1019
|
+
if (liveMatches.length === 1) targetTaskId = liveMatches[0].id;
|
|
1015
1020
|
if (!targetTaskId) {
|
|
1016
|
-
ctx.ui.notify(`Agent '${targetAgent}'
|
|
1021
|
+
ctx.ui.notify(`Agent '${targetAgent}' is not working on exactly one live task; provide an exact task ID`, "warning");
|
|
1017
1022
|
return;
|
|
1018
1023
|
}
|
|
1019
1024
|
} else {
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
if (
|
|
1024
|
-
|
|
1025
|
+
const liveTasks = msgTasks.filter((task) =>
|
|
1026
|
+
getActiveScheduler()?.getPool().isRunning(task.id),
|
|
1027
|
+
);
|
|
1028
|
+
if (liveTasks.length === 1) {
|
|
1029
|
+
targetTaskId = liveTasks[0].id;
|
|
1030
|
+
targetAgent = liveTasks[0].agent;
|
|
1031
|
+
} else {
|
|
1032
|
+
ctx.ui.notify("No unique live task to message; provide an exact task ID", "warning");
|
|
1025
1033
|
return;
|
|
1026
1034
|
}
|
|
1027
1035
|
}
|
|
1028
1036
|
|
|
1029
|
-
|
|
1030
|
-
if (msgSched) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
ts: store.now(),
|
|
1036
|
-
from: "human",
|
|
1037
|
-
type: "message",
|
|
1038
|
-
text: msgText,
|
|
1039
|
-
});
|
|
1040
|
-
ctx.ui.notify(`Logged to ${targetTaskId} (agent not running)`, "info");
|
|
1037
|
+
let msgSched = getActiveScheduler();
|
|
1038
|
+
if (!msgSched) {
|
|
1039
|
+
msgSched = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
|
|
1040
|
+
schedulers.set(activeSquadId, msgSched);
|
|
1041
|
+
wireSchedulerEvents(pi, msgSched, activeSquadId);
|
|
1042
|
+
await msgSched.start();
|
|
1041
1043
|
}
|
|
1044
|
+
const delivered = await msgSched.sendHumanMessage(targetTaskId, msgText);
|
|
1045
|
+
ctx.ui.notify(
|
|
1046
|
+
delivered ? `Sent to ${targetAgent}: "${msgText}"` : `Queued durably for ${targetTaskId}`,
|
|
1047
|
+
"info",
|
|
1048
|
+
);
|
|
1042
1049
|
forceWidgetUpdate();
|
|
1043
1050
|
return;
|
|
1044
1051
|
}
|
|
@@ -1466,6 +1473,63 @@ function forceWidgetUpdate(): void {
|
|
|
1466
1473
|
widgetControls?.requestUpdate();
|
|
1467
1474
|
}
|
|
1468
1475
|
|
|
1476
|
+
/** Wire one scheduler to durable main-session notifications. */
|
|
1477
|
+
function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
|
|
1478
|
+
scheduler.onEvent((event: SchedulerEvent) => {
|
|
1479
|
+
forceWidgetUpdate();
|
|
1480
|
+
switch (event.type) {
|
|
1481
|
+
case "squad_review_required": {
|
|
1482
|
+
const tasks = store.loadAllTasks(squadId);
|
|
1483
|
+
const summary = buildCompletionSummary(tasks);
|
|
1484
|
+
const totalCost = tasks.reduce((sum, task) => sum + task.usage.cost, 0);
|
|
1485
|
+
scheduler.updateContext();
|
|
1486
|
+
const squad = store.loadSquad(squadId);
|
|
1487
|
+
if (!squad) break;
|
|
1488
|
+
pi.sendMessage({
|
|
1489
|
+
customType: "squad-review-required",
|
|
1490
|
+
content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
|
|
1491
|
+
`Squad claims (review inputs only):\n${summary}\n\n` +
|
|
1492
|
+
`Total cost: $${totalCost.toFixed(4)}\n\n` +
|
|
1493
|
+
buildOrchestratorReviewGate(squad, tasks),
|
|
1494
|
+
display: true,
|
|
1495
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1496
|
+
// Keep the settled scheduler addressable. A later exact-task message
|
|
1497
|
+
// can reopen the task immediately on its bound durable Pi session.
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
case "squad_failed": {
|
|
1501
|
+
const tasks = store.loadAllTasks(squadId);
|
|
1502
|
+
const failed = tasks.filter((task) => task.status === "failed");
|
|
1503
|
+
const done = tasks.filter((task) => task.status === "done");
|
|
1504
|
+
pi.sendMessage({
|
|
1505
|
+
customType: "squad-failed",
|
|
1506
|
+
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1507
|
+
`${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
|
|
1508
|
+
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1509
|
+
"Use squad_status for details or squad_modify to adjust.",
|
|
1510
|
+
display: true,
|
|
1511
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
case "orchestrator_reply":
|
|
1515
|
+
pi.sendMessage({
|
|
1516
|
+
customType: "squad-agent-reply",
|
|
1517
|
+
content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
|
|
1518
|
+
display: true,
|
|
1519
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1520
|
+
break;
|
|
1521
|
+
case "escalation":
|
|
1522
|
+
pi.sendMessage({
|
|
1523
|
+
customType: "squad-escalation",
|
|
1524
|
+
content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
|
|
1525
|
+
`${event.message}\n\nReply to me and I'll forward your answer, or use the squad panel.`,
|
|
1526
|
+
display: true,
|
|
1527
|
+
}, { triggerTurn: true });
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1469
1533
|
// ============================================================================
|
|
1470
1534
|
// Panel — overlay via ctx.ui.custom() with proper done() lifecycle
|
|
1471
1535
|
// ============================================================================
|
|
@@ -1476,6 +1540,7 @@ function forceWidgetUpdate(): void {
|
|
|
1476
1540
|
* that resolves when done() is called. The panel calls done() on close.
|
|
1477
1541
|
*/
|
|
1478
1542
|
function openPanel(
|
|
1543
|
+
pi: ExtensionAPI,
|
|
1479
1544
|
ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
|
|
1480
1545
|
scheduler: Scheduler,
|
|
1481
1546
|
squadId: string,
|
|
@@ -1493,18 +1558,19 @@ function openPanel(
|
|
|
1493
1558
|
const task = store.loadTask(squadId, taskId);
|
|
1494
1559
|
const agentName = task?.agent || taskId;
|
|
1495
1560
|
const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1561
|
+
if (input) {
|
|
1562
|
+
let panelSched = schedulers.get(squadId);
|
|
1563
|
+
if (!panelSched) {
|
|
1564
|
+
panelSched = scheduler;
|
|
1565
|
+
schedulers.set(squadId, panelSched);
|
|
1566
|
+
wireSchedulerEvents(pi, panelSched, squadId);
|
|
1567
|
+
await panelSched.start();
|
|
1568
|
+
}
|
|
1569
|
+
const delivered = await panelSched.sendHumanMessage(taskId, input);
|
|
1570
|
+
ctx.ui.notify(
|
|
1571
|
+
delivered ? `Sent to ${agentName}: "${input}"` : `Queued durably for ${taskId}`,
|
|
1572
|
+
"info",
|
|
1573
|
+
);
|
|
1508
1574
|
}
|
|
1509
1575
|
tui.requestRender();
|
|
1510
1576
|
};
|
|
@@ -1657,71 +1723,8 @@ async function startSquad(
|
|
|
1657
1723
|
widgetState.enabled = true;
|
|
1658
1724
|
widgetControls?.requestUpdate();
|
|
1659
1725
|
|
|
1660
|
-
// Wire up completion/escalation notifications to main agent
|
|
1661
|
-
|
|
1662
|
-
// Update widget on every scheduler event
|
|
1663
|
-
forceWidgetUpdate();
|
|
1664
|
-
switch (event.type) {
|
|
1665
|
-
case "squad_review_required": {
|
|
1666
|
-
const tasks = store.loadAllTasks(squadId);
|
|
1667
|
-
const summary = buildCompletionSummary(tasks);
|
|
1668
|
-
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1669
|
-
|
|
1670
|
-
// Final context update before clearing scheduler
|
|
1671
|
-
const completedSched = schedulers.get(squadId);
|
|
1672
|
-
if (completedSched) {
|
|
1673
|
-
completedSched.updateContext();
|
|
1674
|
-
}
|
|
1675
|
-
const squad = store.loadSquad(squadId)!;
|
|
1676
|
-
|
|
1677
|
-
// Wake the main agent into an explicit acceptance gate. Never frame
|
|
1678
|
-
// agent execution or squad QA as trusted completion.
|
|
1679
|
-
pi.sendMessage({
|
|
1680
|
-
customType: "squad-review-required",
|
|
1681
|
-
content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
|
|
1682
|
-
`Squad claims (review inputs only):\n${summary}\n\n` +
|
|
1683
|
-
`Total cost: $${totalCost.toFixed(4)}\n\n` +
|
|
1684
|
-
buildOrchestratorReviewGate(squad, tasks),
|
|
1685
|
-
display: true,
|
|
1686
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1687
|
-
|
|
1688
|
-
// Clear scheduler but keep activeSquadId so squad_status still works
|
|
1689
|
-
schedulers.delete(squadId);
|
|
1690
|
-
forceWidgetUpdate(); // Final update showing done state
|
|
1691
|
-
break;
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
case "squad_failed": {
|
|
1695
|
-
const tasks = store.loadAllTasks(squadId);
|
|
1696
|
-
const failed = tasks.filter((t) => t.status === "failed");
|
|
1697
|
-
const done = tasks.filter((t) => t.status === "done");
|
|
1698
|
-
|
|
1699
|
-
pi.sendMessage({
|
|
1700
|
-
customType: "squad-failed",
|
|
1701
|
-
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1702
|
-
`${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
|
|
1703
|
-
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1704
|
-
`Use squad_status for details or squad_modify to adjust.`,
|
|
1705
|
-
display: true,
|
|
1706
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1707
|
-
forceWidgetUpdate();
|
|
1708
|
-
break;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
case "escalation": {
|
|
1712
|
-
// Escalation — agent needs help. triggerTurn so the main agent
|
|
1713
|
-
// can respond and relay help.
|
|
1714
|
-
pi.sendMessage({
|
|
1715
|
-
customType: "squad-escalation",
|
|
1716
|
-
content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
|
|
1717
|
-
`${event.message}\n\n` +
|
|
1718
|
-
`Reply to me and I'll forward your answer, or use the squad panel.`,
|
|
1719
|
-
display: true,
|
|
1720
|
-
}, { triggerTurn: true });
|
|
1721
|
-
break;
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
});
|
|
1726
|
+
// Wire up completion/escalation notifications to main agent.
|
|
1727
|
+
wireSchedulerEvents(pi, scheduler, squadId);
|
|
1725
1728
|
|
|
1726
1729
|
// Start scheduling — fire and forget, don't block the tool call.
|
|
1727
1730
|
// scheduler.start() spawns agents which can take seconds per agent.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* message-view.ts — Scrollable message log for a task.
|
|
3
|
-
*
|
|
4
|
-
* TUI
|
|
3
|
+
* The durable history is never sliced; only the fixed-height viewport is
|
|
4
|
+
* collected for the TUI so large histories cannot change component height.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
@@ -9,11 +9,6 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import type { TaskMessage } from "../types.js";
|
|
10
10
|
import * as store from "../store.js";
|
|
11
11
|
|
|
12
|
-
/** Max messages to render (most recent). Older messages are skipped. */
|
|
13
|
-
const MAX_MESSAGES = 30;
|
|
14
|
-
/** Max lines per text message before truncation */
|
|
15
|
-
const MAX_TEXT_LINES = 5;
|
|
16
|
-
|
|
17
12
|
export class MessageView {
|
|
18
13
|
private theme: Theme;
|
|
19
14
|
private squadId: string;
|
|
@@ -79,21 +74,13 @@ export class MessageView {
|
|
|
79
74
|
return pad(header, maxLines);
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const msgLines: string[] = [];
|
|
87
|
-
if (skipped > 0) {
|
|
88
|
-
msgLines.push(fit(th.fg("dim", ` ··· ${skipped} older messages ···`), w));
|
|
89
|
-
msgLines.push("");
|
|
90
|
-
}
|
|
91
|
-
msgLines.push(...this.renderMessages(messages, w));
|
|
92
|
-
|
|
93
|
-
// Fixed layout: header + scrollable content + status line = maxLines exactly
|
|
77
|
+
// Fixed layout: header + scrollable content + status line = maxLines exactly.
|
|
78
|
+
// Count lazily, then collect only the visible window. This keeps the renderer
|
|
79
|
+
// bounded without dropping any durable message or body line.
|
|
94
80
|
const statusLines = 1; // always show status/scroll bar
|
|
95
81
|
const contentHeight = Math.max(1, maxLines - header.length - statusLines);
|
|
96
|
-
const
|
|
82
|
+
const messageLineCount = this.countMessageLines(allMessages, w);
|
|
83
|
+
const maxScroll = Math.max(0, messageLineCount - contentHeight);
|
|
97
84
|
|
|
98
85
|
// Auto-scroll to bottom unless user scrolled up
|
|
99
86
|
if (!this.userScrolled) {
|
|
@@ -108,10 +95,10 @@ export class MessageView {
|
|
|
108
95
|
// Build output — exact height every time
|
|
109
96
|
const lines = [...header];
|
|
110
97
|
|
|
111
|
-
// Content area: pad to exact contentHeight
|
|
112
|
-
const visible =
|
|
98
|
+
// Content area: collect and pad to exact contentHeight.
|
|
99
|
+
const visible = this.collectMessageLines(allMessages, w, this.scrollOffset, contentHeight);
|
|
113
100
|
while (visible.length < contentHeight) visible.push("");
|
|
114
|
-
lines.push(...visible
|
|
101
|
+
lines.push(...visible);
|
|
115
102
|
|
|
116
103
|
// Status bar (always present, keeps layout stable)
|
|
117
104
|
const pct = maxScroll > 0 ? Math.round((this.scrollOffset / maxScroll) * 100) : 100;
|
|
@@ -124,10 +111,32 @@ export class MessageView {
|
|
|
124
111
|
return lines.slice(0, maxLines);
|
|
125
112
|
}
|
|
126
113
|
|
|
127
|
-
private
|
|
114
|
+
private countMessageLines(messages: TaskMessage[], width: number): number {
|
|
115
|
+
let count = 0;
|
|
116
|
+
for (const _line of this.iterMessageLines(messages, width)) count++;
|
|
117
|
+
return count;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private collectMessageLines(
|
|
121
|
+
messages: TaskMessage[],
|
|
122
|
+
width: number,
|
|
123
|
+
start: number,
|
|
124
|
+
limit: number,
|
|
125
|
+
): string[] {
|
|
126
|
+
const visible: string[] = [];
|
|
127
|
+
let index = 0;
|
|
128
|
+
for (const line of this.iterMessageLines(messages, width)) {
|
|
129
|
+
if (index++ < start) continue;
|
|
130
|
+
visible.push(line);
|
|
131
|
+
if (visible.length >= limit) break;
|
|
132
|
+
}
|
|
133
|
+
return visible;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private *iterMessageLines(messages: TaskMessage[], width: number): Generator<string> {
|
|
128
137
|
const th = this.theme;
|
|
129
|
-
const lines: string[] = [];
|
|
130
138
|
let lastFrom: string | null = null;
|
|
139
|
+
let hasOutput = false;
|
|
131
140
|
|
|
132
141
|
for (const msg of messages) {
|
|
133
142
|
if (msg.type === "status" && msg.from === "system" && msg.text === "Agent starting work") continue;
|
|
@@ -136,54 +145,47 @@ export class MessageView {
|
|
|
136
145
|
lastFrom = msg.from;
|
|
137
146
|
|
|
138
147
|
if (showHeader) {
|
|
139
|
-
if (
|
|
148
|
+
if (hasOutput) yield "";
|
|
140
149
|
const time = fmtTime(msg.ts);
|
|
141
|
-
const color = msg.from === "human"
|
|
142
|
-
|
|
143
|
-
|
|
150
|
+
const color = msg.from === "human" || msg.from === "orchestrator"
|
|
151
|
+
? "accent"
|
|
152
|
+
: msg.from === "system" ? "dim" : "success";
|
|
153
|
+
const name = senderLabel(msg.from);
|
|
154
|
+
yield fit(` ${th.fg("dim", time)} ${th.fg(color as any, name)}`, width);
|
|
155
|
+
hasOutput = true;
|
|
144
156
|
}
|
|
145
157
|
|
|
146
158
|
switch (msg.type) {
|
|
147
159
|
case "tool": {
|
|
148
160
|
const name = msg.name || msg.text;
|
|
149
|
-
// Tool args can contain multi-line bash commands — take first line only
|
|
161
|
+
// Tool args can contain multi-line bash commands — take first line only.
|
|
150
162
|
const rawArg = (msg.args?.path || msg.args?.command || "").toString();
|
|
151
163
|
const arg = rawArg.split("\n")[0];
|
|
152
|
-
|
|
164
|
+
yield fit(` ${th.fg("muted", `→ ${name}${arg ? " " + arg : ""}`)}`, width);
|
|
153
165
|
break;
|
|
154
166
|
}
|
|
155
|
-
case "mention":
|
|
156
|
-
|
|
167
|
+
case "mention":
|
|
168
|
+
yield fit(` ${th.fg("accent", `@${msg.to || "?"}`)} ${th.fg("dim", msg.text)}`, width);
|
|
157
169
|
break;
|
|
158
|
-
}
|
|
159
170
|
case "text":
|
|
160
171
|
case "message":
|
|
161
|
-
case "reply":
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
for (const tl of show) {
|
|
165
|
-
// Wrap long lines instead of truncating
|
|
166
|
-
const wrapped = wrap(` ${tl}`, width, " ");
|
|
167
|
-
lines.push(...wrapped);
|
|
168
|
-
}
|
|
169
|
-
if (textLines.length > MAX_TEXT_LINES) {
|
|
170
|
-
lines.push(fit(` ${th.fg("dim", `... +${textLines.length - MAX_TEXT_LINES} lines`)}`, width));
|
|
172
|
+
case "reply":
|
|
173
|
+
for (const textLine of msg.text.replace(/\r/g, "").split("\n")) {
|
|
174
|
+
for (const line of wrap(` ${textLine}`, width, " ")) yield line;
|
|
171
175
|
}
|
|
172
176
|
break;
|
|
173
|
-
}
|
|
174
177
|
case "done":
|
|
175
|
-
|
|
178
|
+
for (const line of wrap(` ✓ ${msg.text}`, width, " ")) yield line;
|
|
176
179
|
break;
|
|
177
180
|
case "error":
|
|
178
|
-
|
|
181
|
+
for (const line of wrap(` ✗ ${msg.text}`, width, " ")) yield line;
|
|
179
182
|
break;
|
|
180
183
|
case "status":
|
|
181
|
-
|
|
184
|
+
yield fit(` ${th.fg("dim", msg.text)}`, width);
|
|
182
185
|
break;
|
|
183
186
|
}
|
|
187
|
+
hasOutput = true;
|
|
184
188
|
}
|
|
185
|
-
|
|
186
|
-
return lines;
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
|
|
@@ -235,6 +237,12 @@ function stripAnsi(str: string): string {
|
|
|
235
237
|
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
function senderLabel(from: string): string {
|
|
241
|
+
if (from === "human") return "YOU";
|
|
242
|
+
if (from === "orchestrator") return "ORCHESTRATOR";
|
|
243
|
+
return from;
|
|
244
|
+
}
|
|
245
|
+
|
|
238
246
|
function pad(lines: string[], max: number): string[] {
|
|
239
247
|
while (lines.length < max) lines.push("");
|
|
240
248
|
return lines.slice(0, max);
|