pi-extended-teams 2.3.0 → 2.3.2

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.
@@ -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
+ }
@@ -7,6 +7,8 @@ export interface CostRun {
7
7
  settle(snapshot: AgentCostSnapshot | undefined, outcome: CostOutcome): void;
8
8
  exclude(): void;
9
9
  }
10
+ export type SessionCostContext = Pick<ExtensionContext, "sessionManager">;
11
+ type CostHost = Pick<ExtensionAPI, "on" | "appendEntry" | "events">;
10
12
  interface Receipt {
11
13
  rootSessionId: string;
12
14
  teamName: string;
@@ -16,6 +18,11 @@ interface Receipt {
16
18
  costUsd?: number | null;
17
19
  outcome?: CostOutcome;
18
20
  }
21
+ interface Scope {
22
+ root: string;
23
+ failed: Set<string>;
24
+ pending: Map<string, Receipt>;
25
+ }
19
26
  const object = (value: unknown): Record<string, unknown> =>
20
27
  value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
21
28
  const validCost = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -40,8 +47,13 @@ export function recordedSessionCost(entries: readonly unknown[]): { usd: number;
40
47
  return { usd, complete };
41
48
  }
42
49
 
43
- export function createCombinedSessionCost(pi: Pick<ExtensionAPI, "on" | "appendEntry" | "events">) {
44
- let scope: { root: string; ctx: ExtensionContext; failed: Set<string>; pending: Map<string, Receipt> } | undefined;
50
+ export function createCombinedSessionCost(initialHost: CostHost) {
51
+ let scope: Scope | undefined;
52
+ let host: CostHost | undefined;
53
+ let context: SessionCostContext | undefined;
54
+ let displayTotal: ReturnType<typeof recordedSessionCost> | undefined;
55
+ let unsubscribeDisplay: (() => void) | undefined;
56
+ let listenerGeneration = 0;
45
57
  const receipts = (entries: readonly unknown[]) => {
46
58
  const runs = new Map<string, Receipt>();
47
59
  let complete = true;
@@ -62,10 +74,11 @@ export function createCombinedSessionCost(pi: Pick<ExtensionAPI, "on" | "appendE
62
74
  }
63
75
  return { runs, complete };
64
76
  };
77
+ const active = () => !!scope && !!host && !!context && context.sessionManager.getSessionId() === scope.root;
65
78
  const total = () => {
66
79
  try {
67
- if (!scope || scope.ctx.sessionManager.getSessionId() !== scope.root) return { usd: 0, complete: false };
68
- const entries = scope.ctx.sessionManager.getEntries();
80
+ if (!active() || !scope || !context) return { usd: 0, complete: false };
81
+ const entries = context.sessionManager.getEntries();
69
82
  const own = recordedSessionCost(entries);
70
83
  const replay = receipts(entries);
71
84
  let complete = own.complete && replay.complete && scope.failed.size === 0;
@@ -85,65 +98,122 @@ export function createCombinedSessionCost(pi: Pick<ExtensionAPI, "on" | "appendE
85
98
  return { usd, complete };
86
99
  } catch { return { usd: 0, complete: false }; }
87
100
  };
88
- let displayTotal: ReturnType<typeof recordedSessionCost> | undefined;
89
- // Synchronous display-only query: native usage and durable receipts stay untouched.
90
- const unsubscribe = pi.events.on("pi-extended-teams:cost-request", (value) => {
91
- const request = object(value);
92
- if (scope && request.sessionId === scope.root && scope.ctx.sessionManager.getSessionId() === scope.root) {
93
- request.result = displayTotal;
94
- }
95
- });
96
101
  const refresh = () => {
97
- displayTotal = scope ? total() : undefined;
98
- try { pi.events.emit("pi-extended-teams:cost-changed", undefined); }
102
+ if (!host) {
103
+ displayTotal = undefined;
104
+ return;
105
+ }
106
+ displayTotal = total();
107
+ try { host.events.emit("pi-extended-teams:cost-changed", undefined); }
99
108
  catch { /* Display observers must not affect lifecycle cleanup. */ }
100
109
  };
101
- pi.on("session_start", (_event, ctx) => {
102
- scope = { root: ctx.sessionManager.getSessionId(), ctx, failed: new Set(), pending: new Map() };
103
- refresh();
104
- });
105
- pi.on("turn_end", refresh);
106
- pi.on("agent_end", refresh);
107
- pi.on("session_compact", refresh);
108
- pi.on("session_tree", refresh);
110
+ const append = (origin: Scope, key: string, receipt: Receipt) => {
111
+ if (!active() || scope !== origin || !host) return false;
112
+ try {
113
+ host.appendEntry(COST_ENTRY_TYPE, receipt);
114
+ origin.failed.delete(key);
115
+ origin.pending.delete(key);
116
+ refresh();
117
+ return true;
118
+ } catch {
119
+ // Keep the terminal receipt, not its pending predecessor, for reload retry.
120
+ origin.failed.add(key);
121
+ origin.pending.set(key, receipt);
122
+ refresh();
123
+ return false;
124
+ }
125
+ };
126
+ const listen = (nextHost: CostHost) => {
127
+ host = nextHost;
128
+ const generation = ++listenerGeneration;
129
+ unsubscribeDisplay = nextHost.events.on("pi-extended-teams:cost-request", (value) => {
130
+ const request = object(value);
131
+ if (generation === listenerGeneration && scope && context && request.sessionId === scope.root
132
+ && context.sessionManager.getSessionId() === scope.root) request.result = displayTotal;
133
+ });
134
+ nextHost.on("session_start", (_event, nextContext) => {
135
+ if (generation !== listenerGeneration) return;
136
+ const root = nextContext.sessionManager.getSessionId();
137
+ if (!scope || scope.root !== root) scope = { root, failed: new Set(), pending: new Map() };
138
+ context = nextContext;
139
+ refresh();
140
+ });
141
+ const refreshCurrent = () => { if (generation === listenerGeneration) refresh(); };
142
+ nextHost.on("turn_end", refreshCurrent);
143
+ nextHost.on("agent_end", refreshCurrent);
144
+ nextHost.on("session_compact", refreshCurrent);
145
+ nextHost.on("session_tree", refreshCurrent);
146
+ };
147
+ const flushFinalReceipts = () => {
148
+ if (!scope || !context) return;
149
+ const durable = receipts(context.sessionManager.getEntries()).runs;
150
+ for (const [key, receipt] of scope.pending) {
151
+ if (receipt.phase === "pending") continue;
152
+ const persisted = durable.get(key);
153
+ if (persisted && persisted.phase !== "pending") {
154
+ scope.pending.delete(key);
155
+ scope.failed.delete(key);
156
+ } else append(scope, key, receipt);
157
+ }
158
+ };
159
+ const detach = () => {
160
+ listenerGeneration++;
161
+ unsubscribeDisplay?.();
162
+ unsubscribeDisplay = undefined;
163
+ host = undefined;
164
+ context = undefined;
165
+ displayTotal = undefined;
166
+ };
167
+ listen(initialHost);
109
168
  return {
110
169
  total,
170
+ // The reload owner must pass the newly supplied context for this saved root; other roots are never adopted.
171
+ attach(nextHost: CostHost, nextContext: SessionCostContext) {
172
+ try {
173
+ if (!scope || scope.root !== nextContext.sessionManager.getSessionId()) return false;
174
+ } catch { return false; }
175
+ detach();
176
+ context = nextContext;
177
+ listen(nextHost);
178
+ try { flushFinalReceipts(); }
179
+ catch { refresh(); }
180
+ refresh();
181
+ return true;
182
+ },
183
+ detach,
111
184
  deactivate() {
185
+ detach();
112
186
  scope = undefined;
113
- unsubscribe();
114
- refresh();
115
187
  },
116
188
  begin(rootSessionId: string, teamName: string, lifecycleRunId: string): CostRun {
117
189
  const origin = scope;
118
190
  if (!origin || origin.root !== rootSessionId) return { settle() {}, exclude() {} };
119
191
  const pending: Receipt = { rootSessionId, teamName, lifecycleRunId, phase: "pending" };
120
192
  const key = identity(pending);
121
- const active = () => scope === origin && origin.ctx.sessionManager.getSessionId() === rootSessionId;
122
- const append = (receipt: Receipt) => {
123
- try {
124
- if (!active()) return false;
125
- pi.appendEntry(COST_ENTRY_TYPE, receipt);
126
- origin.failed.delete(key);
127
- origin.pending.delete(key);
128
- refresh();
129
- return true;
130
- } catch {
131
- origin.failed.add(key);
132
- origin.pending.set(key, pending);
133
- refresh();
134
- return false;
193
+ if (!origin.pending.has(key)) {
194
+ if (!context) origin.pending.set(key, pending);
195
+ else {
196
+ try {
197
+ if (!receipts(context.sessionManager.getEntries()).runs.has(key)) append(origin, key, pending);
198
+ } catch { origin.failed.add(key); origin.pending.set(key, pending); refresh(); }
135
199
  }
136
- };
137
- try {
138
- if (!receipts(origin.ctx.sessionManager.getEntries()).runs.has(key)) append(pending);
139
- } catch { origin.failed.add(key); origin.pending.set(key, pending); refresh(); }
200
+ }
140
201
  const finish = (receipt: Receipt) => {
202
+ if (scope !== origin) return;
203
+ const previous = origin.pending.get(key);
204
+ if (!active() || !context) {
205
+ if (!previous || previous.phase === "pending") {
206
+ origin.failed.add(key);
207
+ origin.pending.set(key, receipt);
208
+ }
209
+ return;
210
+ }
141
211
  try {
142
- if (!active()) return;
143
- const previous = origin.pending.get(key) ?? receipts(origin.ctx.sessionManager.getEntries()).runs.get(key);
144
- if (previous?.phase !== "pending") return;
145
- append(receipt);
146
- } catch { origin.failed.add(key); refresh(); }
212
+ const durable = receipts(context.sessionManager.getEntries()).runs.get(key);
213
+ const current = previous ?? durable;
214
+ if (current?.phase !== "pending") return;
215
+ append(origin, key, receipt);
216
+ } catch { origin.failed.add(key); origin.pending.set(key, receipt); refresh(); }
147
217
  };
148
218
  return {
149
219
  settle: (snapshot, outcome) => finish({ ...pending, phase: "final", outcome,
@@ -0,0 +1,51 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { StringEnum } from "../internal/schema";
3
+ import { ACCEPTED_FAVORITE_MODEL_SLOTS } from "../../src/utils/settings";
4
+ import { CheckPolicySchema } from "../../src/results/check-policy";
5
+ import { RepairPolicySchema } from "../../src/results/repair-policy";
6
+ import { CheckpointPolicySchema } from "../../src/results/checkpoint-policy";
7
+ import { CompletionGroupPolicySchema } from "../../src/results/completion-group";
8
+
9
+ const levelDescription = "Required intent tier. Configured favorites take priority; an unconfigured tier inherits the current lead model and thinking. Read tiers: read-collect gathers bounded facts without owning the conclusion; read-review is the normal default for focused review, verification, and bounded synthesis; read-analyze explains behavior or root cause across connected evidence; read-critical is only for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning. Write tiers: write-patch makes a narrow localized change; write-feature implements a bounded feature with a known design; write-system owns a cross-cutting integration or refactor within explicitly claimed files; write-critical is only for high-risk security, concurrency, recovery, migration, or data-integrity changes. Prefer canonical tiers; legacy reading-*/writing-* aliases remain accepted for this minor release. Do not pass role, model, or thinking directly; see README.md.";
10
+ const base = {
11
+ name: Type.Optional(Type.String({ description: "Stable display name. Defaults to a generated agent name." })),
12
+ prompt: Type.String({ description: "The agent's assignment, relevant prior context, evidence already gathered, constraints, and report shape." }),
13
+ cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the lead session cwd." })),
14
+ checks: Type.Optional(CheckPolicySchema),
15
+ repair: Type.Optional(RepairPolicySchema),
16
+ checkpoint: Type.Optional(CheckpointPolicySchema),
17
+ continue_from: Type.Optional(Type.String({ description: "Continue this checkpoint as a fresh recipient/run with the current assignment and tier. name is a prefix, not a recipient to reuse." })),
18
+ session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Optional filtered snapshot of the lead's active session branch. Use lazy only when omitted session history may materially affect the lane; the child reads it on demand rather than receiving transcript content in its prompt.", default: "none" })),
19
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
20
+ allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Opt in eligible depth-0 write-feature/write-critical agents to restricted read-only child spawning.", default: false })),
21
+ };
22
+
23
+ // Metadata is available before session_start without creating a second queue,
24
+ // lifecycle controller, or execution owner during host reload.
25
+ export const publicTeamToolMetadata = {
26
+ get_agent_status: {
27
+ name: "get_agent_status", label: "Get Agent Status",
28
+ description: "Get one read-only snapshot of one or all agents owned by this parent. Omit agent_name to inspect all. This call is allowed when current status is needed; it never waits, polls, stops, or changes agents. Do not call it repeatedly. Final reports arrive automatically and resume this agent.",
29
+ parameters: Type.Object({ agent_name: Type.Optional(Type.String({ description: "One owned agent to inspect. Omit to inspect all owned agents." })) }),
30
+ },
31
+ spawn_agent: {
32
+ name: "spawn_agent", label: "Spawn Agent",
33
+ description: "Spawn one agent by intent tier only. Configured favorites take priority; an unconfigured tier inherits the current lead model and thinking. Give it the relevant goal, decisions, prior attempts, inspected evidence, constraints, and expected delta rather than a context-free task. Use session_context=lazy only as an on-demand fallback when omitted session history may materially matter; it never replaces a good mission prompt. read-review is the normal read default; use read-collect for bounded fact gathering, read-analyze for connected explanation/root cause, and read-critical only for irreducible high-stakes reasoning. For edits, choose write-patch, write-feature, write-system, or the rare high-risk write-critical by scope and risk. After spawning, do not duplicate or take over its lane; work only on unrelated work, then end the turn so the automatic report can resume you. One get_agent_status snapshot is allowed when current status is needed; never sleep, busy-wait, repeatedly read inbox/status, or treat healthy silence as failure. Wait for the actual report before synthesizing; intervene only on a reported blocker/error, actual health failure, or explicit user cancellation. model_slot selects behavior, model, and thinking; do not pass role, model, or thinking directly.",
34
+ parameters: Type.Object({ ...base, model_slot: StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription, default: "read-review" }) }),
35
+ },
36
+ spawn_swarm_agents: {
37
+ name: "spawn_swarm_agents", label: "Spawn Swarm Agents",
38
+ description: "Spawn a batch by intent tier only. Configured favorites take priority; unconfigured tiers inherit the current lead model and thinking. Give each lane the relevant goal, decisions, prior attempts, inspected evidence, constraints, and expected delta. Use session_context=lazy selectively as an on-demand fallback, never instead of a good mission prompt. Use read-review as the normal default, read-collect for bounded collection lanes, read-analyze for connected explanation, and read-critical only for irreducible high-stakes reasoning; choose write-patch/feature/system/critical by edit scope and risk. Each spawned lane is delegation-locked: do not duplicate or take it over. After unrelated work is done, end the turn so automatic reports can resume you. One get_agent_status snapshot is allowed when current status is needed; never sleep, busy-wait, repeatedly read inbox/status, or intervene early. Synthesize only after actual reports; intervene only on blocker/error, actual failure, or explicit cancellation. Each agent gets model_slot directly or from defaults; do not pass role, model, or thinking directly.",
39
+ parameters: Type.Object({
40
+ defaults: Type.Optional(Type.Object({
41
+ cwd: Type.Optional(Type.String()), checks: Type.Optional(CheckPolicySchema), repair: Type.Optional(RepairPolicySchema),
42
+ checkpoint: Type.Optional(CheckpointPolicySchema), model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
43
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
44
+ session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Shared lazy session-reference policy.", default: "none" })),
45
+ allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Shared opt-in for eligible depth-0 write-feature/write-critical agents.", default: false })),
46
+ })),
47
+ completion_group: Type.Optional(CompletionGroupPolicySchema),
48
+ agents: Type.Array(Type.Object({ ...base, model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })) }), { description: "Agents to spawn as one batch. Each one must have model_slot directly or inherit it from defaults." }),
49
+ }),
50
+ },
51
+ };