pi-famulus 0.2.0 → 0.2.2-beta.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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @name editor completion: suggest live sessions, insert a
3
+ * `<RequestSendMessage to=… host=… />` marker.
4
+ *
5
+ * Upstream baseline: agent-intercom @ 98183fb, plugin/pi — verbatim port of
6
+ * the plugin's autocomplete provider.
7
+ */
8
+
9
+ import type { SessionRow } from "./types";
10
+ import { hostOf } from "./types";
11
+ import { fuzzyFilter } from "./overlay";
12
+
13
+ export type AutocompleteItem = {
14
+ value: string;
15
+ label: string;
16
+ description?: string;
17
+ };
18
+
19
+ export type AutocompleteProvider = {
20
+ getSuggestions: (
21
+ lines: string[],
22
+ cursorLine: number,
23
+ cursorCol: number,
24
+ options: { signal: AbortSignal; force?: boolean },
25
+ ) => Promise<{ items: AutocompleteItem[]; prefix: string } | null>;
26
+ applyCompletion: (
27
+ lines: string[],
28
+ cursorLine: number,
29
+ cursorCol: number,
30
+ item: AutocompleteItem,
31
+ prefix: string,
32
+ ) => { lines: string[]; cursorLine: number; cursorCol: number };
33
+ shouldTriggerFileCompletion?: (lines: string[], cursorLine: number, cursorCol: number) => boolean;
34
+ };
35
+
36
+ /** @file / @. / @~ stay file completion; bare @name is an agent mention. */
37
+ export function extractAgentMention(textBeforeCursor: string): { prefix: string; query: string } | undefined {
38
+ const match = textBeforeCursor.match(/(?:^|[ \t])@([^\s]*)$/);
39
+ if (!match) return undefined;
40
+ const query = match[1];
41
+ if (query.startsWith('"') || query.startsWith(".") || query.startsWith("~") || query.includes("/")) {
42
+ return undefined;
43
+ }
44
+ return { prefix: `@${query}`, query };
45
+ }
46
+
47
+ function xmlAttr(s: string): string {
48
+ return s.replace(/["<>]/g, "");
49
+ }
50
+
51
+ export function filterAgentItems(
52
+ rows: SessionRow[],
53
+ query: string,
54
+ self: { id: string; name: string },
55
+ ): AutocompleteItem[] {
56
+ const candidates: { row: SessionRow; label: string }[] = [];
57
+ for (const row of rows) {
58
+ const id = (row.id ?? "").trim();
59
+ const name = (row.name ?? "").trim();
60
+ if (!id && !name) continue;
61
+ if ((id && id === self.id) || (name && name === self.name)) continue;
62
+ candidates.push({ row, label: name || id });
63
+ }
64
+ const matched = fuzzyFilter(candidates, query, (c) => `${c.row.name ?? ""} ${c.row.id ?? ""}`);
65
+ const items: AutocompleteItem[] = [];
66
+ for (const { row, label } of matched) {
67
+ const description = [row.harness, row.status, hostOf(row)].filter(Boolean).join(" ");
68
+ const host = hostOf(row);
69
+ items.push({
70
+ // Insert a self-describing marker so the model sends to the exact
71
+ // session id; the name alone is ambiguous across duplicate names.
72
+ // The host is written next to it so the human reading their own
73
+ // message can tell which machine the opaque id belongs to.
74
+ value: host
75
+ ? `<RequestSendMessage to="${row.id ?? label}" host="${xmlAttr(host)}" />`
76
+ : `<RequestSendMessage to="${row.id ?? label}" />`,
77
+ label,
78
+ ...(description && { description }),
79
+ });
80
+ if (items.length >= 20) break;
81
+ }
82
+ return items;
83
+ }
@@ -0,0 +1,439 @@
1
+ /**
2
+ * The /intercom overlay: session list + compose box.
3
+ *
4
+ * Upstream baseline: agent-intercom @ 98183fb, plugin/pi — verbatim port of
5
+ * the plugin's SessionListOverlay/ComposeOverlay and their text helpers.
6
+ */
7
+
8
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
9
+ import type { SessionRow } from "./types";
10
+ import { stringWidth } from "./widgets";
11
+
12
+ export type OverlayTui = { requestRender(): void };
13
+
14
+ export type OverlayComponent = {
15
+ invalidate(): void;
16
+ handleInput?(data: string): void;
17
+ render(width: number): string[];
18
+ dispose?(): void;
19
+ };
20
+
21
+ export function formatLastActive(updatedAt: number | undefined, now = Date.now()): string {
22
+ if (!updatedAt || updatedAt <= 0) return "-";
23
+ const elapsed = Math.max(0, now - updatedAt);
24
+ if (elapsed < 10_000) return "now";
25
+ if (elapsed < 60_000) return `${Math.floor(elapsed / 1_000)}s ago`;
26
+ if (elapsed < 60 * 60_000) return `${Math.floor(elapsed / 60_000)}m ago`;
27
+ if (elapsed < 24 * 60 * 60_000) return `${Math.floor(elapsed / (60 * 60_000))}h ago`;
28
+ const date = new Date(updatedAt);
29
+ const pad = (value: number) => String(value).padStart(2, "0");
30
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
31
+ }
32
+
33
+ /**
34
+ * Fuzzy matching ported from pi-tui's fuzzy.js so @mention completion and
35
+ * the overlay filter behave like pi's built-in slash-command completion:
36
+ * all query characters must appear in order (not necessarily consecutive);
37
+ * lower score = better match.
38
+ */
39
+ export function fuzzyMatch(query: string, text: string): { matches: boolean; score: number } {
40
+ const queryLower = query.toLowerCase();
41
+ const textLower = text.toLowerCase();
42
+ if (queryLower.length === 0) return { matches: true, score: 0 };
43
+ if (queryLower.length > textLower.length) return { matches: false, score: 0 };
44
+ let queryIndex = 0;
45
+ let score = 0;
46
+ let lastMatchIndex = -1;
47
+ let consecutiveMatches = 0;
48
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
49
+ if (textLower[i] === queryLower[queryIndex]) {
50
+ const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]);
51
+ if (lastMatchIndex === i - 1) {
52
+ consecutiveMatches++;
53
+ score -= consecutiveMatches * 5;
54
+ } else {
55
+ consecutiveMatches = 0;
56
+ if (lastMatchIndex >= 0) {
57
+ score += (i - lastMatchIndex - 1) * 2;
58
+ }
59
+ }
60
+ if (isWordBoundary) score -= 10;
61
+ score += i * 0.1;
62
+ lastMatchIndex = i;
63
+ queryIndex++;
64
+ }
65
+ }
66
+ if (queryIndex < queryLower.length) return { matches: false, score: 0 };
67
+ if (queryLower === textLower) score -= 100;
68
+ return { matches: true, score };
69
+ }
70
+
71
+ /** Filter and sort candidates by fuzzy match quality (best matches first). */
72
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
73
+ if (!query.trim()) return items;
74
+ const tokens = query
75
+ .trim()
76
+ .split(/[\s/]+/)
77
+ .filter((t) => t.length > 0);
78
+ if (tokens.length === 0) return items;
79
+ const results: { item: T; totalScore: number }[] = [];
80
+ for (const item of items) {
81
+ const text = getText(item);
82
+ let totalScore = 0;
83
+ let allMatch = true;
84
+ for (const token of tokens) {
85
+ const match = fuzzyMatch(token, text);
86
+ if (match.matches) {
87
+ totalScore += match.score;
88
+ } else {
89
+ allMatch = false;
90
+ break;
91
+ }
92
+ }
93
+ if (allMatch) results.push({ item, totalScore });
94
+ }
95
+ results.sort((a, b) => a.totalScore - b.totalScore);
96
+ return results.map((r) => r.item);
97
+ }
98
+
99
+ export function stripAnsi(text: string): string {
100
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
101
+ }
102
+
103
+ export function visibleWidth(text: string): number {
104
+ return stringWidth(stripAnsi(text));
105
+ }
106
+
107
+ export function truncateToWidth(text: string, maxWidth: number): string {
108
+ if (maxWidth <= 0) return "";
109
+ const tokens = text.match(/(\x1b\[[0-9;]*m|[\s\S])/g) ?? [];
110
+ let out = "";
111
+ let width = 0;
112
+ let hasAnsi = false;
113
+ let truncated = false;
114
+ for (const token of tokens) {
115
+ if (token.startsWith("\x1b")) {
116
+ out += token;
117
+ hasAnsi = true;
118
+ continue;
119
+ }
120
+ const tokenWidth = stringWidth(token);
121
+ if (width + tokenWidth > maxWidth) {
122
+ truncated = true;
123
+ break;
124
+ }
125
+ out += token;
126
+ width += tokenWidth;
127
+ }
128
+ return truncated && hasAnsi ? `${out}\x1b[0m` : out;
129
+ }
130
+
131
+ export function middleTruncate(text: string, maxWidth: number): string {
132
+ if (visibleWidth(text) <= maxWidth) return text;
133
+ if (maxWidth <= 3) return truncateToWidth(text, maxWidth);
134
+
135
+ const chars = [...text];
136
+ const targetSideWidth = Math.max(1, Math.floor((maxWidth - 1) / 2));
137
+ let left = "";
138
+ for (const char of chars) {
139
+ if (visibleWidth(left + char) > targetSideWidth) break;
140
+ left += char;
141
+ }
142
+
143
+ let right = "";
144
+ for (const char of chars.slice().reverse()) {
145
+ if (visibleWidth(char + right) > targetSideWidth) break;
146
+ right = char + right;
147
+ }
148
+ return truncateToWidth(`${left}…${right}`, maxWidth);
149
+ }
150
+
151
+ export function shortSessionId(sessionId: string | undefined): string {
152
+ return sessionId ? sessionId.slice(0, 8) : "?";
153
+ }
154
+
155
+ export function sessionTitle(session: SessionRow, options?: { self?: boolean; sameCwd?: boolean }): string {
156
+ const name = session.name || "Unnamed session";
157
+ const tags = [options?.self ? "self" : undefined, options?.sameCwd ? "same cwd" : undefined]
158
+ .filter((tag): tag is string => Boolean(tag));
159
+ const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
160
+ return `${name} (${shortSessionId(session.id)})${suffix}`;
161
+ }
162
+
163
+ export function sessionDetails(session: SessionRow, contentWidth: number): string {
164
+ const cwd = middleTruncate(session.cwd || "(no cwd)", Math.max(8, contentWidth - 4));
165
+ const harness = session.harness || "unknown";
166
+ const status = session.status ? ` • ${session.status}` : "";
167
+ return `${cwd} • ${harness}${status} • ${formatLastActive(session.updated_at)}`;
168
+ }
169
+
170
+ export class SessionListOverlay implements OverlayComponent {
171
+ private theme: Theme;
172
+ private keybindings: KeybindingsManager;
173
+ private currentSession: SessionRow;
174
+ private done: (result: SessionRow | undefined) => void;
175
+ private sessions: SessionRow[];
176
+ private selectedIndex = 0;
177
+ private maxVisible = 8;
178
+ private filtering = false;
179
+ private filterQuery = "";
180
+
181
+ constructor(
182
+ theme: Theme,
183
+ keybindings: KeybindingsManager,
184
+ currentSession: SessionRow,
185
+ sessions: SessionRow[],
186
+ done: (result: SessionRow | undefined) => void,
187
+ ) {
188
+ this.theme = theme;
189
+ this.keybindings = keybindings;
190
+ this.currentSession = currentSession;
191
+ this.sessions = sessions;
192
+ this.done = done;
193
+ }
194
+
195
+ invalidate(): void {}
196
+
197
+ private filteredSessions(): SessionRow[] {
198
+ if (!this.filterQuery.trim()) return this.sessions;
199
+ return fuzzyFilter(
200
+ this.sessions,
201
+ this.filterQuery,
202
+ (session) => `${session.name ?? ""} ${session.id ?? ""} ${session.harness ?? ""} ${session.status ?? ""} ${session.cwd ?? ""}`,
203
+ );
204
+ }
205
+
206
+ private clampSelection(sessions: SessionRow[]): void {
207
+ if (sessions.length === 0) {
208
+ this.selectedIndex = 0;
209
+ return;
210
+ }
211
+ this.selectedIndex = Math.min(this.selectedIndex, sessions.length - 1);
212
+ }
213
+
214
+ private updateFilter(query: string): void {
215
+ this.filterQuery = query;
216
+ this.selectedIndex = 0;
217
+ }
218
+
219
+ handleInput(data: string): void {
220
+ const sessions = this.filteredSessions();
221
+
222
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
223
+ if (this.filtering) {
224
+ this.filtering = false;
225
+ this.updateFilter("");
226
+ return;
227
+ }
228
+ this.done(undefined);
229
+ return;
230
+ }
231
+
232
+ if (!this.filtering && data === "/") {
233
+ this.filtering = true;
234
+ return;
235
+ }
236
+
237
+ if (this.keybindings.matches(data, "tui.select.up")) {
238
+ if (sessions.length > 0) {
239
+ this.selectedIndex = this.selectedIndex === 0 ? sessions.length - 1 : this.selectedIndex - 1;
240
+ }
241
+ return;
242
+ }
243
+ if (this.keybindings.matches(data, "tui.select.down")) {
244
+ if (sessions.length > 0) {
245
+ this.selectedIndex = this.selectedIndex === sessions.length - 1 ? 0 : this.selectedIndex + 1;
246
+ }
247
+ return;
248
+ }
249
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
250
+ const session = sessions[this.selectedIndex];
251
+ if (session) this.done(session);
252
+ return;
253
+ }
254
+
255
+ if (this.filtering) {
256
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
257
+ this.updateFilter([...this.filterQuery].slice(0, -1).join(""));
258
+ return;
259
+ }
260
+ if (data.startsWith("\x1b")) return;
261
+ const printable = [...data].filter((char) => char >= " ").join("");
262
+ if (printable) this.updateFilter(this.filterQuery + printable);
263
+ }
264
+ }
265
+
266
+ render(width: number): string[] {
267
+ const innerWidth = Math.max(1, Math.min(width, 88));
268
+ if (innerWidth === 1) return [this.theme.fg("accent", "│")];
269
+
270
+ const contentWidth = Math.max(0, innerWidth - 2);
271
+ const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message • /: Filter • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
272
+ const border = (text: string) => this.theme.fg("accent", text);
273
+ const row = (text = "") => {
274
+ const clipped = truncateToWidth(text, contentWidth);
275
+ return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
276
+ };
277
+
278
+ const lines: string[] = [];
279
+ lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
280
+ lines.push(row(this.theme.bold(" Current Session")));
281
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
282
+ lines.push(row());
283
+ lines.push(row(` ${this.theme.fg("dim", sessionTitle(this.currentSession, { self: true }))}`));
284
+ lines.push(row(` ${this.theme.fg("dim", sessionDetails(this.currentSession, contentWidth))}`));
285
+ lines.push(row());
286
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
287
+ lines.push(row(this.theme.bold(" Other Sessions")));
288
+ if (this.filtering || this.filterQuery) {
289
+ lines.push(row(` ${this.theme.fg("dim", "Filter:")} ${this.filterQuery}${this.filtering ? "█" : ""}`));
290
+ }
291
+ lines.push(row());
292
+
293
+ const sessions = this.filteredSessions();
294
+ this.clampSelection(sessions);
295
+ if (sessions.length === 0) {
296
+ const empty = this.filterQuery ? ` No sessions matching "${this.filterQuery}"` : " No other agent-intercom sessions";
297
+ lines.push(row(this.theme.fg("dim", empty)));
298
+ } else {
299
+ const startIndex = Math.max(
300
+ 0,
301
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), sessions.length - this.maxVisible),
302
+ );
303
+ const endIndex = Math.min(startIndex + this.maxVisible, sessions.length);
304
+ for (let index = startIndex; index < endIndex; index += 1) {
305
+ const session = sessions[index];
306
+ const isSelected = index === this.selectedIndex;
307
+ const sameCwd = Boolean(session.cwd && session.cwd === this.currentSession.cwd);
308
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
309
+ const title = sessionTitle(session, { sameCwd });
310
+ lines.push(row(`${prefix}${isSelected ? this.theme.fg("accent", title) : title}`));
311
+ lines.push(row(` ${this.theme.fg("dim", sessionDetails(session, contentWidth))}`));
312
+ if (index < endIndex - 1) lines.push(row());
313
+ }
314
+ if (startIndex > 0 || endIndex < sessions.length) {
315
+ lines.push(row());
316
+ lines.push(row(this.theme.fg("dim", ` ${this.selectedIndex + 1}/${sessions.length}`)));
317
+ }
318
+ }
319
+
320
+ lines.push(row());
321
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
322
+ lines.push(row(this.theme.fg("dim", ` ${footer}`)));
323
+ lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
324
+ return lines;
325
+ }
326
+ }
327
+
328
+ export interface ComposeResult {
329
+ sent: boolean;
330
+ messageId?: string;
331
+ text?: string;
332
+ }
333
+
334
+ export class ComposeOverlay implements OverlayComponent {
335
+ private tui: OverlayTui;
336
+ private theme: Theme;
337
+ private keybindings: KeybindingsManager;
338
+ private target: SessionRow;
339
+ private targetLabel: string;
340
+ private send: (text: string) => Promise<{ messageId?: string }>;
341
+ private done: (result: ComposeResult) => void;
342
+ private inputBuffer = "";
343
+ private sending = false;
344
+ private error: string | null = null;
345
+
346
+ constructor(
347
+ tui: OverlayTui,
348
+ theme: Theme,
349
+ keybindings: KeybindingsManager,
350
+ target: SessionRow,
351
+ targetLabel: string,
352
+ send: (text: string) => Promise<{ messageId?: string }>,
353
+ done: (result: ComposeResult) => void,
354
+ ) {
355
+ this.tui = tui;
356
+ this.theme = theme;
357
+ this.keybindings = keybindings;
358
+ this.target = target;
359
+ this.targetLabel = targetLabel;
360
+ this.send = send;
361
+ this.done = done;
362
+ }
363
+
364
+ invalidate(): void {}
365
+
366
+ handleInput(data: string): void {
367
+ if (this.sending) return;
368
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
369
+ this.done({ sent: false });
370
+ return;
371
+ }
372
+ if (data.startsWith("\x1b")) return;
373
+
374
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
375
+ if (this.inputBuffer.trim()) void this.sendMessage();
376
+ return;
377
+ }
378
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
379
+ this.inputBuffer = [...this.inputBuffer].slice(0, -1).join("");
380
+ this.tui.requestRender();
381
+ return;
382
+ }
383
+
384
+ const printable = [...data].filter((char) => char >= " ").join("");
385
+ if (printable) {
386
+ this.inputBuffer += printable;
387
+ this.tui.requestRender();
388
+ }
389
+ }
390
+
391
+ private async sendMessage(): Promise<void> {
392
+ this.sending = true;
393
+ this.error = null;
394
+ this.tui.requestRender();
395
+ const text = this.inputBuffer.trim();
396
+ try {
397
+ const result = await this.send(text);
398
+ this.done({ sent: true, messageId: result.messageId, text });
399
+ } catch (error) {
400
+ this.error = error instanceof Error ? error.message : String(error);
401
+ this.sending = false;
402
+ this.tui.requestRender();
403
+ }
404
+ }
405
+
406
+ render(width: number): string[] {
407
+ const innerWidth = Math.max(1, Math.min(width, 72));
408
+ if (innerWidth === 1) return [this.theme.fg("accent", "│")];
409
+
410
+ const contentWidth = Math.max(0, innerWidth - 2);
411
+ const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Send • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
412
+ const border = (text: string) => this.theme.fg("accent", text);
413
+ const row = (text = "") => {
414
+ const clipped = truncateToWidth(text, contentWidth);
415
+ return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
416
+ };
417
+
418
+ const lines: string[] = [];
419
+ lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
420
+ lines.push(row(this.theme.bold(` Send to: ${this.targetLabel}`)));
421
+ lines.push(row(this.theme.fg("dim", ` ${sessionDetails(this.target, contentWidth)}`)));
422
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
423
+ lines.push(row());
424
+ if (this.sending) {
425
+ lines.push(row(this.theme.fg("dim", " Sending...")));
426
+ } else if (this.error) {
427
+ lines.push(row(this.theme.fg("error", ` Error: ${this.error}`)));
428
+ lines.push(row());
429
+ lines.push(row(` > ${this.inputBuffer}█`));
430
+ } else {
431
+ lines.push(row(` > ${this.inputBuffer}█`));
432
+ }
433
+ lines.push(row());
434
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
435
+ lines.push(row(this.theme.fg("dim", ` ${footer}`)));
436
+ lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
437
+ return lines;
438
+ }
439
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The intercom transport: one op at a time onto the famulus manager
3
+ * connection, frames pushed back as `intercom` events.
4
+ *
5
+ * Upstream baseline: agent-intercom @ 98183fb, plugin/pi — this interface
6
+ * replaces the plugin's Unix socket (`client.sock`) with the manager
7
+ * connection the extension already holds (`manager.sock`, protocol 7).
8
+ */
9
+
10
+ import { ManagerClient, ManagerError } from "../manager-client";
11
+
12
+ /** Why the bridge cannot serve ops right now. */
13
+ export type BridgeDownReason = "disabled" | "unavailable";
14
+
15
+ export class IntercomError extends Error {
16
+ constructor(
17
+ message: string,
18
+ readonly code?: string,
19
+ readonly reason?: BridgeDownReason,
20
+ ) {
21
+ super(message);
22
+ this.name = "IntercomError";
23
+ }
24
+ }
25
+
26
+ export interface IntercomTransport {
27
+ /** Issue one intercom op; rejects with IntercomError when the bridge or
28
+ * the manager connection is down. Op-level errors resolve with the
29
+ * daemon's error object (`{ error: string }`). */
30
+ call(op: Record<string, unknown>): Promise<Record<string, unknown>>;
31
+ /** Bridge frames for this session (deliver/receipt/idle_notice). */
32
+ onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
33
+ /** Registration is invalidated: bridge restart (`intercom_reset`) or the
34
+ * manager connection re-hello'd (restart / in-place upgrade). */
35
+ onReset(handler: () => void): () => void;
36
+ /** The manager connection is up (registration may still be required). */
37
+ isUp(): boolean;
38
+ }
39
+
40
+ const DOWN_CODES: Record<string, BridgeDownReason> = {
41
+ E_INTERCOM_DISABLED: "disabled",
42
+ E_INTERCOM_UNAVAILABLE: "unavailable",
43
+ };
44
+
45
+ /**
46
+ * The transport over the extension's ManagerClient. One instance per pi
47
+ * session runtime; `getClient` resolves the current session's client, which
48
+ * is (re)created on session_start and torn down on shutdown.
49
+ */
50
+ export function createManagerTransport(
51
+ getClient: () => ManagerClient | null,
52
+ ): IntercomTransport {
53
+ return {
54
+ async call(op) {
55
+ const client = getClient();
56
+ if (!client || !client.isAvailable()) {
57
+ throw new IntercomError("pi-famulus is not connected", "E_TRANSPORT", "unavailable");
58
+ }
59
+ try {
60
+ return await client.intercom(op);
61
+ } catch (error) {
62
+ if (error instanceof ManagerError) {
63
+ const reason = DOWN_CODES[error.code];
64
+ if (reason) throw new IntercomError(error.message, error.code, reason);
65
+ // Op-level failure (unknown session, ownership ended, …): the
66
+ // caller reads the `error` field, like the plugin's frame did.
67
+ return { error: error.message, code: error.code };
68
+ }
69
+ throw error instanceof IntercomError
70
+ ? error
71
+ : new IntercomError(error instanceof Error ? error.message : String(error));
72
+ }
73
+ },
74
+ onFrame(handler) {
75
+ const client = getClient();
76
+ if (!client) return () => {};
77
+ return client.onEvent((event) => {
78
+ if (event.event === "intercom" && event.frame && typeof event.frame === "object") {
79
+ handler(event.frame);
80
+ }
81
+ }) ?? (() => {});
82
+ },
83
+ onReset(handler) {
84
+ const client = getClient();
85
+ if (!client) return () => {};
86
+ const offEvent = client.onEvent((event) => {
87
+ if (event.event === "intercom_reset") handler();
88
+ }) ?? (() => {});
89
+ // A reconnect (daemon restart or in-place upgrade) re-hellos the same
90
+ // manager socket; the port does not survive that either.
91
+ const offReconnect = client.onReconnect(handler) ?? (() => {});
92
+ return () => {
93
+ offEvent();
94
+ offReconnect();
95
+ };
96
+ },
97
+ isUp() {
98
+ const client = getClient();
99
+ return Boolean(client && client.isAvailable());
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Types for the pi-famulus intercom port.
3
+ *
4
+ * Upstream baseline: agent-intercom @ 98183fb, plugin/pi (the pi plugin's
5
+ * observable behaviour — tools, commands, envelopes, receipts, busy/idle
6
+ * publication — is preserved; the client.sock transport is replaced by the
7
+ * famulus manager connection, protocol 7 `intercom` request + events).
8
+ */
9
+
10
+ /** A directory row as the bridge publishes it. */
11
+ export interface SessionRow {
12
+ id?: string;
13
+ name?: string;
14
+ harness?: string;
15
+ status?: string;
16
+ cwd?: string;
17
+ updated_at?: number;
18
+ machine_id?: string;
19
+ machine_name?: string;
20
+ }
21
+
22
+ /** One client-protocol frame pushed by the bridge (`intercom` event). */
23
+ export interface IntercomFrame {
24
+ type?: string;
25
+ session_id?: string;
26
+ from?: string;
27
+ body?: string;
28
+ reply_to?: string;
29
+ message_id?: string;
30
+ status?: string;
31
+ subscription_id?: string;
32
+ sessions?: SessionRow[];
33
+ error?: string;
34
+ }
35
+
36
+ /** Machine a session runs on: its display name, else its machine id. */
37
+ export function hostOf(row: SessionRow): string {
38
+ return (row.machine_name || row.machine_id || "").trim();
39
+ }