pi-famulus 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createComms (design doc §4.7): parent↔child messaging on top of a CommsHost.
|
|
3
|
+
*
|
|
4
|
+
* Supervisor notifications are self-contained here (wiring matrix: comms owns
|
|
5
|
+
* its XML formats; nothing is added to src/format.ts).
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
DECISION_TIMEOUT_MESSAGE,
|
|
9
|
+
Mailbox,
|
|
10
|
+
type MailboxOptions,
|
|
11
|
+
} from "./mailbox";
|
|
12
|
+
import type { Comms, CommsHost } from "./types";
|
|
13
|
+
import { formatFamulusWake, type FormattedWake } from "../wake";
|
|
14
|
+
import type { Clock } from "../clock";
|
|
15
|
+
|
|
16
|
+
/** Ring bucket used when the host does not know the child (defensive fallback). */
|
|
17
|
+
export const UNKNOWN_RUN_ID = "unknown";
|
|
18
|
+
|
|
19
|
+
/** Fire-and-forget progress notification shown to the parent agent. */
|
|
20
|
+
export function formatSupervisorUpdate(
|
|
21
|
+
from: { childId: string; name: string },
|
|
22
|
+
message: string,
|
|
23
|
+
): FormattedWake {
|
|
24
|
+
return formatFamulusWake({
|
|
25
|
+
kind: "supervisor-update",
|
|
26
|
+
from: from.childId,
|
|
27
|
+
name: from.name,
|
|
28
|
+
message,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Blocking decision request; the reply recipe is a <reply-with> child, not message text. */
|
|
33
|
+
export function formatSupervisorRequest(
|
|
34
|
+
from: { childId: string; name: string },
|
|
35
|
+
message: string,
|
|
36
|
+
): FormattedWake {
|
|
37
|
+
return formatFamulusWake({
|
|
38
|
+
kind: "supervisor-request",
|
|
39
|
+
from: from.childId,
|
|
40
|
+
name: from.name,
|
|
41
|
+
message,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Appendix B `createComms(host)`. The returned object is a `Comms`; the
|
|
47
|
+
* widened `CommsWithOrigin` return type additionally lets callers attribute
|
|
48
|
+
* `send`/`reply` mailbox entries to a child origin (used by the agent_message
|
|
49
|
+
* tool for child senders). Structurally assignable to `Comms`.
|
|
50
|
+
*/
|
|
51
|
+
export interface CommsWithOrigin extends Comms {
|
|
52
|
+
reply(toChildId: string, message: string, from?: string): void;
|
|
53
|
+
send(
|
|
54
|
+
toChildId: string,
|
|
55
|
+
message: string,
|
|
56
|
+
delivery: "steer" | "queue",
|
|
57
|
+
from?: string,
|
|
58
|
+
): Promise<void>;
|
|
59
|
+
/** Cancel pending decision waiters and timers (session shutdown). */
|
|
60
|
+
dispose(): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CommsOptions {
|
|
64
|
+
/** Pre-built mailbox (tests); otherwise one is created from the options below. */
|
|
65
|
+
mailbox?: Mailbox;
|
|
66
|
+
decisionTimeoutMs?: MailboxOptions["decisionTimeoutMs"];
|
|
67
|
+
clock?: Clock;
|
|
68
|
+
logEvent?: (type: string, fields?: Record<string, unknown>) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createComms(host: CommsHost, options: CommsOptions = {}): CommsWithOrigin {
|
|
72
|
+
const mailbox =
|
|
73
|
+
options.mailbox ??
|
|
74
|
+
new Mailbox({ decisionTimeoutMs: options.decisionTimeoutMs, clock: options.clock });
|
|
75
|
+
|
|
76
|
+
function runIdOf(childId: string): string {
|
|
77
|
+
return host.getChild(childId)?.runId ?? UNKNOWN_RUN_ID;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async contactSupervisor(fromChildId, reason, message) {
|
|
82
|
+
const child = host.getChild(fromChildId);
|
|
83
|
+
const runId = child?.runId ?? UNKNOWN_RUN_ID;
|
|
84
|
+
const name = child?.name ?? fromChildId;
|
|
85
|
+
const entry = mailbox.append(runId, {
|
|
86
|
+
from: fromChildId,
|
|
87
|
+
to: "supervisor",
|
|
88
|
+
kind: reason,
|
|
89
|
+
message,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (reason === "progress_update") {
|
|
93
|
+
host.notifySupervisor(formatSupervisorUpdate({ childId: fromChildId, name }, message));
|
|
94
|
+
return "ok";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// need_decision: register the per-child waiter BEFORE notifying, so a
|
|
98
|
+
// supervisor that replies synchronously still resolves correctly.
|
|
99
|
+
const wait = mailbox.beginDecision(fromChildId, name, message);
|
|
100
|
+
options.logEvent?.("decision.request", { child_id: fromChildId });
|
|
101
|
+
const stall = child?.handle as { pauseStall?: () => void; resumeStall?: () => void } | undefined;
|
|
102
|
+
stall?.pauseStall?.();
|
|
103
|
+
host.notifySupervisor(formatSupervisorRequest({ childId: fromChildId, name }, message));
|
|
104
|
+
try {
|
|
105
|
+
const replyText = await wait;
|
|
106
|
+
if (replyText === DECISION_TIMEOUT_MESSAGE) {
|
|
107
|
+
options.logEvent?.("decision.timeout", { child_id: fromChildId });
|
|
108
|
+
}
|
|
109
|
+
entry.reply = replyText;
|
|
110
|
+
return replyText;
|
|
111
|
+
} finally {
|
|
112
|
+
stall?.resumeStall?.();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
reply(toChildId, message, from = "supervisor") {
|
|
117
|
+
if (!mailbox.resolveDecision(toChildId, message)) {
|
|
118
|
+
const pending = mailbox.pendingRequests();
|
|
119
|
+
const listing =
|
|
120
|
+
pending.length === 0
|
|
121
|
+
? "none"
|
|
122
|
+
: pending.map((p) => `${p.childId} (${p.name})`).join(", ");
|
|
123
|
+
throw new Error(
|
|
124
|
+
`No pending decision request from ${toChildId}. Pending requests: ${listing}.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
mailbox.append(runIdOf(toChildId), { from, to: toChildId, kind: "reply", message });
|
|
128
|
+
options.logEvent?.("decision.reply", { child_id: toChildId });
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async send(toChildId, message, delivery, from = "supervisor") {
|
|
132
|
+
const child = host.getChild(toChildId);
|
|
133
|
+
if (!child) {
|
|
134
|
+
const known =
|
|
135
|
+
host.listChildren().map((c) => `${c.childId} (${c.name})`).join(", ") || "none";
|
|
136
|
+
throw new Error(`Unknown child "${toChildId}". Known children: ${known}.`);
|
|
137
|
+
}
|
|
138
|
+
if (child.status === "running") {
|
|
139
|
+
if (delivery === "steer") {
|
|
140
|
+
await child.handle.steer(message);
|
|
141
|
+
} else {
|
|
142
|
+
await child.handle.followUp(message);
|
|
143
|
+
}
|
|
144
|
+
} else if (
|
|
145
|
+
child.status === "completed" ||
|
|
146
|
+
child.status === "failed" ||
|
|
147
|
+
child.status === "interrupted"
|
|
148
|
+
) {
|
|
149
|
+
// Lifecycle (resume) belongs to the subagent tool. Resuming here never
|
|
150
|
+
// wired a completion wake, so the parent hung.
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Child ${toChildId} (${child.name}) has finished (${child.status}). ` +
|
|
153
|
+
`agent_message does not resume children. ` +
|
|
154
|
+
`Use subagent({ action: "resume", run_id: "${child.runId}", child_id: "${toChildId}", message: "..." }).`,
|
|
155
|
+
);
|
|
156
|
+
} else {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Child ${toChildId} (${child.name}) is ${child.status}; ` +
|
|
159
|
+
"messages can only be delivered to running or finished children.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
mailbox.append(child.runId, { from, to: toChildId, kind: "send", message });
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async broadcast(runId, message, fromChildId) {
|
|
166
|
+
const from = fromChildId ?? "supervisor";
|
|
167
|
+
const targets = host
|
|
168
|
+
.listChildren()
|
|
169
|
+
.filter(
|
|
170
|
+
(c) => c.runId === runId && c.status === "running" && c.childId !== fromChildId,
|
|
171
|
+
);
|
|
172
|
+
const delivered: string[] = [];
|
|
173
|
+
for (const target of targets) {
|
|
174
|
+
const child = host.getChild(target.childId);
|
|
175
|
+
if (!child) continue;
|
|
176
|
+
try {
|
|
177
|
+
await child.handle.steer(message);
|
|
178
|
+
} catch {
|
|
179
|
+
continue; // best-effort: only successfully steered children are reported
|
|
180
|
+
}
|
|
181
|
+
delivered.push(target.childId);
|
|
182
|
+
mailbox.append(runId, { from, to: target.childId, kind: "broadcast", message });
|
|
183
|
+
}
|
|
184
|
+
return delivered;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
pendingRequests() {
|
|
188
|
+
return mailbox.pendingRequests();
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
log(runId, limit) {
|
|
192
|
+
return mailbox.log(runId, limit);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
dispose() {
|
|
196
|
+
mailbox.dispose();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mailbox (design doc §4.7): per-run ring log of all comms traffic plus
|
|
3
|
+
* per-child `need_decision` waiters.
|
|
4
|
+
*
|
|
5
|
+
* Anti-global-lock (pi-intercom lesson): every child gets its own waiter
|
|
6
|
+
* entry with its own promise and its own timeout timer — children never
|
|
7
|
+
* queue behind each other.
|
|
8
|
+
*/
|
|
9
|
+
import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
10
|
+
import type { MailboxEntry } from "./types";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAILBOX_CAPACITY = 200;
|
|
13
|
+
export const DEFAULT_LOG_LIMIT = 20;
|
|
14
|
+
export const DEFAULT_DECISION_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
15
|
+
|
|
16
|
+
/** Exact contract text (§4.7) returned to a child whose decision request times out. */
|
|
17
|
+
export const DECISION_TIMEOUT_MESSAGE =
|
|
18
|
+
"Supervisor did not respond within 10 minutes; decide yourself and continue.";
|
|
19
|
+
|
|
20
|
+
export interface PendingDecisionRequest {
|
|
21
|
+
childId: string;
|
|
22
|
+
name: string;
|
|
23
|
+
message: string;
|
|
24
|
+
sinceMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Waiter {
|
|
28
|
+
name: string;
|
|
29
|
+
message: string;
|
|
30
|
+
sinceMs: number;
|
|
31
|
+
resolve: (reply: string) => void;
|
|
32
|
+
timer: ClockTimer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MailboxOptions {
|
|
36
|
+
/** Ring capacity per run. Default 200. */
|
|
37
|
+
capacity?: number;
|
|
38
|
+
/** need_decision timeout. Default 600_000 (10 min). */
|
|
39
|
+
decisionTimeoutMs?: number;
|
|
40
|
+
clock?: Clock;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class Mailbox {
|
|
44
|
+
private readonly capacity: number;
|
|
45
|
+
private readonly decisionTimeoutMs: number;
|
|
46
|
+
private readonly clock: Clock;
|
|
47
|
+
private readonly rings = new Map<string, MailboxEntry[]>();
|
|
48
|
+
private readonly waiters = new Map<string, Waiter>();
|
|
49
|
+
|
|
50
|
+
constructor(options: MailboxOptions = {}) {
|
|
51
|
+
this.capacity = options.capacity ?? DEFAULT_MAILBOX_CAPACITY;
|
|
52
|
+
this.decisionTimeoutMs = options.decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
|
|
53
|
+
this.clock = options.clock ?? realClock;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Append an entry to a run's ring (oldest entries dropped past capacity).
|
|
58
|
+
* Returns the stored entry; callers may later set `entry.reply` — the ring
|
|
59
|
+
* holds the same object reference.
|
|
60
|
+
*/
|
|
61
|
+
append(runId: string, entry: Omit<MailboxEntry, "ts"> & { ts?: number }): MailboxEntry {
|
|
62
|
+
const full: MailboxEntry = {
|
|
63
|
+
ts: entry.ts ?? this.clock.now(),
|
|
64
|
+
from: entry.from,
|
|
65
|
+
to: entry.to,
|
|
66
|
+
kind: entry.kind,
|
|
67
|
+
message: entry.message,
|
|
68
|
+
};
|
|
69
|
+
if (entry.reply !== undefined) full.reply = entry.reply;
|
|
70
|
+
let ring = this.rings.get(runId);
|
|
71
|
+
if (!ring) {
|
|
72
|
+
ring = [];
|
|
73
|
+
this.rings.set(runId, ring);
|
|
74
|
+
}
|
|
75
|
+
ring.push(full);
|
|
76
|
+
if (ring.length > this.capacity) {
|
|
77
|
+
ring.splice(0, ring.length - this.capacity);
|
|
78
|
+
}
|
|
79
|
+
return full;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Last `limit` entries of a run, chronological order. Default 20. */
|
|
83
|
+
log(runId: string, limit: number = DEFAULT_LOG_LIMIT): MailboxEntry[] {
|
|
84
|
+
const ring = this.rings.get(runId) ?? [];
|
|
85
|
+
if (limit <= 0) return [];
|
|
86
|
+
return ring.slice(Math.max(0, ring.length - limit));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
hasPendingDecision(childId: string): boolean {
|
|
90
|
+
return this.waiters.has(childId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Register this child's need_decision waiter and return the promise that
|
|
95
|
+
* resolves with the supervisor's reply (or DECISION_TIMEOUT_MESSAGE after
|
|
96
|
+
* the timeout). Independent per child — no global lock.
|
|
97
|
+
*
|
|
98
|
+
* Throws if the child already has a pending request (one at a time).
|
|
99
|
+
*/
|
|
100
|
+
beginDecision(childId: string, name: string, message: string): Promise<string> {
|
|
101
|
+
if (this.waiters.has(childId)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Child ${childId} already has a pending decision request; ` +
|
|
104
|
+
"wait for the supervisor's reply before asking again.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
let resolveFn!: (reply: string) => void;
|
|
108
|
+
const promise = new Promise<string>((res) => {
|
|
109
|
+
resolveFn = res;
|
|
110
|
+
});
|
|
111
|
+
const timer = this.clock.setTimeout(() => {
|
|
112
|
+
if (this.waiters.delete(childId)) {
|
|
113
|
+
resolveFn(DECISION_TIMEOUT_MESSAGE);
|
|
114
|
+
}
|
|
115
|
+
}, this.decisionTimeoutMs);
|
|
116
|
+
this.waiters.set(childId, {
|
|
117
|
+
name,
|
|
118
|
+
message,
|
|
119
|
+
sinceMs: this.clock.now(),
|
|
120
|
+
resolve: resolveFn,
|
|
121
|
+
timer,
|
|
122
|
+
});
|
|
123
|
+
return promise;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a pending need_decision waiter with the supervisor's reply.
|
|
128
|
+
* Returns false when the child has no pending request.
|
|
129
|
+
*/
|
|
130
|
+
resolveDecision(childId: string, reply: string): boolean {
|
|
131
|
+
const waiter = this.waiters.get(childId);
|
|
132
|
+
if (!waiter) return false;
|
|
133
|
+
this.waiters.delete(childId);
|
|
134
|
+
this.clock.clearTimeout(waiter.timer);
|
|
135
|
+
waiter.resolve(reply);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pendingRequests(): PendingDecisionRequest[] {
|
|
140
|
+
return [...this.waiters.entries()].map(([childId, w]) => ({
|
|
141
|
+
childId,
|
|
142
|
+
name: w.name,
|
|
143
|
+
message: w.message,
|
|
144
|
+
sinceMs: w.sinceMs,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Cancel all timers; outstanding waiters resolve with the timeout message. */
|
|
149
|
+
dispose(): void {
|
|
150
|
+
for (const waiter of this.waiters.values()) {
|
|
151
|
+
this.clock.clearTimeout(waiter.timer);
|
|
152
|
+
waiter.resolve(DECISION_TIMEOUT_MESSAGE);
|
|
153
|
+
}
|
|
154
|
+
this.waiters.clear();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommsHost adapter over the subagent RunRegistry (integration wiring, §4.7).
|
|
3
|
+
*
|
|
4
|
+
* Lives in comms/ so the M4 module stays self-contained; the registry is
|
|
5
|
+
* accessed structurally (RunRegistry query surface from appendix B) plus the
|
|
6
|
+
* concrete SubagentRegistry.handle() lookup.
|
|
7
|
+
*/
|
|
8
|
+
import type { SubagentRegistry } from "../subagent/registry";
|
|
9
|
+
import { FAMULUS_WAKE_CUSTOM_TYPE } from "../wake";
|
|
10
|
+
|
|
11
|
+
import type { CommsHost } from "./types";
|
|
12
|
+
|
|
13
|
+
export interface RegistryHostDeps {
|
|
14
|
+
getRegistry: () => SubagentRegistry | null;
|
|
15
|
+
/** NotifyCenter-compatible sink for supervisor-bound notifications. */
|
|
16
|
+
getNotifyCenter: () => {
|
|
17
|
+
notify(msg: { customType: string; content: string; details?: unknown }): void;
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export function createRegistryCommsHost(deps: RegistryHostDeps): CommsHost {
|
|
23
|
+
const locate = (childId: string) => {
|
|
24
|
+
const registry = deps.getRegistry();
|
|
25
|
+
if (!registry) return undefined;
|
|
26
|
+
for (const run of registry.list()) {
|
|
27
|
+
const child = run.children.find((c) => c.childId === childId || c.name === childId);
|
|
28
|
+
if (child) return { run, child };
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
getChild(childId) {
|
|
35
|
+
const found = locate(childId);
|
|
36
|
+
const handle = deps.getRegistry()?.handle(found?.child.childId ?? childId);
|
|
37
|
+
if (!found || !handle) return undefined;
|
|
38
|
+
return {
|
|
39
|
+
handle,
|
|
40
|
+
runId: found.run.runId,
|
|
41
|
+
name: found.child.name,
|
|
42
|
+
status: found.child.status,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
listChildren() {
|
|
47
|
+
const registry = deps.getRegistry();
|
|
48
|
+
if (!registry) return [];
|
|
49
|
+
return registry.list().flatMap((run) =>
|
|
50
|
+
run.children.map((c) => ({
|
|
51
|
+
childId: c.childId,
|
|
52
|
+
runId: run.runId,
|
|
53
|
+
name: c.name,
|
|
54
|
+
status: c.status,
|
|
55
|
+
})),
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
sameRun(childIdA, childIdB) {
|
|
60
|
+
const a = locate(childIdA);
|
|
61
|
+
const b = locate(childIdB);
|
|
62
|
+
return a !== undefined && b !== undefined && a.run.runId === b.run.runId;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
notifySupervisor(wake) {
|
|
66
|
+
deps.getNotifyCenter()?.notify({
|
|
67
|
+
customType: FAMULUS_WAKE_CUSTOM_TYPE,
|
|
68
|
+
content: wake.content,
|
|
69
|
+
details: wake.details,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Comms } from "./types";
|
|
3
|
+
|
|
4
|
+
export type ReplyCommandResolution =
|
|
5
|
+
| { childId: string; message: string }
|
|
6
|
+
| { error: string };
|
|
7
|
+
|
|
8
|
+
export function resolveReplyCommand(
|
|
9
|
+
args: string,
|
|
10
|
+
pending: ReturnType<Comms["pendingRequests"]>,
|
|
11
|
+
): ReplyCommandResolution {
|
|
12
|
+
const input = args.trim();
|
|
13
|
+
if (pending.length === 0) return { error: "No pending supervisor decisions." };
|
|
14
|
+
if (!input) {
|
|
15
|
+
return {
|
|
16
|
+
error: `Pending decisions: ${pending.map((item) => `${item.childId} (${item.name})`).join(", ")}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const [target, ...rest] = input.split(/\s+/);
|
|
21
|
+
const matched = pending.filter((item) => item.childId === target || item.name === target);
|
|
22
|
+
if (matched.length === 1) {
|
|
23
|
+
const message = rest.join(" ").trim();
|
|
24
|
+
return message
|
|
25
|
+
? { childId: matched[0].childId, message }
|
|
26
|
+
: { error: `Usage: /reply ${target} <decision>` };
|
|
27
|
+
}
|
|
28
|
+
if (matched.length > 1) return { error: `Ambiguous pending decision "${target}"; use a child id.` };
|
|
29
|
+
if (pending.length === 1) return { childId: pending[0].childId, message: input };
|
|
30
|
+
return {
|
|
31
|
+
error: `Specify a pending child id or name. Pending: ${pending.map((item) => `${item.childId} (${item.name})`).join(", ")}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerReplyCommand(pi: ExtensionAPI, comms: Comms): void {
|
|
36
|
+
pi.registerCommand("reply", {
|
|
37
|
+
description: "Reply to a pending subagent decision (/reply <child-id> <decision>)",
|
|
38
|
+
handler: async (args, ctx: ExtensionContext) => {
|
|
39
|
+
const resolved = resolveReplyCommand(args, comms.pendingRequests());
|
|
40
|
+
if ("error" in resolved) {
|
|
41
|
+
ctx.ui.notify(resolved.error, "info");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
comms.reply(resolved.childId, resolved.message);
|
|
46
|
+
ctx.ui.notify(`Replied to ${resolved.childId}.`, "info");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lineage checks (design doc §4.7): sibling messaging is only allowed inside
|
|
3
|
+
* the same run. Pure functions over the CommsHost interface.
|
|
4
|
+
*/
|
|
5
|
+
import type { CommsHost } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Assert that child `fromChildId` may message child `toChildId`.
|
|
9
|
+
* Throws Error("cross-run messaging not allowed") when the two children belong
|
|
10
|
+
* to different runs (v1 lineage = same runId), and a clear error for self-send.
|
|
11
|
+
*/
|
|
12
|
+
export function assertSiblingAllowed(
|
|
13
|
+
host: CommsHost,
|
|
14
|
+
fromChildId: string,
|
|
15
|
+
toChildId: string,
|
|
16
|
+
): void {
|
|
17
|
+
if (fromChildId === toChildId) {
|
|
18
|
+
throw new Error(`Child ${fromChildId} cannot send a message to itself.`);
|
|
19
|
+
}
|
|
20
|
+
if (!host.sameRun(fromChildId, toChildId)) {
|
|
21
|
+
throw new Error("cross-run messaging not allowed");
|
|
22
|
+
}
|
|
23
|
+
}
|