@fradser/pi-agent-teams 0.8.3 → 0.9.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/.memory/MEMORY.md +1 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/LICENSE +21 -0
- package/README.md +119 -157
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -161
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Static } from "typebox";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
|
|
3
4
|
|
|
4
5
|
// ── Teammate ──────────────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -31,6 +32,9 @@ export interface Teammate {
|
|
|
31
32
|
agent: string;
|
|
32
33
|
/** Per-spawn capability identity, regenerated for every process. */
|
|
33
34
|
spawnId: string;
|
|
35
|
+
/** Stable Work Item selected by agent; each reopen creates a new assignment attempt. */
|
|
36
|
+
workId?: string;
|
|
37
|
+
context?: "fresh" | "fork";
|
|
34
38
|
pid: number;
|
|
35
39
|
status: TeammateStatus;
|
|
36
40
|
/** Working directory (the worktree root when isolated). */
|
|
@@ -69,15 +73,13 @@ export interface Teammate {
|
|
|
69
73
|
* the capability set). Absent only for legacy snapshots. */
|
|
70
74
|
tools?: string[];
|
|
71
75
|
/** True once recognized model/stream activity was observed for this
|
|
72
|
-
* incarnation;
|
|
76
|
+
* incarnation; console telemetry only, never a leader notification. */
|
|
73
77
|
modelOutputSeen?: boolean;
|
|
74
78
|
createdAt: number;
|
|
75
79
|
updatedAt: number;
|
|
76
80
|
stoppedAt?: number;
|
|
77
81
|
/** Last wall-clock time output was observed (any stream event or prompt delivery). */
|
|
78
82
|
lastOutputAt?: number;
|
|
79
|
-
/** When the current stall episode notice was sent (one per episode). */
|
|
80
|
-
stallNoticeSentAt?: number;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// ── Task board ────────────────────────────────────────────────────
|
|
@@ -111,6 +113,8 @@ export interface BoardTask {
|
|
|
111
113
|
/** Replacement task that made this task obsolete. */
|
|
112
114
|
supersededBy?: string;
|
|
113
115
|
result?: string;
|
|
116
|
+
/** Deferred mail archived when an accepted result closes its Work Item. */
|
|
117
|
+
deferredMessages?: Array<{ from: string; subject: string; body: string; timestamp: number }>;
|
|
114
118
|
errorMessage?: string;
|
|
115
119
|
createdAt: number;
|
|
116
120
|
updatedAt: number;
|
|
@@ -123,6 +127,7 @@ export interface TaskIntent {
|
|
|
123
127
|
taskId: string;
|
|
124
128
|
worker: string;
|
|
125
129
|
spawnId: string;
|
|
130
|
+
assignmentId?: string;
|
|
126
131
|
status?: "completed" | "failed";
|
|
127
132
|
result?: string;
|
|
128
133
|
timestamp: number;
|
|
@@ -144,10 +149,12 @@ export interface WorkerUsage {
|
|
|
144
149
|
|
|
145
150
|
export interface MailboxMessage {
|
|
146
151
|
id: string;
|
|
152
|
+
assignmentId?: string;
|
|
153
|
+
spawnId?: string;
|
|
147
154
|
from: string;
|
|
148
155
|
subject: string;
|
|
149
156
|
body: string;
|
|
150
|
-
status?: "in_progress" | "completed" | "failed";
|
|
157
|
+
status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
|
|
151
158
|
/** The harness retained this report for console inspection after shutdown;
|
|
152
159
|
* it never initiated a leader follow-up. */
|
|
153
160
|
archived?: boolean;
|
|
@@ -158,11 +165,12 @@ export interface MailboxMessage {
|
|
|
158
165
|
* is derived by the harness from the first line of the body. */
|
|
159
166
|
export interface WorkerReportEvent {
|
|
160
167
|
id: string;
|
|
168
|
+
assignmentId?: string;
|
|
161
169
|
type: "message";
|
|
162
170
|
worker: string;
|
|
163
171
|
spawnId: string;
|
|
164
172
|
body: string;
|
|
165
|
-
status?: "in_progress" | "completed" | "failed";
|
|
173
|
+
status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
|
|
166
174
|
/** Wall-clock time the teammate wrote the event. */
|
|
167
175
|
timestamp?: number;
|
|
168
176
|
}
|
|
@@ -178,7 +186,8 @@ export function isWorkerEvent(value: unknown): value is WorkerReportEvent {
|
|
|
178
186
|
&& typeof event.worker === "string"
|
|
179
187
|
&& typeof event.spawnId === "string"
|
|
180
188
|
&& typeof event.body === "string"
|
|
181
|
-
&& (event.
|
|
189
|
+
&& (event.assignmentId === undefined || typeof event.assignmentId === "string")
|
|
190
|
+
&& (event.status === undefined || ["in_progress", "completed", "failed", "inform", "request", "handoff"].includes(event.status))
|
|
182
191
|
&& (event.timestamp === undefined || (typeof event.timestamp === "number" && Number.isFinite(event.timestamp)));
|
|
183
192
|
}
|
|
184
193
|
|
|
@@ -197,84 +206,113 @@ export interface InboxMessage {
|
|
|
197
206
|
from: string;
|
|
198
207
|
subject: string;
|
|
199
208
|
body: string;
|
|
209
|
+
/** Incarnation the sender addressed. A replacement resident of the same name
|
|
210
|
+
* must never consume mail written for its predecessor. */
|
|
211
|
+
toSpawnId?: string;
|
|
200
212
|
timestamp: number;
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
// ── Tool parameter schemas (typebox) ──────────────────────────────
|
|
204
216
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
model: Type.Optional(Type.String({ description: 'Optional provider/model pin, or "inherit" to run on the leader\'s current model' })),
|
|
216
|
-
verify: Type.Optional(Type.String({ description: "Role-default completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL" })),
|
|
217
|
-
worktree: Type.Optional(Type.Boolean({ description: "Whether this role receives a dedicated Git worktree" })),
|
|
218
|
-
prompt: Type.String({ minLength: 1, description: "Role prompt for this generated teammate / sub-agent" }),
|
|
219
|
-
persist: Type.Optional(Type.Boolean({ description: "Persist only when the user explicitly asks to keep this role for future sessions" })),
|
|
220
|
-
persistScope: Type.Optional(Type.Union([
|
|
221
|
-
Type.Literal("project"),
|
|
222
|
-
Type.Literal("project-local"),
|
|
223
|
-
], { description: "Persistence scope; defaults to project-local when persist is true" })),
|
|
224
|
-
}, { description: "Optional generated role definition; kept in memory unless explicitly persisted" })),
|
|
225
|
-
});
|
|
217
|
+
export const InlineAgentDefinitionParams = Type.Object({
|
|
218
|
+
description: Type.String({ minLength: 1, description: "Routing contract for the generated Agent" }),
|
|
219
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: `Explicit minimal tool grant. Canonical built-ins: ${WORKER_BUILTIN_TOOLS.join(", ")}. Omitted or [] means coordination-only (agent_event and work), with no file or shell access. No aliases or inherited leader extension tools.` })),
|
|
220
|
+
model: Type.Optional(Type.String({ description: "Provider/model pin or inherit" })),
|
|
221
|
+
verify: Type.Optional(Type.String({ description: "Default Work verification gate" })),
|
|
222
|
+
worktree: Type.Optional(Type.Boolean({ description: "Dedicated Git worktree" })),
|
|
223
|
+
prompt: Type.String({ minLength: 1, description: "Agent role prompt" }),
|
|
224
|
+
persist: Type.Optional(Type.Boolean({ description: "Persist only when explicitly requested" })),
|
|
225
|
+
persistScope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("project-local")])),
|
|
226
|
+
}, { additionalProperties: false });
|
|
226
227
|
|
|
227
|
-
/**
|
|
228
|
-
export const
|
|
229
|
-
name: Type.String({
|
|
230
|
-
})
|
|
228
|
+
/** Legacy overloaded Agent control; replaced by AgentActionParams at final cutover. */
|
|
229
|
+
export const AgentActionParams = Type.Union([
|
|
230
|
+
Type.Object({ action: Type.Literal("delegate"), name: Type.String({ minLength: 1 }), prompt: Type.String({ minLength: 1 }), definition: Type.Optional(InlineAgentDefinitionParams), resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), verify: Type.Optional(Type.String()), model: Type.Optional(Type.String()), fork: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
231
|
+
Type.Object({ action: Type.Literal("start"), name: Type.String({ minLength: 1 }), definition: Type.Optional(InlineAgentDefinitionParams), model: Type.Optional(Type.String()) }, { additionalProperties: false }),
|
|
232
|
+
Type.Object({ action: Type.Literal("inspect"), name: Type.String({ minLength: 1 }), session: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
|
|
233
|
+
Type.Object({ action: Type.Literal("stop"), session: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
|
|
234
|
+
], { description: "Delegate, start, inspect, or stop an Agent session" });
|
|
231
235
|
|
|
236
|
+
/** Shut down one living teammate. */
|
|
232
237
|
/** Create a board task (leader-only). */
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
})
|
|
238
|
+
/** First public Work interface slice. Work creation reuses the current
|
|
239
|
+
* single-writer task record until every acquisition path shares one lifecycle. */
|
|
240
|
+
export const WorkToolParams = Type.Union([
|
|
241
|
+
Type.Object({
|
|
242
|
+
action: Type.Literal("create"),
|
|
243
|
+
subject: Type.String({ minLength: 1, description: "Work title" }),
|
|
244
|
+
description: Type.Optional(Type.String({ description: "Full Work description" })),
|
|
245
|
+
dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Work IDs that must complete first" })),
|
|
246
|
+
verify: Type.Optional(Type.String({ description: "Completion gate for this Work Item" })),
|
|
247
|
+
resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Resource tags that cannot overlap active Work" })),
|
|
248
|
+
}, { additionalProperties: false }),
|
|
249
|
+
Type.Object({ action: Type.Literal("list") }, { additionalProperties: false }),
|
|
250
|
+
Type.Object({
|
|
251
|
+
action: Type.Literal("assign"),
|
|
252
|
+
id: Type.String({ minLength: 1, description: "Pending Work Item ID" }),
|
|
253
|
+
target: Type.Object({ session: Type.String({ minLength: 1, description: "Exact idle session route" }) }, { additionalProperties: false }),
|
|
254
|
+
}, { additionalProperties: false }),
|
|
255
|
+
Type.Object({
|
|
256
|
+
action: Type.Literal("release"),
|
|
257
|
+
id: Type.String({ minLength: 1, description: "Claimed Work Item ID" }),
|
|
258
|
+
reason: Type.String({ minLength: 1, description: "Reason Work is returned to pending" }),
|
|
259
|
+
}, { additionalProperties: false }),
|
|
260
|
+
Type.Object({
|
|
261
|
+
action: Type.Literal("reopen"),
|
|
262
|
+
id: Type.String({ minLength: 1, description: "Completed Work Item ID" }),
|
|
263
|
+
reason: Type.String({ minLength: 1, description: "Reason this completed Work is reopened" }),
|
|
264
|
+
}, { additionalProperties: false }),
|
|
265
|
+
Type.Object({
|
|
266
|
+
action: Type.Literal("supersede"),
|
|
267
|
+
subject: Type.String({ minLength: 1, description: "Replacement Work title" }),
|
|
268
|
+
description: Type.Optional(Type.String({ description: "Full replacement Work description" })),
|
|
269
|
+
dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Replacement Work dependencies" })),
|
|
270
|
+
verify: Type.Optional(Type.String({ description: "Replacement completion gate" })),
|
|
271
|
+
resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Replacement resource tags" })),
|
|
272
|
+
supersedes: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, description: "Obsolete Work IDs replaced atomically" }),
|
|
273
|
+
}, { additionalProperties: false }),
|
|
274
|
+
], { description: "Create, list, assign, release, reopen, or supersede current Work Items" });
|
|
242
275
|
|
|
243
276
|
/** Shared leader/worker read-only board view. */
|
|
244
|
-
|
|
277
|
+
/** Shared communication event parameters across Leader, Worker, and Peers. */
|
|
278
|
+
export const AgentEventParams = Type.Object({
|
|
279
|
+
message: Type.String({ description: "Message content" }),
|
|
280
|
+
to: Type.Optional(Type.String({ minLength: 1, description: "Recipient Agent, leader, or exact session route" })),
|
|
281
|
+
intent: Type.Optional(Type.Union([Type.Literal("inform"), Type.Literal("request")])),
|
|
282
|
+
}, { additionalProperties: false });
|
|
245
283
|
|
|
246
284
|
/** The reserved recipient name for reports to the team leader. */
|
|
247
285
|
export const LEADER_RECIPIENT = "leader";
|
|
248
286
|
|
|
249
287
|
/** The single messaging primitive: addressed peer mail and leader reports.
|
|
250
288
|
* `status` is honored only for to="leader" terminal reports. */
|
|
251
|
-
export const SendMessageParams = Type.Object({
|
|
252
|
-
to: Type.String({ minLength: 1, description: 'Recipient: a teammate name on the roster, or "leader" to report to the team leader' }),
|
|
253
|
-
message: Type.String({ description: "Message content; the first line becomes the title shown in the console" }),
|
|
254
|
-
reopen: Type.Optional(Type.Boolean({ description: "Leader only: explicitly start a new assignment after the teammate reported completion" })),
|
|
255
|
-
resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Leader only: resource tags for a reopened direct assignment. Ignored for worker reports." })),
|
|
256
|
-
status: Type.Optional(Type.Union([
|
|
257
|
-
Type.Literal("in_progress"),
|
|
258
|
-
Type.Literal("completed"),
|
|
259
|
-
Type.Literal("failed"),
|
|
260
|
-
], { description: 'Only for to="leader": completed or failed ends the current assignment and triggers immediate follow-up delivery' })),
|
|
261
|
-
});
|
|
262
|
-
|
|
263
289
|
/** Self-claim a pending board task. */
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
290
|
+
/** Worker-only Work claim operation. It queues an intent; the single-writer
|
|
291
|
+
* harness remains the only authority that can grant ownership. */
|
|
292
|
+
export const WorkerWorkToolParams = Type.Union([
|
|
293
|
+
Type.Object({ action: Type.Literal("list") }, { additionalProperties: false }),
|
|
294
|
+
Type.Object({
|
|
295
|
+
action: Type.Literal("claim"),
|
|
296
|
+
id: Type.Optional(Type.String({ minLength: 1, description: "Specific claimable Work Item ID" })),
|
|
297
|
+
}, { additionalProperties: false }),
|
|
298
|
+
Type.Object({
|
|
299
|
+
action: Type.Literal("release"),
|
|
300
|
+
result: Type.Optional(Type.String({ description: "Reason owned Work is released" })),
|
|
301
|
+
}, { additionalProperties: false }),
|
|
302
|
+
Type.Object({
|
|
303
|
+
action: Type.Literal("submit"),
|
|
304
|
+
outcome: Type.Union([Type.Literal("success"), Type.Literal("failed")]),
|
|
305
|
+
result: Type.Optional(Type.String({ description: "Result evidence for the owned Work Item" })),
|
|
306
|
+
}, { additionalProperties: false }),
|
|
307
|
+
], { description: "Worker claim or submit operation for Work" });
|
|
267
308
|
|
|
268
309
|
/** Submit a claimed task outcome. Completion passes through the verify gate. */
|
|
269
|
-
export const TaskSubmitParams = Type.Object({
|
|
270
|
-
taskId: Type.String({ description: "The claimed task id" }),
|
|
271
|
-
status: Type.Union([Type.Literal("completed"), Type.Literal("failed")], { description: "Outcome of the claimed task" }),
|
|
272
|
-
result: Type.Optional(Type.String({ description: "Result summary recorded on the board when completed" })),
|
|
273
|
-
});
|
|
274
|
-
|
|
275
310
|
// ── State snapshot for persistence ────────────────────────────────
|
|
276
311
|
|
|
312
|
+
export const TEAM_RUNTIME_VERSION = 2;
|
|
313
|
+
|
|
277
314
|
export interface TeamState {
|
|
315
|
+
runtimeVersion: number;
|
|
278
316
|
teammates: Record<string, Teammate>;
|
|
279
317
|
tasks: Record<string, BoardTask>;
|
|
280
318
|
/** Unified teammate model for this session; spawns without a role pin use it. */
|
|
@@ -290,7 +328,8 @@ export interface TeamState {
|
|
|
290
328
|
peerInboxOffsets: Record<string, number>;
|
|
291
329
|
/** Delivered peer message ids per inbox, capped FIFO for deduplication. */
|
|
292
330
|
peerDeliveredIds: Record<string, string[]>;
|
|
293
|
-
/** Harness-owned peer-delivery transition by message id. This records queueing
|
|
294
|
-
*
|
|
295
|
-
|
|
331
|
+
/** Harness-owned peer-delivery transition by message id. This records queueing,
|
|
332
|
+
* control-stream acceptance, or a drop aimed at a retired incarnation; it
|
|
333
|
+
* never asserts recipient processing. */
|
|
334
|
+
peerDeliveryStates: Record<string, "queued" | "routed" | "dropped">;
|
|
296
335
|
}
|
package/src/ui.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { truncateToWidth, Key, matchesKey, fuzzyFilter } from "@earendil-works/pi-tui";
|
|
5
5
|
import {
|
|
6
|
+
agentColor,
|
|
7
|
+
createLiveActivityWidget,
|
|
6
8
|
createPiThemeStyle,
|
|
7
9
|
createSearchPicker,
|
|
8
10
|
modelLabel,
|
|
@@ -11,15 +13,15 @@ import {
|
|
|
11
13
|
PI_SPINNER_INTERVAL_MS,
|
|
12
14
|
notifyPi,
|
|
13
15
|
renderPiPanel,
|
|
14
|
-
|
|
16
|
+
renderLiveActivityIdentity,
|
|
15
17
|
sortModels,
|
|
16
18
|
type SearchPicker,
|
|
17
19
|
} from "@fradser/pi-kit";
|
|
18
|
-
import type
|
|
20
|
+
import { type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
19
21
|
import {
|
|
20
22
|
clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
|
|
21
23
|
} from "./console-viewport.ts";
|
|
22
|
-
import {
|
|
24
|
+
import { formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
|
|
23
25
|
import { MODEL_INHERIT_ALIAS, discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
|
|
24
26
|
import { getPeerDeliveryState, getState, getTeammate, getTeamDefaultModel, listTasks, listTeammates, livingTeammates, setTeamDefaultModel } from "./state.ts";
|
|
25
27
|
import {
|
|
@@ -38,19 +40,15 @@ import { inboxPath } from "./statefile.ts";
|
|
|
38
40
|
import * as fs from "node:fs";
|
|
39
41
|
import * as path from "node:path";
|
|
40
42
|
|
|
41
|
-
const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
|
|
42
43
|
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
43
44
|
let spinnerFrame = 0;
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
function colorFor(name: string): (typeof TEAM_COLORS)[number] {
|
|
52
|
-
return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
|
|
53
|
-
}
|
|
46
|
+
const teamActivityWidget = createLiveActivityWidget({
|
|
47
|
+
key: "teammate",
|
|
48
|
+
placement: "aboveEditor",
|
|
49
|
+
fit: truncateToWidth,
|
|
50
|
+
activityFormat: "markdown",
|
|
51
|
+
});
|
|
54
52
|
|
|
55
53
|
function ensureSpinner(): void {
|
|
56
54
|
const working = livingTeammates().some(isWorking);
|
|
@@ -73,41 +71,12 @@ export function stopUiTimers(): void {
|
|
|
73
71
|
}
|
|
74
72
|
|
|
75
73
|
export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
ctx.ui.setWidget("teammate", (tui, theme) => {
|
|
85
|
-
const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
|
|
86
|
-
timer.unref?.();
|
|
87
|
-
const style = createPiThemeStyle(theme);
|
|
88
|
-
return {
|
|
89
|
-
placement: "belowEditor",
|
|
90
|
-
render: (width: number) => {
|
|
91
|
-
// Only WORKING teammates appear above the input box; idle and
|
|
92
|
-
// stopped teammates stay in the /agent-teams console instead.
|
|
93
|
-
const working = livingTeammates().filter(isWorking);
|
|
94
|
-
if (working.length === 0) return [];
|
|
95
|
-
const lines: string[] = [];
|
|
96
|
-
for (const teammate of working) {
|
|
97
|
-
lines.push(renderPiWidgetRow(fitTeammateRow(
|
|
98
|
-
PI_SPINNER_FRAMES[spinnerFrame],
|
|
99
|
-
style.fg(colorFor(teammate.name), teammate.name),
|
|
100
|
-
runningTeammateActivity(teammate) + stallSuffix(teammate),
|
|
101
|
-
Math.max(1, width - 1),
|
|
102
|
-
(activity) => theme.bold(style.fg("accent", activity)),
|
|
103
|
-
), width, truncateToWidth));
|
|
104
|
-
}
|
|
105
|
-
return lines;
|
|
106
|
-
},
|
|
107
|
-
invalidate: () => {},
|
|
108
|
-
dispose: () => clearInterval(timer),
|
|
109
|
-
};
|
|
110
|
-
});
|
|
74
|
+
const working = livingTeammates().filter(isWorking);
|
|
75
|
+
teamActivityWidget.update(ctx, working.map((teammate) => ({
|
|
76
|
+
id: teammate.name,
|
|
77
|
+
identity: teammate.name,
|
|
78
|
+
activity: runningTeammateActivity(teammate) + stallSuffix(teammate),
|
|
79
|
+
})));
|
|
111
80
|
}
|
|
112
81
|
|
|
113
82
|
function isWorking(teammate: { status: string }): boolean {
|
|
@@ -289,7 +258,7 @@ function describeSpawnModel(pinned: string | undefined): string {
|
|
|
289
258
|
const resolved = resolveSpawnModel(pinned, getTeamDefaultModel(), currentLeaderModelRef());
|
|
290
259
|
if (pinned && pinned.trim().toLowerCase() !== MODEL_INHERIT_ALIAS) return pinned.trim();
|
|
291
260
|
if (resolved.model) {
|
|
292
|
-
const suffix = pinned ? "inherit -> " : "[team default] ";
|
|
261
|
+
const suffix = pinned ? "inherit -> " : resolved.source === "team-default" ? "[team default] " : "";
|
|
293
262
|
return `${suffix}${resolved.model}`;
|
|
294
263
|
}
|
|
295
264
|
return pinned ? "inherit -> (unavailable at spawn)" : "(Pi default)";
|
|
@@ -333,7 +302,7 @@ const LIST_CHROME_LINES = 5;
|
|
|
333
302
|
* teammate-model picker. */
|
|
334
303
|
export function openTeamConsole(ctx: {
|
|
335
304
|
ui: ExtensionUIContext;
|
|
336
|
-
modelRegistry?: { getAvailable(): Array<{ provider: string; id: string; name?: string }> };
|
|
305
|
+
modelRegistry?: { getAll?(): Array<{ provider: string; id: string; name?: string }>; getAvailable(): Array<{ provider: string; id: string; name?: string }> };
|
|
337
306
|
}): Promise<void> {
|
|
338
307
|
return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
339
308
|
let page: ConsolePage = "roster";
|
|
@@ -396,7 +365,11 @@ export function openTeamConsole(ctx: {
|
|
|
396
365
|
model?.id === CLEAR_TEAM_MODEL_ENTRY.id;
|
|
397
366
|
|
|
398
367
|
const openModelPicker = (): void => {
|
|
399
|
-
const
|
|
368
|
+
const registryModels =
|
|
369
|
+
typeof ctx.modelRegistry?.getAll === "function"
|
|
370
|
+
? ctx.modelRegistry.getAll()
|
|
371
|
+
: (ctx.modelRegistry?.getAvailable() ?? []);
|
|
372
|
+
const models = sortModels([...registryModels]);
|
|
400
373
|
if (models.length === 0) return;
|
|
401
374
|
picker = createSearchPicker([CLEAR_TEAM_MODEL_ENTRY, ...models], {
|
|
402
375
|
filter: fuzzyFilter,
|
|
@@ -520,7 +493,7 @@ export function openTeamConsole(ctx: {
|
|
|
520
493
|
const teammateRowText = (key: string, selected: boolean): string => {
|
|
521
494
|
const teammate = getTeammate(key)!;
|
|
522
495
|
const marker = selected ? style.accent("❯ ") : " ";
|
|
523
|
-
const name =
|
|
496
|
+
const name = renderLiveActivityIdentity(teammate.name, theme, "@");
|
|
524
497
|
if (isWorking(teammate)) {
|
|
525
498
|
const prefix = `${marker}${name} `;
|
|
526
499
|
const width = tui.terminal.columns;
|
|
@@ -537,7 +510,7 @@ export function openTeamConsole(ctx: {
|
|
|
537
510
|
const roleRowText = (key: string, selected: boolean): string => {
|
|
538
511
|
const def = getRoles().find((candidate) => candidate.name === key)!;
|
|
539
512
|
const marker = selected ? style.accent("❯ ") : " ";
|
|
540
|
-
const name =
|
|
513
|
+
const name = renderLiveActivityIdentity(key, theme, "@");
|
|
541
514
|
const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
|
|
542
515
|
const liveTag = live > 0 ? style.success(`[${live} live]`) : style.dim("[0 live]");
|
|
543
516
|
const provenance = def.scope === "session" ? "in-memory" : displaySource(def.source);
|
|
@@ -547,7 +520,7 @@ export function openTeamConsole(ctx: {
|
|
|
547
520
|
const taskRowText = (key: string, selected: boolean): string => {
|
|
548
521
|
const task = listTasks().find((candidate) => candidate.id === key)!;
|
|
549
522
|
const marker = selected ? style.accent("❯ ") : " ";
|
|
550
|
-
const label = theme.fg(
|
|
523
|
+
const label = theme.fg(agentColor(key), `[${key}]`);
|
|
551
524
|
const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
|
|
552
525
|
const statusText = task.status === "completed"
|
|
553
526
|
? style.success("✓")
|
|
@@ -564,7 +537,7 @@ export function openTeamConsole(ctx: {
|
|
|
564
537
|
const lines: ContentLine[] = [];
|
|
565
538
|
if (page === "board") {
|
|
566
539
|
if (rows.length === 0) {
|
|
567
|
-
lines.push({ text: style.dim("The
|
|
540
|
+
lines.push({ text: style.dim("The Work list is empty. Create Work with work action=create."), select: -1 });
|
|
568
541
|
}
|
|
569
542
|
rows.forEach((row, index) => lines.push({ text: taskRowText(row.key, index === currentPageSelection(rows.length)), select: index }));
|
|
570
543
|
return lines;
|
|
@@ -691,7 +664,10 @@ export function openTeamConsole(ctx: {
|
|
|
691
664
|
return;
|
|
692
665
|
}
|
|
693
666
|
if (data === "m" || data === "M") {
|
|
694
|
-
|
|
667
|
+
const availableCount = ctx.modelRegistry
|
|
668
|
+
? (typeof ctx.modelRegistry.getAll === "function" ? ctx.modelRegistry.getAll() : ctx.modelRegistry.getAvailable()).length
|
|
669
|
+
: 0;
|
|
670
|
+
if (ctx.modelRegistry && availableCount > 0) {
|
|
695
671
|
openModelPicker();
|
|
696
672
|
} else {
|
|
697
673
|
notifyPi(ctx.ui, "No models are available in the model registry.", "warning");
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
buildSessionContext,
|
|
5
|
+
SessionManager,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type SessionContext,
|
|
8
|
+
type SessionMessageEntry,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
type AgentMessage = SessionContext["messages"][number];
|
|
12
|
+
type ContextReader = Pick<ExtensionContext["sessionManager"], "getBranch" | "getLeafId">;
|
|
13
|
+
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
|
14
|
+
type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
|
|
15
|
+
type ToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
16
|
+
|
|
17
|
+
function withoutRuntimeState(message: AgentMessage): AgentMessage {
|
|
18
|
+
if (message.role === "custom") {
|
|
19
|
+
const { details: _details, ...history } = message;
|
|
20
|
+
return history;
|
|
21
|
+
}
|
|
22
|
+
if (message.role === "toolResult") {
|
|
23
|
+
const { details: _details, addedToolNames: _tools, ...history } = message;
|
|
24
|
+
return history;
|
|
25
|
+
}
|
|
26
|
+
return message;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function completedExchanges(messages: AgentMessage[]): { calls: Set<ToolCall>; results: Set<ToolResultMessage> } {
|
|
30
|
+
const pending = new Map<string, ToolCall>();
|
|
31
|
+
const calls = new Set<ToolCall>();
|
|
32
|
+
const results = new Set<ToolResultMessage>();
|
|
33
|
+
for (const message of messages) {
|
|
34
|
+
if (message.role === "user" || message.role === "assistant") pending.clear();
|
|
35
|
+
if (message.role === "assistant") {
|
|
36
|
+
for (const block of message.content) {
|
|
37
|
+
if (block.type === "toolCall" && !pending.has(block.id)) pending.set(block.id, block);
|
|
38
|
+
}
|
|
39
|
+
} else if (message.role === "toolResult") {
|
|
40
|
+
const call = pending.get(message.toolCallId);
|
|
41
|
+
if (!call || call.name !== message.toolName) continue;
|
|
42
|
+
calls.add(call);
|
|
43
|
+
results.add(message);
|
|
44
|
+
pending.delete(call.id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { calls, results };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Snapshot the active Pi context without moving the parent's branch or replaying pending tools. */
|
|
51
|
+
export function snapshotWorkContext(sessionManager: ContextReader | undefined): AgentMessage[] {
|
|
52
|
+
if (!sessionManager || typeof sessionManager.getBranch !== "function" || typeof sessionManager.getLeafId !== "function") {
|
|
53
|
+
throw new Error("Fork context unavailable: leader session manager is unavailable.");
|
|
54
|
+
}
|
|
55
|
+
const messages = buildSessionContext(sessionManager.getBranch(), sessionManager.getLeafId()).messages;
|
|
56
|
+
const completed = completedExchanges(messages);
|
|
57
|
+
const history: AgentMessage[] = [];
|
|
58
|
+
for (const message of messages) {
|
|
59
|
+
if (message.role === "toolResult" && !completed.results.has(message)) continue;
|
|
60
|
+
if (message.role !== "assistant") {
|
|
61
|
+
history.push(withoutRuntimeState(message));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const content = message.content.filter((block) => block.type !== "toolCall" || completed.calls.has(block));
|
|
65
|
+
if (content.length === 0 && message.content.length > 0) continue;
|
|
66
|
+
const hasTools = content.some((block) => block.type === "toolCall");
|
|
67
|
+
const stopReason = message.stopReason === "toolUse" && !hasTools ? "stop" : message.stopReason;
|
|
68
|
+
history.push({ ...message, content, stopReason });
|
|
69
|
+
}
|
|
70
|
+
return structuredClone(history);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Materialize already-rebuilt context; appendMessage cannot represent Pi summary roles. */
|
|
74
|
+
export function writeWorkContext(cwd: string, directory: string, messages: AgentMessage[]): string {
|
|
75
|
+
const session = SessionManager.create(cwd, directory);
|
|
76
|
+
const file = session.getSessionFile()!;
|
|
77
|
+
let parentId: string | null = null;
|
|
78
|
+
const entries = messages.map((message): SessionMessageEntry => {
|
|
79
|
+
const entry: SessionMessageEntry = {
|
|
80
|
+
type: "message", id: randomUUID(), parentId, timestamp: new Date().toISOString(), message,
|
|
81
|
+
};
|
|
82
|
+
parentId = entry.id;
|
|
83
|
+
return entry;
|
|
84
|
+
});
|
|
85
|
+
const content = [session.getHeader(), ...entries].map((entry) => JSON.stringify(entry)).join("\n");
|
|
86
|
+
writeFileSync(file, `${content}\n`, { mode: 0o600, flag: "wx" });
|
|
87
|
+
return file;
|
|
88
|
+
}
|