pi-extended-teams 2.2.10 → 2.3.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.
Files changed (58) hide show
  1. package/README.md +182 -2
  2. package/extensions/agents/read-agent-report.ts +11 -2
  3. package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
  4. package/extensions/agents/read-agent.ts +387 -65
  5. package/extensions/agents/write-agent.ts +3 -0
  6. package/extensions/events/register-events.ts +37 -21
  7. package/extensions/index.ts +257 -60
  8. package/extensions/internal/pi-check-operations.ts +24 -0
  9. package/extensions/internal/session-files.ts +10 -2
  10. package/extensions/runtime/lead-attachment.ts +91 -0
  11. package/extensions/runtime/reload-owner.ts +107 -0
  12. package/extensions/runtime/team-host.ts +124 -0
  13. package/extensions/runtime/teammate-interrupt.ts +11 -5
  14. package/extensions/runtime/types.ts +4 -0
  15. package/extensions/team/lifecycle.ts +26 -0
  16. package/extensions/team/session-cost.ts +225 -0
  17. package/extensions/tools/agent-communication-tools.ts +24 -8
  18. package/extensions/tools/agent-status-tool.ts +50 -7
  19. package/extensions/tools/coordination-tools.ts +78 -8
  20. package/extensions/tools/public-team-tools.ts +51 -0
  21. package/extensions/tools/task-runtime-tools.ts +23 -4
  22. package/extensions/tools/team-tools.ts +365 -107
  23. package/extensions/ui/activity-colors.ts +50 -0
  24. package/extensions/ui/agent-follow-view.ts +15 -11
  25. package/extensions/ui/agent-navigation.ts +35 -1
  26. package/extensions/ui/checkpoints-command.ts +28 -0
  27. package/extensions/ui/onboarding-command.ts +218 -0
  28. package/extensions/ui/status-widget.ts +22 -14
  29. package/package.json +1 -1
  30. package/skills/teams.md +16 -0
  31. package/src/orchestration/index.ts +19 -1
  32. package/src/orchestration/status-projection.ts +2 -3
  33. package/src/orchestration/types.ts +9 -1
  34. package/src/results/check-journal.ts +149 -0
  35. package/src/results/check-policy.ts +86 -0
  36. package/src/results/check-runner.ts +110 -0
  37. package/src/results/checkpoint-assignment.ts +60 -0
  38. package/src/results/checkpoint-policy.ts +20 -0
  39. package/src/results/checkpoint-report.ts +109 -0
  40. package/src/results/checkpoint-retention.ts +15 -0
  41. package/src/results/completion-group-delivery.ts +66 -0
  42. package/src/results/completion-group-recovery.ts +61 -0
  43. package/src/results/completion-group-wake.ts +62 -0
  44. package/src/results/completion-group.ts +290 -0
  45. package/src/results/continuation-recipient.ts +37 -0
  46. package/src/results/durable-json.ts +29 -0
  47. package/src/results/repair-policy.ts +24 -0
  48. package/src/results/report-result.ts +77 -0
  49. package/src/results/source-identity.ts +115 -0
  50. package/src/results/specialist-checkpoint.ts +227 -0
  51. package/src/results/verification-controller.ts +308 -0
  52. package/src/utils/messaging.ts +47 -10
  53. package/src/utils/models.ts +13 -0
  54. package/src/utils/paths.ts +4 -0
  55. package/src/utils/report-events.ts +95 -10
  56. package/src/utils/settings.ts +24 -0
  57. package/src/utils/teams.ts +3 -1
  58. package/src/utils/write-queue.ts +3 -0
@@ -0,0 +1,24 @@
1
+ import type { CheckOperations } from "../../src/results/check-runner";
2
+ import { loadPiRuntimeApi } from "./pi-runtime-api";
3
+
4
+ export async function loadNativeCheckOperations(suppliedApi?: unknown): Promise<CheckOperations | undefined> {
5
+ const api: unknown = suppliedApi === undefined ? await loadPiRuntimeApi() : suppliedApi;
6
+ if (typeof api !== "object" || api === null) return undefined;
7
+ const factory: unknown = Reflect.get(api, "createLocalBashOperations");
8
+ if (typeof factory !== "function") return undefined;
9
+ const operations: unknown = factory();
10
+ if (typeof operations !== "object" || operations === null) return undefined;
11
+ const exec: unknown = Reflect.get(operations, "exec");
12
+ if (typeof exec !== "function") return undefined;
13
+ return {
14
+ async exec(command, cwd, options) {
15
+ const result: unknown = await Reflect.apply(exec, operations, [command, cwd, options]);
16
+ if (typeof result !== "object" || result === null) throw new Error("Native check returned no exit evidence.");
17
+ const exitCode: unknown = Reflect.get(result, "exitCode");
18
+ if (exitCode !== null && (typeof exitCode !== "number" || !Number.isInteger(exitCode))) {
19
+ throw new Error("Native check returned invalid exit evidence.");
20
+ }
21
+ return { exitCode };
22
+ },
23
+ };
24
+ }
@@ -31,18 +31,22 @@ export function findLeadTeamForSession(sessionId?: string): string | null {
31
31
  const teamsDir = path.dirname(paths.teamDir("__probe__"));
32
32
  if (!fs.existsSync(teamsDir)) return null;
33
33
 
34
+ let latest: { name: string; selectedAt: number } | undefined;
34
35
  for (const teamDir of fs.readdirSync(teamsDir)) {
36
+ if (teamDir.startsWith("prompt-build-")) continue;
35
37
  try {
36
38
  const sessionFile = paths.leadSessionPath(teamDir);
37
39
  if (!fs.existsSync(sessionFile)) continue;
38
40
  const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
39
41
  if (session.sessionId === sessionId && session.pid === process.pid) {
40
- return teamDir;
42
+ const selectedAt = typeof session.startedAt === "number" && Number.isFinite(session.startedAt) ? session.startedAt : 0;
43
+ if (!latest || selectedAt > latest.selectedAt) latest = { name: teamDir, selectedAt };
41
44
  }
42
45
  } catch {
43
46
  // ignore entries that are not valid team names, and invalid session files
44
47
  }
45
48
  }
49
+ return latest?.name ?? null;
46
50
  } catch {
47
51
  // ignore errors
48
52
  }
@@ -50,13 +54,17 @@ export function findLeadTeamForSession(sessionId?: string): string | null {
50
54
  }
51
55
 
52
56
  export function registerLeadSession(teamName: string, sessionId?: string) {
57
+ const previousTeam = findLeadTeamForSession(sessionId);
58
+ const previous = previousTeam ? readJsonFile(paths.leadSessionPath(previousTeam)) : null;
59
+ // Ownership selections stay ordered even when two adoptions share a clock tick.
60
+ const selectedAt = Math.max(Date.now(), typeof previous?.startedAt === "number" && Number.isFinite(previous.startedAt) ? previous.startedAt + 1 : 0);
53
61
  const sessionFile = paths.leadSessionPath(teamName);
54
62
  const dir = path.dirname(sessionFile);
55
63
  fs.mkdirSync(dir, { recursive: true });
56
64
  fs.writeFileSync(sessionFile, JSON.stringify({
57
65
  pid: process.pid,
58
66
  sessionId,
59
- startedAt: Date.now(),
67
+ startedAt: selectedAt,
60
68
  }));
61
69
  }
62
70
 
@@ -0,0 +1,91 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { TSchema } from "@sinclair/typebox";
3
+ import { Key } from "@mariozechner/pi-tui";
4
+ import { connectReloadOwner, type ReloadOwner } from "./reload-owner";
5
+ import { createTeamHost, type TeamHostBinding } from "./team-host";
6
+ import { registerAgentReportRenderer } from "../events/register-events";
7
+ import { publicTeamToolMetadata } from "../tools/public-team-tools";
8
+ import { registerCoordinationTools, type CoordinationToolsOptions } from "../tools/coordination-tools";
9
+ import { registerTaskRuntimeTools, type TaskRuntimeToolsOptions } from "../tools/task-runtime-tools";
10
+ import { registerFavoriteModelsCommand } from "../ui/favorite-models-command";
11
+ import { registerExtensionsCommand } from "../ui/extensions-command";
12
+ import { registerOnboardingCommand } from "../ui/onboarding-command";
13
+ import { registerCheckpointsCommand } from "../ui/checkpoints-command";
14
+
15
+ export interface TeamExecutionOwner extends ReloadOwner<TeamHostBinding> {
16
+ host: ReturnType<typeof createTeamHost>;
17
+ coordination: CoordinationToolsOptions;
18
+ taskTools: TaskRuntimeToolsOptions;
19
+ activate(): void;
20
+ }
21
+
22
+ export function registerLeadAttachment(pi: ExtensionAPI, createOwner: () => TeamExecutionOwner): void {
23
+ let connection: Awaited<ReturnType<typeof connectReloadOwner<TeamHostBinding>>> | undefined;
24
+ let attaching: Promise<void> | undefined;
25
+ const attach = (ctx: any, event: any): Promise<void> => {
26
+ if (connection) return Promise.resolve();
27
+ return attaching ??= connectReloadOwner(ctx.sessionManager, ctx.sessionManager.getSessionId(), { pi, ctx, event }, createOwner)
28
+ .then(value => { connection = value; execution().activate(); }).finally(() => { attaching = undefined; });
29
+ };
30
+ const execution = (): TeamExecutionOwner => {
31
+ if (!connection) throw new Error("Team execution is not attached to this session.");
32
+ return connection.owner as TeamExecutionOwner;
33
+ };
34
+
35
+ for (const metadata of Object.values(publicTeamToolMetadata)) {
36
+ pi.registerTool<TSchema>({ ...metadata, async execute(id, params, signal, update, ctx) {
37
+ await attach(ctx, { reason: "startup" });
38
+ const owner = execution();
39
+ return owner.host.tools.get(metadata.name).execute(id, params, signal, update, owner.host.snapshot(ctx));
40
+ } });
41
+ }
42
+ // These lead-only adapters carry no running task state. In-process child
43
+ // tools remain owned by the retained controller and its original imports.
44
+ registerCoordinationTools(pi, {
45
+ isTeammate: false, agentName: "team-lead",
46
+ get terminal() { return execution().coordination.terminal; },
47
+ getTeamName: () => execution().coordination.getTeamName(),
48
+ requireWriteAgentTeam: () => execution().coordination.requireWriteAgentTeam(),
49
+ requireTeamContext: name => execution().coordination.requireTeamContext(name),
50
+ releaseAllClaimsForAgent: (...args) => execution().coordination.releaseAllClaimsForAgent(...args),
51
+ drainWriteQueue: name => execution().coordination.drainWriteQueue(name),
52
+ resolveSkillFile: (...args) => execution().coordination.resolveSkillFile(...args),
53
+ adoptTeamAsLead: (name, ctx) => execution().coordination.adoptTeamAsLead(name, ctx && execution().host.snapshot(ctx)),
54
+ renderLeadInboxStatus: () => execution().coordination.renderLeadInboxStatus(),
55
+ resetLeadWakeNotifiedCount: () => execution().coordination.resetLeadWakeNotifiedCount(),
56
+ deliverMessageToActiveAgent: (...args) => execution().coordination.deliverMessageToActiveAgent!(...args),
57
+ });
58
+ registerTaskRuntimeTools(pi, {
59
+ isTeammate: false,
60
+ get terminal() { return execution().taskTools.terminal; },
61
+ get runningReadAgents() { return execution().taskTools.runningReadAgents; },
62
+ readAgentKey: (...args) => execution().taskTools.readAgentKey(...args),
63
+ getTeamName: () => execution().taskTools.getTeamName(),
64
+ interruptTeammate: name => execution().taskTools.interruptTeammate!(name),
65
+ cancelQueuedAgent: (...args) => execution().taskTools.cancelQueuedAgent!(...args),
66
+ shutdownTeammate: (...args) => execution().taskTools.shutdownTeammate(...args),
67
+ });
68
+ registerAgentReportRenderer(pi);
69
+ registerFavoriteModelsCommand(pi);
70
+ registerExtensionsCommand(pi);
71
+ registerOnboardingCommand(pi);
72
+ registerCheckpointsCommand(pi);
73
+ pi.registerShortcut(Key.alt("tab"), {
74
+ description: "Cycle active writer-agent tmux screens",
75
+ handler: ctx => execution().host.shortcuts.get(Key.alt("tab"))?.handler(execution().host.snapshot(ctx)),
76
+ });
77
+
78
+ pi.on("session_start", (event, ctx) => attach(ctx, event));
79
+ pi.on("session_shutdown", async event => {
80
+ const current = connection;
81
+ connection = undefined;
82
+ await current?.shutdown(event.reason ?? "quit");
83
+ });
84
+ for (const name of ["context", "turn_start", "tool_execution_start", "tool_execution_end", "message_end", "turn_end",
85
+ "before_agent_start", "agent_end", "agent_settled", "session_compact", "session_tree"] as const) {
86
+ (pi.on as any)(name, (event: unknown, ctx: unknown) => {
87
+ const owner = execution();
88
+ return owner.host.emit(name, event, owner.host.snapshot(ctx));
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,107 @@
1
+ export interface ReloadOwner<Attachment> {
2
+ attach(attachment: Attachment): void | Promise<void>;
3
+ detach(): void;
4
+ close(reason: string): Promise<void>;
5
+ hasWork(): boolean;
6
+ }
7
+
8
+ interface RetainedOwner {
9
+ owner: ReloadOwner<unknown>;
10
+ state: "attaching" | "attached" | "detached" | "closing";
11
+ generation: number;
12
+ deadline?: number;
13
+ timer?: ReturnType<typeof setTimeout>;
14
+ closing?: Promise<void>;
15
+ }
16
+
17
+ interface Registry {
18
+ version: 1;
19
+ sessions: WeakMap<object, Map<string, RetainedOwner>>;
20
+ }
21
+
22
+ const REGISTRY_KEY = Symbol.for("pi-extended-teams.reload-owners.v1");
23
+ const RECOVERY_WINDOW_MS = 60_000;
24
+
25
+ // Pi re-evaluates modules on reload, but keeps the Node realm and (in the
26
+ // supported host) the root SessionManager. Session IDs alone can collide
27
+ // between two independent hosts opening the same saved conversation.
28
+ function registry(): Registry {
29
+ const globals = globalThis as typeof globalThis & { [REGISTRY_KEY]?: Registry };
30
+ return globals[REGISTRY_KEY] ??= { version: 1, sessions: new WeakMap() };
31
+ }
32
+
33
+ export async function connectReloadOwner<Attachment>(
34
+ manager: object,
35
+ sessionId: string,
36
+ attachment: Attachment,
37
+ createOwner: () => ReloadOwner<Attachment>,
38
+ ): Promise<{ owner: ReloadOwner<Attachment>; shutdown(reason: string): Promise<void> }> {
39
+ const sessions = registry().sessions;
40
+ let owners = sessions.get(manager);
41
+ if (!owners) sessions.set(manager, owners = new Map());
42
+ let record = owners.get(sessionId);
43
+ if (record && record.state !== "detached") {
44
+ throw new Error(`Team execution owner is ${record.state}; refusing a replacement.`);
45
+ }
46
+ if (!record) {
47
+ record = { owner: createOwner() as ReloadOwner<unknown>, state: "attaching", generation: 0 };
48
+ owners.set(sessionId, record);
49
+ }
50
+ const current = record;
51
+ const generation = ++current.generation;
52
+ current.state = "attaching";
53
+
54
+ const close = (reason: string): Promise<void> => {
55
+ if (current.closing) return current.closing;
56
+ current.state = "closing";
57
+ current.generation++;
58
+ clearTimeout(current.timer);
59
+ current.closing = Promise.resolve().then(() => current.owner.close(reason)).then(() => {
60
+ if (owners.get(sessionId) === current) owners.delete(sessionId);
61
+ });
62
+ return current.closing;
63
+ };
64
+ const armDeadline = (): void => {
65
+ clearTimeout(current.timer);
66
+ const epoch = current.generation;
67
+ current.timer = setTimeout(() => {
68
+ if (current.generation !== epoch || current.state === "attached") return;
69
+ // A failed cleanup remains in the registry as a fence. Durable lifecycle
70
+ // records retain the cleanup evidence; do not silently replace this owner.
71
+ void close("reload-timeout").catch(() => {});
72
+ }, Math.max(0, (current.deadline ?? Date.now()) - Date.now()));
73
+ current.timer.unref?.();
74
+ };
75
+
76
+ const isClosing = () => current.state === "closing";
77
+ if (current.deadline !== undefined) armDeadline();
78
+ try {
79
+ await current.owner.attach(attachment);
80
+ if (current.generation !== generation || current.state !== "attaching") {
81
+ throw new Error("Team reload attachment expired while reconnecting.");
82
+ }
83
+ clearTimeout(current.timer);
84
+ current.deadline = undefined;
85
+ current.state = "attached";
86
+ } catch (error) {
87
+ if (!isClosing()) {
88
+ current.owner.detach();
89
+ current.state = "detached";
90
+ current.deadline ??= Date.now() + RECOVERY_WINDOW_MS;
91
+ armDeadline();
92
+ }
93
+ throw error;
94
+ }
95
+
96
+ return {
97
+ owner: current.owner as ReloadOwner<Attachment>,
98
+ async shutdown(reason) {
99
+ if (current.generation !== generation || current.state !== "attached") return;
100
+ if (reason !== "reload" || !current.owner.hasWork()) return close(reason);
101
+ current.owner.detach();
102
+ current.state = "detached";
103
+ current.deadline = Date.now() + RECOVERY_WINDOW_MS;
104
+ armDeadline();
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,124 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ type Handler = (...args: any[]) => any;
4
+ export interface TeamHostBinding { pi: ExtensionAPI; ctx: any; event: any }
5
+
6
+ // Explicit root-facing capabilities used by the execution owner. This is not
7
+ // an ExtensionAPI proxy: registrations are data, and detached sends are kept
8
+ // separate from native agent work and its raw delivery promises.
9
+ export function createTeamHost() {
10
+ let binding: TeamHostBinding | undefined;
11
+ let generation = 0;
12
+ let ready = false;
13
+ let closed = false;
14
+ const handlers = new Map<string, Handler[]>();
15
+ const tools = new Map<string, any>();
16
+ const shortcuts = new Map<string, any>();
17
+ const subscriptions = new Set<{ name: string; handler: Handler; unsubscribe?: () => void }>();
18
+ const pending: Array<(pi: ExtensionAPI) => void> = [];
19
+ const waiters = new Set<{ resolve(): void; reject(error: Error): void }>();
20
+ const current = () => {
21
+ if (!binding) throw new Error("The team host is detached during reload.");
22
+ return binding;
23
+ };
24
+ function send(action: (pi: ExtensionAPI) => void) {
25
+ if (binding && ready) action(binding.pi);
26
+ else if (!closed) pending.push(action);
27
+ }
28
+ const api = {
29
+ on(name: string, handler: Handler) { handlers.set(name, [...(handlers.get(name) ?? []), handler]); },
30
+ registerTool(tool: any) { tools.set(tool.name, tool); },
31
+ registerCommand() {},
32
+ registerShortcut(key: string, shortcut: any) { shortcuts.set(key, shortcut); },
33
+ getCommands: () => current().pi.getCommands(),
34
+ getAllTools: () => current().pi.getAllTools(),
35
+ appendEntry: (...args: Parameters<ExtensionAPI["appendEntry"]>) => current().pi.appendEntry(...args),
36
+ sendUserMessage: (...args: Parameters<ExtensionAPI["sendUserMessage"]>) => send(pi => pi.sendUserMessage(...args)),
37
+ sendMessage: (...args: Parameters<ExtensionAPI["sendMessage"]>) => send(pi => {
38
+ if (typeof pi.sendMessage === "function") pi.sendMessage(...args);
39
+ else pi.sendUserMessage(args[0].content);
40
+ }),
41
+ events: {
42
+ emit(name: string, payload: unknown) { send(pi => pi.events.emit(name, payload)); },
43
+ on(name: string, handler: Handler) {
44
+ const subscription = { name, handler, unsubscribe: binding?.pi.events.on(name, handler) };
45
+ subscriptions.add(subscription);
46
+ return () => { subscription.unsubscribe?.(); subscriptions.delete(subscription); };
47
+ },
48
+ },
49
+ };
50
+ function detach() {
51
+ ready = false;
52
+ binding = undefined;
53
+ generation++;
54
+ for (const subscription of subscriptions) {
55
+ subscription.unsubscribe?.();
56
+ subscription.unsubscribe = undefined;
57
+ }
58
+ }
59
+ function snapshot(ctx: any) {
60
+ const epoch = generation;
61
+ const available = () => binding && generation === epoch;
62
+ const ui: Record<string, unknown> = {};
63
+ for (const name of ["notify", "setStatus", "setWidget", "setTitle", "setEditorComponent", "setWorkingMessage", "setHeader", "setFooter"]) {
64
+ ui[name] = (...args: unknown[]) => { if (available()) return binding!.ctx.ui?.[name]?.(...args); };
65
+ }
66
+ for (const name of ["custom", "select", "confirm", "input", "editor", "getEditorComponent", "getToolsExpanded"]) {
67
+ ui[name] = (...args: unknown[]) => { if (available()) return binding!.ctx.ui?.[name]?.(...args); };
68
+ }
69
+ Object.defineProperty(ui, "theme", { get: () => available() ? binding!.ctx.ui?.theme : undefined });
70
+ const trusted = ctx.isProjectTrusted?.() === true;
71
+ return {
72
+ cwd: ctx.cwd, sessionManager: ctx.sessionManager, modelRegistry: ctx.modelRegistry,
73
+ model: ctx.model, thinkingLevel: ctx.thinkingLevel, hasUI: ctx.hasUI, mode: ctx.mode, ui,
74
+ isProjectTrusted: () => trusted,
75
+ isIdle: () => available() ? binding!.ctx.isIdle() : false,
76
+ getContextUsage: () => available() ? binding!.ctx.getContextUsage?.() : undefined,
77
+ getSystemPrompt: () => available() ? binding!.ctx.getSystemPrompt?.() : "",
78
+ shutdown: () => { if (available()) return binding!.ctx.shutdown(); },
79
+ };
80
+ }
81
+ return {
82
+ api, tools, shortcuts, snapshot,
83
+ get attached() { return !!binding; },
84
+ get ready() { return ready && !!binding; },
85
+ get context() { return binding ? snapshot(binding.ctx) : undefined; },
86
+ attach(next: TeamHostBinding) {
87
+ if (closed) throw new Error("The team execution owner is closing.");
88
+ binding = next;
89
+ ready = false;
90
+ generation++;
91
+ for (const subscription of subscriptions) subscription.unsubscribe = next.pi.events.on(subscription.name, subscription.handler);
92
+ },
93
+ detach,
94
+ async emit(name: string, event: any, ctx: any) {
95
+ let result: any;
96
+ for (const handler of handlers.get(name) ?? []) {
97
+ const value = await handler(event, ctx);
98
+ if (value !== undefined) result = value;
99
+ }
100
+ return result;
101
+ },
102
+ flush() {
103
+ ready = !!binding && !closed;
104
+ while (ready && binding && pending.length) {
105
+ const action = pending.shift()!;
106
+ // Remove before invoking: a thrown/uncertain host send is not replayed.
107
+ try { action(binding.pi); }
108
+ catch (error) { binding.ctx.ui?.notify?.(`Team delivery needs attention: ${String(error)}. Read the saved inbox/reports.`, "warning"); }
109
+ }
110
+ if (binding) { for (const waiter of waiters) waiter.resolve(); waiters.clear(); }
111
+ },
112
+ whenAttached(): Promise<void> {
113
+ if (closed) return Promise.reject(new Error("Team execution is closing."));
114
+ if (binding && ready) return Promise.resolve();
115
+ return new Promise((resolve, reject) => waiters.add({ resolve, reject }));
116
+ },
117
+ close() {
118
+ closed = true;
119
+ detach();
120
+ for (const waiter of waiters) waiter.reject(new Error("Team reload recovery expired or the session closed."));
121
+ waiters.clear();
122
+ },
123
+ };
124
+ }
@@ -12,7 +12,7 @@ export interface TeammateInterruptResult {
12
12
  message: string;
13
13
  agentKind?: "read" | "write";
14
14
  lifecycleRunId?: string;
15
- mechanism?: "agent-session-abort" | "tmux-escape";
15
+ mechanism?: "agent-session-abort" | "assigned-check-abort" | "tmux-escape";
16
16
  reason?: "no-active-session" | "session-not-created" | "lifecycle-closing"
17
17
  | "missing-pane" | "missing-runtime-proof" | "terminal-interrupt-unavailable";
18
18
  error?: string;
@@ -73,15 +73,18 @@ async function interruptReadAgent(
73
73
  }
74
74
 
75
75
  const generation = state.activeOperationGeneration;
76
+ const checkOperation = state.checkOperation;
76
77
  const toolName = state.activeToolName?.trim();
77
- if (!generation || !toolName || state.status !== "working" || !state.session.isStreaming) {
78
+ if (!toolName || state.status !== "working" || (!checkOperation && (!generation || !state.session.isStreaming))) {
78
79
  return result("no_command", state.name, `Agent ${state.name} has no running tool command to interrupt.`, fields);
79
80
  }
80
81
  if (state.operationInterruptPromise) {
81
82
  return result("pending", state.name, `An interrupt is already pending for ${state.name}'s running ${toolName} command.`, fields);
82
83
  }
83
84
 
84
- const operationSettlement = state.activeOperationSettlementPromise;
85
+ const operationSettlement = checkOperation
86
+ ? Promise.all([checkOperation.settled, state.activeOperationSettlementPromise]).then(() => {})
87
+ : state.activeOperationSettlementPromise;
85
88
  if (!operationSettlement) {
86
89
  return result("no_command", state.name, `Agent ${state.name} has no running tool command to interrupt.`, fields);
87
90
  }
@@ -89,7 +92,10 @@ async function interruptReadAgent(
89
92
  state.interruptRequestedGeneration = generation;
90
93
  let nativeAbort: Promise<void>;
91
94
  try {
92
- nativeAbort = Promise.resolve(state.session.abort());
95
+ checkOperation?.controller.abort();
96
+ nativeAbort = checkOperation && !state.session.isStreaming
97
+ ? Promise.resolve()
98
+ : Promise.resolve(state.session.abort());
93
99
  } catch (error) {
94
100
  if (state.interruptRequestedGeneration === generation) state.interruptRequestedGeneration = undefined;
95
101
  const message = errorText(error);
@@ -130,7 +136,7 @@ async function interruptReadAgent(
130
136
  });
131
137
  }
132
138
  return result("interrupted", state.name, `Interrupted ${state.name}'s running ${toolName} command. The agent is still active and can receive a follow-up message.`, {
133
- ...fields, mechanism: "agent-session-abort",
139
+ ...fields, mechanism: checkOperation ? "assigned-check-abort" : "agent-session-abort",
134
140
  });
135
141
  }
136
142
 
@@ -1,4 +1,5 @@
1
1
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
2
+ import type { ReportResult } from "../../src/results/report-result";
2
3
  import type { ContextUsageSnapshot, RuntimeError } from "../../src/utils/runtime";
3
4
  import type { ManagedReadAgentLifecycleState } from "../agents/read-agent-session-lifecycle";
4
5
 
@@ -46,6 +47,8 @@ export interface RunningReadAgent extends ManagedReadAgentLifecycleState {
46
47
  cleanupPrivateSessionOnFinalize?: boolean;
47
48
  /** Fail finalization closed so runtime, member, fence, and transcript remain recoverable. */
48
49
  finalizationBlockedReason?: string;
50
+ /** Optional accounting observer, called only after exact successful lifecycle settlement. */
51
+ onCostSettled?(): void;
49
52
  session?: AgentSession;
50
53
  moveToHerdr?(): Promise<void>;
51
54
  finished?: Promise<void>;
@@ -63,6 +66,7 @@ export interface CompletedAgentReport {
63
66
  name: string;
64
67
  role: string;
65
68
  status: "completed" | "failed";
69
+ result?: ReportResult;
66
70
  report: string;
67
71
  summary?: string;
68
72
  completedAt: number;
@@ -4,6 +4,12 @@ import * as paths from "../../src/utils/paths";
4
4
  import * as runtime from "../../src/utils/runtime";
5
5
  import * as messaging from "../../src/utils/messaging";
6
6
  import * as teams from "../../src/utils/teams";
7
+ import { readStoredTeamReportEvent } from "../../src/utils/report-events";
8
+ import { resyncReportCheckpoint } from "../../src/results/checkpoint-report";
9
+ import { createReportResult } from "../../src/results/report-result";
10
+ import { VerificationController } from "../../src/results/verification-controller";
11
+ import { CompletionGroup } from "../../src/results/completion-group";
12
+ import { enqueueCompletionGroupDeliveries } from "../../src/results/completion-group-delivery";
7
13
  import { loadSettings } from "../../src/utils/settings";
8
14
  import type { Member } from "../../src/utils/models";
9
15
  import type { RunningReadAgent } from "../runtime/types";
@@ -259,6 +265,25 @@ export function createLifecycleRuntime(options: LifecycleRuntimeOptions) {
259
265
  return;
260
266
  }
261
267
 
268
+ const bound = createReportResult(teamName, member.name, expectedRunId, {});
269
+ const report = await readStoredTeamReportEvent(teamName, bound.reportId);
270
+ const result = { ...bound, verification: report?.result?.verification ?? bound.verification, repair: report?.result?.repair };
271
+ const candidate = currentMember ?? member;
272
+ const controller = new VerificationController({ teamName, result, cwd: candidate.cwd,
273
+ checks: candidate.assignedChecks, repair: candidate.repairPolicy });
274
+ if (candidate.repairPolicy || result.repair || fs.existsSync(controller.journalPath)) {
275
+ await controller.cancelAndRequireSettled();
276
+ }
277
+ if ((candidate.checkpointAssignment || member.checkpointAssignment) && !report?.checkpoint) throw new Error("Checkpoint report provenance is unavailable.");
278
+ if (report?.checkpoint) await resyncReportCheckpoint(report);
279
+ const groupBinding = candidate.completionGroup ?? member.completionGroup ?? report?.completionGroup;
280
+ if (groupBinding) {
281
+ const group = new CompletionGroup(teamName, groupBinding.groupId);
282
+ if (report?.result) await group.recordReport(groupBinding.slotId, report.result, report.status);
283
+ else await group.apply({ type: reason === "quit" ? "cancelled" : "interrupted", ...groupBinding,
284
+ runId: expectedRunId, reason: `Agent lifecycle ended (${reason}) without a final report.` });
285
+ await enqueueCompletionGroupDeliveries(group);
286
+ }
262
287
  releasedClaims = await releaseClaims(teamName, member.name);
263
288
 
264
289
  // Private transcript cleanup is the first destructive recovery step.
@@ -327,6 +352,7 @@ export function createLifecycleRuntime(options: LifecycleRuntimeOptions) {
327
352
  if (drainQueue && (member.role ?? "write") === "write") {
328
353
  void options.drainWriteQueue(teamName).catch(() => {});
329
354
  }
355
+ try { expectedState?.onCostSettled?.(); } catch { /* Accounting cannot alter settlement. */ }
330
356
  try {
331
357
  options.onTeammateSettled?.(teamName, { ...member, lifecycleRunId: expectedRunId });
332
358
  } catch {