pi-session-orchestrator 0.2.0

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/src/index.ts ADDED
@@ -0,0 +1,311 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { activeCanonicalBinding } from "./binding";
4
+ import type { OrchestrationEvent, PresenceStatus } from "./model";
5
+ import { appendBaselineContext, appendPendingContext, appendRoleContext, footerStatus, pendingAssignmentContext, roleContext } from "./prompt";
6
+ import { LocalStateStore } from "./state";
7
+ import { isCanonicalSessionId, registerOrchestratorTools } from "./tools";
8
+
9
+ const STATUS_KEY = "pi-session-orchestrator";
10
+ const EVENT_ENTRY_TYPE = "pi-session-orchestrator-event";
11
+ const EVENT_WIDGET_KEY = "pi-session-orchestrator-events";
12
+ const MAX_RENDERED_EVENTS = 8;
13
+
14
+ export function eventIndicatorLines(events: readonly OrchestrationEvent[]): string[] {
15
+ const lines = events.slice(0, MAX_RENDERED_EVENTS).map((event) => {
16
+ const action = event.action?.replaceAll("_", " ") ?? event.type.replaceAll("_", " ");
17
+ const summary = event.summary.replace(/[\r\n]+/g, " ").trim().slice(0, 120);
18
+ return `↳ orchestration · ${action}: ${summary}`;
19
+ });
20
+ if (events.length > MAX_RENDERED_EVENTS) lines.push(`↳ orchestration · ${events.length - MAX_RENDERED_EVENTS} more updates`);
21
+ return lines;
22
+ }
23
+
24
+ type EventPresentationContext = Pick<ExtensionContext, "ui" | "mode" | "hasUI" | "sessionManager">;
25
+ type RenderComponent = { render(width: number): string[]; invalidate(): void };
26
+ type EntryRendererRegistrar = (customType: string, renderer: (entry: { data?: unknown }, options: { expanded: boolean }, theme: { fg(color: string, text: string): string }) => RenderComponent | undefined) => void;
27
+ type EntryAppender = (customType: string, data?: unknown) => void;
28
+
29
+ function isEntryRendererRegistrar(value: unknown): value is EntryRendererRegistrar {
30
+ return typeof value === "function";
31
+ }
32
+
33
+ function isEntryAppender(value: unknown): value is EntryAppender {
34
+ return typeof value === "function";
35
+ }
36
+
37
+ function registerEventEntryRenderer(pi: ExtensionAPI): boolean {
38
+ const registrar = pi.registerEntryRenderer;
39
+ if (!isEntryRendererRegistrar(registrar)) return false;
40
+ registrar.call(pi, EVENT_ENTRY_TYPE, (entry, _options, theme) => {
41
+ const data = entry.data as { events?: unknown } | undefined;
42
+ const events = Array.isArray(data?.events) ? data.events.filter((event): event is OrchestrationEvent => (
43
+ typeof event === "object" && event !== null && typeof (event as OrchestrationEvent).summary === "string"
44
+ )) : [];
45
+ const lines = eventIndicatorLines(events);
46
+ return {
47
+ render: (width) => lines.map((line) => theme.fg("dim", line.slice(0, width))),
48
+ invalidate: () => undefined,
49
+ };
50
+ });
51
+ return true;
52
+ }
53
+
54
+ export function presentUnseenEvents(store: LocalStateStore, ctx: EventPresentationContext, pi?: ExtensionAPI): void {
55
+ // Test doubles and legacy callers may not expose Pi's run mode; do not consume a cursor without a render path.
56
+ if (ctx.mode === undefined && !ctx.hasUI) return;
57
+ let events: OrchestrationEvent[];
58
+ try {
59
+ events = store.consumeRelevantEvents(ctx.sessionManager.getSessionId(), MAX_RENDERED_EVENTS);
60
+ } catch {
61
+ return;
62
+ }
63
+ if (!events.length) return;
64
+
65
+ const lines = eventIndicatorLines(events);
66
+ const appender = pi?.appendEntry;
67
+ if (ctx.mode === "tui" && isEntryAppender(appender)) {
68
+ try {
69
+ appender.call(pi, EVENT_ENTRY_TYPE, { version: 1, events });
70
+ return;
71
+ } catch {
72
+ // Fall through to the supported widget/notification path.
73
+ }
74
+ }
75
+ if (ctx.mode === "tui") ctx.ui.setWidget(EVENT_WIDGET_KEY, lines, { placement: "aboveEditor" });
76
+ if (ctx.hasUI) ctx.ui.notify(`Orchestration: ${events.length} new update${events.length === 1 ? "" : "s"}.`, "info");
77
+ }
78
+ const COMMAND_HELP = `Usage:
79
+ /orchestrator coordinator
80
+ /orchestrator assign {"focusedSessionId":"...","role":"domain-coordinator|focused-session","objective":"...","allowedScope":"...","worktree":"optional target path"}
81
+ /orchestrator attach <assignment-id>
82
+ /orchestrator start
83
+ /orchestrator handoff {"outcome":"...","changedSurfaces":[],"checks":[],"risks":[]}
84
+ /orchestrator accept <assignment-id> [reason]
85
+ /orchestrator return <assignment-id> <reason>
86
+ /orchestrator decision {"summary":"...","options":["..."],"recommendation":"..."}
87
+ /orchestrator propose-scope-change {"requestedScope":"...","scopeRevision":1,"reason":"...","evidence":["..."]}
88
+ /orchestrator approve-scope-change {"assignmentId":"...","proposalId":"...","scopeRevision":1}
89
+ /orchestrator approve-scope-change-human {"proposalId":"...","scopeRevision":1}
90
+ /orchestrator reconcile-scope-change {"reconciliationId":"...","scopeRevision":2}
91
+ /orchestrator block <reason>
92
+ /orchestrator abandon <reason>
93
+ /orchestrator status`;
94
+
95
+ function sessionId(ctx: ExtensionCommandContext): string {
96
+ return ctx.sessionManager.getSessionId();
97
+ }
98
+
99
+ function objectArgument(args: string): Record<string, unknown> {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(args);
103
+ } catch {
104
+ throw new Error("Expected a valid JSON object");
105
+ }
106
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Expected a JSON object");
107
+ return parsed as Record<string, unknown>;
108
+ }
109
+
110
+ function text(value: unknown, field: string): string {
111
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${field} must be a non-empty string`);
112
+ return value.trim();
113
+ }
114
+
115
+ function strings(value: unknown, field: string): string[] {
116
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`${field} must be an array of strings`);
117
+ return value;
118
+ }
119
+
120
+ function integer(value: unknown, field: string): number {
121
+ if (!Number.isInteger(value) || (value as number) < 1) throw new Error(`${field} must be a positive integer`);
122
+ return value as number;
123
+ }
124
+
125
+ function assignmentRole(value: unknown): "domain-coordinator" | "focused-session" | undefined {
126
+ if (value === undefined) return undefined;
127
+ if (value === "domain-coordinator" || value === "focused-session") return value;
128
+ throw new Error("role must be domain-coordinator or focused-session");
129
+ }
130
+
131
+ export function presenceForSessionStart(reason: "startup" | "reload" | "new" | "resume" | "fork"): PresenceStatus {
132
+ return reason === "reload" ? "reloading" : "live";
133
+ }
134
+
135
+ export function presenceForSessionShutdown(reason: "quit" | "reload" | "new" | "resume" | "fork"): PresenceStatus {
136
+ return reason === "reload" ? "reloading" : "suspended";
137
+ }
138
+
139
+ function refreshStatus(store: LocalStateStore, ctx: { ui: { setStatus(key: string, text: string | undefined): void }; sessionManager: { getSessionId(): string } }): void {
140
+ try {
141
+ ctx.ui.setStatus(STATUS_KEY, footerStatus(store.read(), ctx.sessionManager.getSessionId()));
142
+ } catch {
143
+ // Corrupt or unavailable local state is deliberately unmanaged, never inferred.
144
+ ctx.ui.setStatus(STATUS_KEY, undefined);
145
+ }
146
+ }
147
+
148
+ export async function runCommand(store: LocalStateStore, args: string, ctx: ExtensionCommandContext,
149
+ present: (ctx: EventPresentationContext) => void = (current) => presentUnseenEvents(store, current)): Promise<void> {
150
+ const [action = "", ...rest] = args.trim().split(/\s+/);
151
+ const remainder = rest.join(" ").trim();
152
+ const currentSessionId = sessionId(ctx);
153
+
154
+ try {
155
+ switch (action) {
156
+ case "coordinator":
157
+ store.registerCoordinator(currentSessionId);
158
+ ctx.ui.notify("Coordinator role recorded locally.", "info");
159
+ break;
160
+ case "assign": {
161
+ const input = objectArgument(remainder);
162
+ const focusedSessionId = text(input.focusedSessionId, "focusedSessionId");
163
+ if (!isCanonicalSessionId(focusedSessionId)) throw new Error("assign requires a full canonical Pi session ID");
164
+ const assignment = store.createAssignment({
165
+ coordinatorSessionId: currentSessionId,
166
+ focusedSessionId,
167
+ targetRole: assignmentRole(input.role),
168
+ binding: activeCanonicalBinding(typeof input.worktree === "string" ? text(input.worktree, "worktree") : ctx.cwd),
169
+ objective: text(input.objective, "objective"),
170
+ allowedScope: text(input.allowedScope, "allowedScope"),
171
+ });
172
+ ctx.ui.notify(`Assignment ${assignment.id} created; the ${assignment.targetRole} must attach explicitly.`, "info");
173
+ break;
174
+ }
175
+ case "attach": {
176
+ const assignment = store.attach(text(remainder, "assignment ID"), currentSessionId, activeCanonicalBinding(ctx.cwd));
177
+ ctx.ui.notify(`Attached to assignment ${assignment.id}.`, "info");
178
+ break;
179
+ }
180
+ case "start": {
181
+ const assignment = store.activate(currentSessionId);
182
+ ctx.ui.notify(`Assignment ${assignment.id} is active.`, "info");
183
+ break;
184
+ }
185
+ case "handoff": {
186
+ const input = objectArgument(remainder);
187
+ const handoff = store.submitHandoff(currentSessionId, {
188
+ outcome: text(input.outcome, "outcome"),
189
+ artifactIdentity: typeof input.artifactIdentity === "string" ? input.artifactIdentity : undefined,
190
+ changedSurfaces: strings(input.changedSurfaces, "changedSurfaces"),
191
+ checks: strings(input.checks, "checks"),
192
+ risks: strings(input.risks, "risks"),
193
+ });
194
+ ctx.ui.notify(`Handoff ${handoff.id} recorded locally; it does not authorize delivery.`, "info");
195
+ break;
196
+ }
197
+ case "accept":
198
+ case "return": {
199
+ const [assignmentId = "", ...reasonParts] = remainder.split(/\s+/);
200
+ const reason = reasonParts.join(" ").trim();
201
+ if (action === "return" && !reason) throw new Error("A return reason is required");
202
+ store.acceptOrReturn(currentSessionId, text(assignmentId, "assignment ID"), action === "accept" ? "accepted" : "returned", reason || undefined);
203
+ ctx.ui.notify(`Handoff ${action === "accept" ? "accepted" : "returned"}.`, "info");
204
+ break;
205
+ }
206
+ case "decision": {
207
+ const input = objectArgument(remainder);
208
+ store.decide(currentSessionId, text(input.summary, "summary"), input);
209
+ ctx.ui.notify("Decision request recorded locally; delivery remains a pi-intercom concern.", "info");
210
+ break;
211
+ }
212
+ case "propose-scope-change": {
213
+ const input = objectArgument(remainder);
214
+ const proposal = store.proposeScopeChange(currentSessionId, {
215
+ baseScopeRevision: integer(input.scopeRevision, "scopeRevision"),
216
+ requestedScope: text(input.requestedScope, "requestedScope"),
217
+ reason: text(input.reason, "reason"),
218
+ evidence: strings(input.evidence, "evidence"),
219
+ });
220
+ ctx.ui.notify(`Scope-change proposal ${proposal.id} recorded at revision ${proposal.baseScopeRevision}.`, "info");
221
+ break;
222
+ }
223
+ case "approve-scope-change": {
224
+ const input = objectArgument(remainder);
225
+ const assignment = store.approveScopeChange(currentSessionId, text(input.assignmentId, "assignmentId"), text(input.proposalId, "proposalId"), integer(input.scopeRevision, "scopeRevision"));
226
+ ctx.ui.notify(`Scope change approved; assignment is now at revision ${assignment.scopeRevision ?? 1}.`, "info");
227
+ break;
228
+ }
229
+ case "approve-scope-change-human": {
230
+ const input = objectArgument(remainder);
231
+ const assignment = store.approveScopeChangeAsHuman(currentSessionId, text(input.proposalId, "proposalId"), integer(input.scopeRevision, "scopeRevision"));
232
+ ctx.ui.notify(`Direct human scope approval recorded at revision ${assignment.scopeRevision ?? 1}; coordinator reconciliation is required.`, "info");
233
+ break;
234
+ }
235
+ case "reconcile-scope-change": {
236
+ const input = objectArgument(remainder);
237
+ store.reconcileScopeChange(currentSessionId, text(input.reconciliationId, "reconciliationId"), integer(input.scopeRevision, "scopeRevision"));
238
+ ctx.ui.notify("Direct-human scope change reconciled without a second approval.", "info");
239
+ break;
240
+ }
241
+ case "block":
242
+ case "abandon":
243
+ store.markTerminal(currentSessionId, action === "block" ? "blocked" : "abandoned", text(remainder, "reason"));
244
+ ctx.ui.notify(`Assignment marked ${action === "block" ? "blocked" : "abandoned"}.`, "info");
245
+ break;
246
+ case "status": {
247
+ const role = store.getRole(currentSessionId);
248
+ ctx.ui.notify(role.kind === "unmanaged" ? "Unmanaged session." : `${role.kind} session: ${currentSessionId}`, "info");
249
+ break;
250
+ }
251
+ default:
252
+ ctx.ui.notify(COMMAND_HELP, "info");
253
+ return;
254
+ }
255
+ present(ctx);
256
+ refreshStatus(store, ctx);
257
+ } catch (error: unknown) {
258
+ ctx.ui.notify(error instanceof Error ? error.message : "Orchestration command failed", "error");
259
+ refreshStatus(store, ctx);
260
+ }
261
+ }
262
+
263
+ const DISCOVERABLE_ACTIONS = [
264
+ "coordinator", "assign", "attach", "start", "handoff", "accept", "return", "decision", "propose-scope-change", "approve-scope-change", "approve-scope-change-human", "reconcile-scope-change", "block", "abandon", "status",
265
+ ] as const;
266
+
267
+ function registerDiscoverableAliases(pi: ExtensionAPI, store: LocalStateStore,
268
+ present: (ctx: EventPresentationContext) => void): void {
269
+ for (const action of DISCOVERABLE_ACTIONS) {
270
+ pi.registerCommand(`orchestrator:${action}`, {
271
+ description: `Run /orchestrator ${action}`,
272
+ handler: (args, ctx) => runCommand(store, `${action} ${args}`.trim(), ctx, present),
273
+ });
274
+ }
275
+ }
276
+
277
+ export default function sessionOrchestratorExtension(pi: ExtensionAPI, store = new LocalStateStore()): void {
278
+ const entryRendererAvailable = registerEventEntryRenderer(pi);
279
+ const present = (ctx: EventPresentationContext) => presentUnseenEvents(store, ctx, entryRendererAvailable ? pi : undefined);
280
+
281
+ pi.registerCommand("orchestrator", {
282
+ description: "Explicit durable coordinator/focused assignment commands",
283
+ handler: (args, ctx) => runCommand(store, args, ctx, present),
284
+ });
285
+ registerDiscoverableAliases(pi, store, present);
286
+ registerOrchestratorTools(pi, store, present);
287
+
288
+ pi.on("session_start", (event, ctx) => {
289
+ store.observePresence(ctx.sessionManager.getSessionId(), presenceForSessionStart(event.reason));
290
+ present(ctx);
291
+ refreshStatus(store, ctx);
292
+ });
293
+ pi.on("session_shutdown", (event, ctx) => {
294
+ store.observePresence(ctx.sessionManager.getSessionId(), presenceForSessionShutdown(event.reason), event.reason);
295
+ present(ctx);
296
+ });
297
+ pi.on("before_agent_start", (_event, ctx) => {
298
+ store.observePresence(ctx.sessionManager.getSessionId(), "live");
299
+ present(ctx);
300
+ refreshStatus(store, ctx);
301
+ try {
302
+ const state = store.read();
303
+ const section = roleContext(state, ctx.sessionManager.getSessionId());
304
+ const pending = pendingAssignmentContext(state, ctx.sessionManager.getSessionId());
305
+ const systemPrompt = appendRoleContext(appendPendingContext(appendBaselineContext(ctx.getSystemPrompt()), pending), section);
306
+ return systemPrompt === ctx.getSystemPrompt() ? undefined : { systemPrompt };
307
+ } catch {
308
+ return undefined;
309
+ }
310
+ });
311
+ }
package/src/model.ts ADDED
@@ -0,0 +1,164 @@
1
+ export const ASSIGNMENT_STATUSES = [
2
+ "created",
3
+ "attached",
4
+ "active",
5
+ "handoff-submitted",
6
+ "accepted",
7
+ "returned",
8
+ "blocked",
9
+ "abandoned",
10
+ ] as const;
11
+
12
+ export type AssignmentStatus = (typeof ASSIGNMENT_STATUSES)[number];
13
+ export type EventType = "progress" | "blocker" | "decision_request" | "handoff" | "review_result";
14
+ export type OrchestrationEventAction =
15
+ | "coordinator_registered"
16
+ | "assignment_created"
17
+ | "assignment_attached"
18
+ | "assignment_started"
19
+ | "handoff_submitted"
20
+ | "handoff_accepted"
21
+ | "handoff_returned"
22
+ | "assignment_blocked"
23
+ | "assignment_abandoned"
24
+ | "decision_requested"
25
+ | "scope_change_proposed"
26
+ | "scope_change_approved"
27
+ | "scope_change_human_approved"
28
+ | "scope_change_reconciled"
29
+ | "presence_changed";
30
+ export type AssignmentRole = "domain-coordinator" | "focused-session";
31
+
32
+ export interface CanonicalBinding {
33
+ repository: string;
34
+ worktree: string;
35
+ }
36
+
37
+ export interface Assignment {
38
+ id: string;
39
+ coordinatorSessionId: string;
40
+ focusedSessionId: string;
41
+ binding: CanonicalBinding;
42
+ objective: string;
43
+ allowedScope: string;
44
+ /** Revision of the durable allowed scope; legacy assignments hydrate as revision 1. */
45
+ scopeRevision?: number;
46
+ targetRole?: AssignmentRole;
47
+ status: AssignmentStatus;
48
+ createdAt: string;
49
+ updatedAt: string;
50
+ }
51
+
52
+ export interface HandoffRecord {
53
+ id: string;
54
+ assignmentId: string;
55
+ submittedAt: string;
56
+ outcome: string;
57
+ artifactIdentity?: string;
58
+ changedSurfaces: string[];
59
+ checks: string[];
60
+ risks: string[];
61
+ }
62
+
63
+ export interface OrchestrationEvent {
64
+ id: string;
65
+ /** Empty for coordinator registration or an unassigned presence observation. */
66
+ assignmentId: string;
67
+ /** Set for session-scoped events such as registration and presence. */
68
+ sessionId?: string;
69
+ type: EventType;
70
+ /** Explicit mutation represented by this event; absent on legacy state entries. */
71
+ action?: OrchestrationEventAction;
72
+ /** Monotonic local ordering; absent on legacy state entries. */
73
+ sequence?: number;
74
+ createdAt: string;
75
+ summary: string;
76
+ nextAction?: string;
77
+ details?: Record<string, unknown>;
78
+ }
79
+
80
+ export interface EventCursor {
81
+ lastSequence: number;
82
+ eventIds: string[];
83
+ }
84
+
85
+ export type ScopeChangeProposalStatus = "pending" | "approved";
86
+ export type ScopeChangeApprovalKind = "coordinator" | "direct-human";
87
+
88
+ export interface ScopeChangeProposal {
89
+ id: string;
90
+ assignmentId: string;
91
+ requesterSessionId: string;
92
+ baseScopeRevision: number;
93
+ requestedScope: string;
94
+ reason: string;
95
+ evidence: string[];
96
+ status: ScopeChangeProposalStatus;
97
+ createdAt: string;
98
+ updatedAt: string;
99
+ approvalKind?: ScopeChangeApprovalKind;
100
+ approvedBy?: string;
101
+ }
102
+
103
+ export interface ScopeReconciliation {
104
+ id: string;
105
+ assignmentId: string;
106
+ scopeRevision: number;
107
+ requestedScope: string;
108
+ reason: string;
109
+ evidence: string[];
110
+ status: "pending" | "reconciled";
111
+ createdAt: string;
112
+ updatedAt: string;
113
+ }
114
+
115
+ /** Versioned extension state; the containing field remains optional for legacy files. */
116
+ export interface ScopeChangeState {
117
+ version: 1;
118
+ proposals: ScopeChangeProposal[];
119
+ reconciliations: ScopeReconciliation[];
120
+ }
121
+
122
+ export type PresenceStatus = "live" | "reloading" | "suspended";
123
+
124
+ export interface SessionPresence {
125
+ sessionId: string;
126
+ status: PresenceStatus;
127
+ lastSeenAt: string;
128
+ lastShutdownReason?: string;
129
+ }
130
+
131
+ export interface DurableState {
132
+ version: 1;
133
+ coordinators: string[];
134
+ assignments: Assignment[];
135
+ handoffs: HandoffRecord[];
136
+ events: OrchestrationEvent[];
137
+ presence?: SessionPresence[];
138
+ /** Optional fields keep state files written before event indicators readable. */
139
+ nextEventSequence?: number;
140
+ eventCursors?: Record<string, EventCursor>;
141
+ /** Hydrated on read for legacy state files and written after scope changes are used. */
142
+ scopeChanges?: ScopeChangeState;
143
+ }
144
+
145
+ export type SessionRole =
146
+ | { kind: "root-coordinator" }
147
+ | { kind: "domain-coordinator"; assignment: Assignment }
148
+ | { kind: "focused-session"; assignment: Assignment }
149
+ | { kind: "unmanaged" };
150
+
151
+ export const EMPTY_STATE: DurableState = {
152
+ version: 1,
153
+ coordinators: [],
154
+ assignments: [],
155
+ handoffs: [],
156
+ events: [],
157
+ presence: [],
158
+ eventCursors: {},
159
+ scopeChanges: { version: 1, proposals: [], reconciliations: [] },
160
+ };
161
+
162
+ export function isTerminal(status: AssignmentStatus): boolean {
163
+ return ["accepted", "returned", "blocked", "abandoned"].includes(status);
164
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { Assignment, DurableState, SessionRole } from "./model";
2
+
3
+ export const BASELINE_CONTEXT_MARKER = "## pi-session-orchestrator baseline";
4
+ export const ROLE_CONTEXT_MARKER = "## pi-session-orchestrator role context";
5
+ export const PENDING_CONTEXT_MARKER = "## pi-session-orchestrator pending assignment";
6
+
7
+ export function baselineContext(): string {
8
+ return `${BASELINE_CONTEXT_MARKER}
9
+ This extension supports explicit, durable coordination among existing Pi sessions. Small, known work may remain inline. Use orchestrator_status to inspect state and orchestrator_update to record transitions. Do not infer roles from conversation, repository topology, or session creation: orchestration or delegation requires an explicit durable assignment. When work becomes larger than its bounded scope, use fast focused delegation after recording that assignment. Keep coordinator topics with coordinators and focused implementation detail with focused sessions; do not dump either across that boundary. Session creation and pi-intercom messaging remain separate concerns; this extension neither creates sessions nor transfers authority or delivery authority.`;
10
+ }
11
+
12
+ export function appendBaselineContext(systemPrompt: string): string {
13
+ if (systemPrompt.includes(BASELINE_CONTEXT_MARKER)) return systemPrompt;
14
+ return `${systemPrompt}\n\n${baselineContext()}`;
15
+ }
16
+
17
+ function activeAssignments(state: DurableState, coordinatorSessionId: string): Assignment[] {
18
+ return state.assignments.filter((assignment) => assignment.coordinatorSessionId === coordinatorSessionId
19
+ && ["created", "attached", "active", "handoff-submitted"].includes(assignment.status));
20
+ }
21
+
22
+ function scopeRevision(assignment: Assignment): number {
23
+ return assignment.scopeRevision ?? 1;
24
+ }
25
+
26
+ function scopeObligations(state: DurableState, assignmentIds: Set<string>): string {
27
+ const scopeChanges = state.scopeChanges ?? { version: 1 as const, proposals: [], reconciliations: [] };
28
+ const proposals = scopeChanges.proposals.filter((proposal) => assignmentIds.has(proposal.assignmentId) && proposal.status === "pending");
29
+ const reconciliations = scopeChanges.reconciliations.filter((reconciliation) => assignmentIds.has(reconciliation.assignmentId) && reconciliation.status === "pending");
30
+ const parts: string[] = [];
31
+ if (proposals.length) parts.push(`pending proposals: ${proposals.map((proposal) => `${proposal.id} (revision ${proposal.baseScopeRevision})`).join(", ")}`);
32
+ if (reconciliations.length) parts.push(`reconciliation required: ${reconciliations.map((item) => `${item.id} (revision ${item.scopeRevision})`).join(", ")}`);
33
+ return parts.join("; ") || "none";
34
+ }
35
+
36
+ export function roleForState(state: DurableState, sessionId: string): SessionRole {
37
+ const assignment = [...state.assignments].reverse().find((item) => item.focusedSessionId === sessionId
38
+ && item.status !== "created");
39
+ if (assignment?.targetRole === "domain-coordinator") return { kind: "domain-coordinator", assignment };
40
+ if (assignment) return { kind: "focused-session", assignment };
41
+ return state.coordinators.includes(sessionId) ? { kind: "root-coordinator" } : { kind: "unmanaged" };
42
+ }
43
+
44
+ function childBuckets(state: DurableState, coordinatorSessionId: string): Record<string, number> {
45
+ const buckets: Record<string, number> = { children: 0, working: 0, ready: 0, pending: 0, attention: 0 };
46
+ for (const assignment of state.assignments.filter((item) => item.coordinatorSessionId === coordinatorSessionId)) {
47
+ if (assignment.targetRole === "domain-coordinator") {
48
+ const nested = childBuckets(state, assignment.focusedSessionId);
49
+ for (const [key, value] of Object.entries(nested)) buckets[key] += value;
50
+ continue;
51
+ }
52
+ if (["accepted", "returned", "abandoned"].includes(assignment.status)) continue;
53
+ buckets.children += 1;
54
+ if (assignment.status === "active") buckets.working += 1;
55
+ else if (assignment.status === "attached") buckets.ready += 1;
56
+ else if (assignment.status === "created") buckets.pending += 1;
57
+ else buckets.attention += 1;
58
+ }
59
+ return buckets;
60
+ }
61
+
62
+ export function pendingAssignmentContext(state: DurableState, sessionId: string): string | undefined {
63
+ const assignment = state.assignments.find((item) => item.focusedSessionId === sessionId && item.status === "created");
64
+ return assignment ? `${PENDING_CONTEXT_MARKER}
65
+ A pending ${assignment.targetRole ?? "focused-session"} assignment awaits your explicit attachment. Coordinator ID: ${assignment.coordinatorSessionId}. Objective: ${assignment.objective}. Validate your binding, then use orchestrator_update with operation attach and assignmentId ${assignment.id}.` : undefined;
66
+ }
67
+
68
+ export function appendPendingContext(systemPrompt: string, section: string | undefined): string {
69
+ if (!section || systemPrompt.includes(PENDING_CONTEXT_MARKER)) return systemPrompt;
70
+ return `${systemPrompt}\n\n${section}`;
71
+ }
72
+
73
+ export function roleContext(state: DurableState, sessionId: string): string | undefined {
74
+ const role = roleForState(state, sessionId);
75
+ if (role.kind === "unmanaged") return undefined;
76
+
77
+ if (role.kind === "root-coordinator" || role.kind === "domain-coordinator") {
78
+ const assignments = activeAssignments(state, sessionId)
79
+ .map((assignment) => `- ${assignment.id}: ${assignment.targetRole ?? "focused-session"} ${assignment.focusedSessionId}; ${assignment.status}; revision ${scopeRevision(assignment)}; ${assignment.objective}`)
80
+ .join("\n") || "- none";
81
+ const assignmentIds = new Set(activeAssignments(state, sessionId).map((assignment) => assignment.id));
82
+ const obligations = scopeObligations(state, assignmentIds);
83
+ const roleName = role.kind === "root-coordinator" ? "root coordinator" : "domain coordinator";
84
+ return `${ROLE_CONTEXT_MARKER}
85
+ Role: ${roleName}. Session ID: ${sessionId}.
86
+ Active assignment summaries:
87
+ ${assignments}
88
+ Route cross-session decisions, preserve ownership boundaries, and explicitly accept or return handoffs. Scope-change obligations: ${obligations}. Approve only current proposals within the assigned boundary; reconcile direct-human changes without a second approval.
89
+ Keep overarching coordination topics here; do not push them into focused-session context or pull focused implementation detail into this context. This does not replace the governing orchestration framework or transfer authority.`;
90
+ }
91
+
92
+ const { assignment } = role;
93
+ const obligations = scopeObligations(state, new Set([assignment.id]));
94
+ return `${ROLE_CONTEXT_MARKER}
95
+ Role: focused session. Coordinator ID: ${assignment.coordinatorSessionId}. Assignment: ${assignment.id} (${assignment.status}).
96
+ Objective: ${assignment.objective}
97
+ Allowed scope: ${assignment.allowedScope}
98
+ Scope revision: ${scopeRevision(assignment)}
99
+ Scope-change obligations: ${obligations}
100
+ Binding: repository ${assignment.binding.repository}; worktree ${assignment.binding.worktree}.
101
+ Stay within scope; independently report blockers, dependencies, scope changes, and decisions. If the issue grows beyond this bounded scope, promptly ask the coordinator for explicit re-scoping or delegation; use orchestrator_update operation propose-scope-change with the current scopeRevision, a narrow requestedScope, reason, and evidence; do not absorb the change silently. Keep focused implementation detail here; do not take on overarching coordinator topics. Handoff format: outcome, artifact identity, changed surfaces, checks, risks. A handoff never authorizes delivery.`;
102
+ }
103
+
104
+ export function appendRoleContext(systemPrompt: string, section: string | undefined): string {
105
+ if (!section || systemPrompt.includes(ROLE_CONTEXT_MARKER)) return systemPrompt;
106
+ return `${systemPrompt}\n\n${section}`;
107
+ }
108
+
109
+ export function footerStatus(state: DurableState, sessionId: string): string | undefined {
110
+ const role = roleForState(state, sessionId);
111
+ if (role.kind === "unmanaged") return pendingAssignmentContext(state, sessionId) ? "⏳ Pending focused assignment" : undefined;
112
+ if (role.kind === "focused-session") return "🎯 Focused session";
113
+ const buckets = childBuckets(state, sessionId);
114
+ const label = role.kind === "root-coordinator" ? "Root" : "Domain";
115
+ const parts = [`🧭 ${label} · ${buckets.children} children`];
116
+ for (const key of ["working", "ready", "pending", "attention"] as const) if (buckets[key]) parts.push(`${buckets[key]} ${key}`);
117
+ return parts.join(" · ");
118
+ }