aibroker 0.58.1 → 0.60.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/dist/adapters/iterm/core.d.ts +12 -1
- package/dist/adapters/iterm/core.d.ts.map +1 -1
- package/dist/adapters/iterm/core.js +32 -5
- package/dist/adapters/iterm/core.js.map +1 -1
- package/dist/adapters/iterm/sessions.d.ts +28 -0
- package/dist/adapters/iterm/sessions.d.ts.map +1 -1
- package/dist/adapters/iterm/sessions.js +101 -4
- package/dist/adapters/iterm/sessions.js.map +1 -1
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +46 -11
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/core/hybrid.d.ts +10 -1
- package/dist/core/hybrid.d.ts.map +1 -1
- package/dist/core/hybrid.js +50 -15
- package/dist/core/hybrid.js.map +1 -1
- package/dist/core/persistence.d.ts +51 -2
- package/dist/core/persistence.d.ts.map +1 -1
- package/dist/core/persistence.js +45 -1
- package/dist/core/persistence.js.map +1 -1
- package/dist/core/session-match.d.ts +23 -0
- package/dist/core/session-match.d.ts.map +1 -1
- package/dist/core/session-match.js +25 -11
- package/dist/core/session-match.js.map +1 -1
- package/dist/core/state.d.ts +7 -1
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +13 -3
- package/dist/core/state.js.map +1 -1
- package/dist/daemon/cli.js +3 -0
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/commands.d.ts.map +1 -1
- package/dist/daemon/commands.js +8 -0
- package/dist/daemon/commands.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts +28 -0
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +215 -59
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/dispatch.d.ts +60 -1
- package/dist/daemon/dispatch.d.ts.map +1 -1
- package/dist/daemon/dispatch.js +389 -18
- package/dist/daemon/dispatch.js.map +1 -1
- package/dist/daemon/inbound.d.ts +4 -0
- package/dist/daemon/inbound.d.ts.map +1 -1
- package/dist/daemon/inbound.js +134 -29
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +42 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/manage.d.ts +37 -0
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +179 -13
- package/dist/daemon/manage.js.map +1 -1
- package/dist/daemon/session-content.d.ts.map +1 -1
- package/dist/daemon/session-content.js +4 -2
- package/dist/daemon/session-content.js.map +1 -1
- package/dist/daemon/todoist-pending.d.ts +45 -0
- package/dist/daemon/todoist-pending.d.ts.map +1 -0
- package/dist/daemon/todoist-pending.js +58 -0
- package/dist/daemon/todoist-pending.js.map +1 -0
- package/dist/daemon/todoist-reply.d.ts +10 -0
- package/dist/daemon/todoist-reply.d.ts.map +1 -1
- package/dist/daemon/todoist-reply.js +1 -0
- package/dist/daemon/todoist-reply.js.map +1 -1
- package/dist/daemon/todoist-webhook.d.ts +29 -0
- package/dist/daemon/todoist-webhook.d.ts.map +1 -1
- package/dist/daemon/todoist-webhook.js +334 -209
- package/dist/daemon/todoist-webhook.js.map +1 -1
- package/dist/ipc/client.d.ts.map +1 -1
- package/dist/ipc/client.js +24 -3
- package/dist/ipc/client.js.map +1 -1
- package/dist/ipc/validate.d.ts +2 -0
- package/dist/ipc/validate.d.ts.map +1 -1
- package/dist/ipc/validate.js +6 -1
- package/dist/ipc/validate.js.map +1 -1
- package/dist/mcp/index.js +19 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/transport/screen.d.ts.map +1 -1
- package/dist/transport/screen.js +30 -4
- package/dist/transport/screen.js.map +1 -1
- package/dist/transport/sync-facade.d.ts +15 -2
- package/dist/transport/sync-facade.d.ts.map +1 -1
- package/dist/transport/sync-facade.js +16 -3
- package/dist/transport/sync-facade.js.map +1 -1
- package/hooks/drain-mailbox.mjs +34 -8
- package/package.json +2 -2
|
@@ -20,6 +20,7 @@ import type { IpcServer } from "../ipc/server.js";
|
|
|
20
20
|
import type { AdapterRegistry } from "./adapter-registry.js";
|
|
21
21
|
import type { APIBackend } from "../backend/api.js";
|
|
22
22
|
import type { HybridSessionManager } from "../core/hybrid.js";
|
|
23
|
+
import type { SessionSnapshot } from "../adapters/iterm/core.js";
|
|
23
24
|
import type { IpcRequest } from "../types/ipc.js";
|
|
24
25
|
/**
|
|
25
26
|
* Best human-readable identity for whoever made this call, for the audit trail.
|
|
@@ -57,10 +58,37 @@ export declare function callerItermId(req: IpcRequest): string | undefined;
|
|
|
57
58
|
* demand to reply that the sender can never see), and no `[Session:...]`
|
|
58
59
|
* prefix is typed (the prefix tells the receiving Claude to route a reply
|
|
59
60
|
* back to a sender, but a beat's label is not a registered session).
|
|
61
|
+
*
|
|
62
|
+
* When a message IS deposited, only a one-line pointer is typed — never the
|
|
63
|
+
* full body. Typing the full body and pressing Enter submits it as an
|
|
64
|
+
* ordinary prompt, which fires the target's own `hooks/drain-mailbox.mjs`
|
|
65
|
+
* UserPromptSubmit hook on that same submission. That hook drains the very
|
|
66
|
+
* message this call just deposited and re-injects it as a system-reminder —
|
|
67
|
+
* so the full content showed up twice in one turn: once as the typed prompt,
|
|
68
|
+
* once again from the mailbox. Reproduced live 2026-09-23 on both an idle and
|
|
69
|
+
* a busy target — not a busy-only race. A short pointer and the deposited
|
|
70
|
+
* body are different strings, so the hook's drain is the only place the full
|
|
71
|
+
* content appears, and a multi-line body is never typed into a live pane at
|
|
72
|
+
* all — the failure mode where only the last line survived typing cannot
|
|
73
|
+
* recur here because there is nothing but one line to type.
|
|
60
74
|
*/
|
|
61
75
|
export declare function sendToSessionDelivery(message: string, senderLabel: string, noReply: boolean): {
|
|
62
76
|
typed: string;
|
|
63
77
|
deposit: boolean;
|
|
64
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* The sender's identity for the mailbox "from" field and the `[Session:X]`
|
|
81
|
+
* prefix — bare, unlike callerLabel()'s `session:`-prefixed audit actor.
|
|
82
|
+
*
|
|
83
|
+
* paiName first, then the session's own stable id — never the raw iTerm
|
|
84
|
+
* name/title, which is the terminal's OWN dynamic display text (a spinner
|
|
85
|
+
* glyph plus tab title for a busy Claude Code session) and not an identity.
|
|
86
|
+
* A sender not found in the live snapshot (worker child process, or a bare
|
|
87
|
+
* CLI call with no pane at all) falls back to whatever id it claimed — for a
|
|
88
|
+
* worker that id is a fabricated, non-live marker (see ipc/client.ts), so it
|
|
89
|
+
* naturally fails to match any session and a reply to it is refused rather
|
|
90
|
+
* than silently reaching the wrong pane.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveSenderLabel(req: IpcRequest, snapshots: SessionSnapshot[]): string;
|
|
65
93
|
export declare function registerCoreHandlers(server: IpcServer, registry: AdapterRegistry, _apiBackend: APIBackend, manager: HybridSessionManager): void;
|
|
66
94
|
//# sourceMappingURL=core-handlers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"core-handlers.d.ts","sourceRoot":"","sources":["../../src/daemon/core-handlers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"core-handlers.d.ts","sourceRoot":"","sources":["../../src/daemon/core-handlers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAiB9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAIjE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AA0ClD;;;;;GAKG;AACH;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,OAAO,GACf;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAIrC;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,EAAE,SAAS,EAAE,eAAe,EAAE,GAAG,MAAM,CAKxF;AAED,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,QAAQ,EAAE,eAAe,EACzB,WAAW,EAAE,UAAU,EACvB,OAAO,EAAE,oBAAoB,GAC5B,IAAI,CAuiEN"}
|
|
@@ -23,7 +23,7 @@ import { broadcastStatus, broadcastVoice, broadcastImage, broadcastText, handleM
|
|
|
23
23
|
import { mqttRequestDebugState } from "../adapters/pailot/mqtt-broker.js";
|
|
24
24
|
import { WatcherClient } from "../ipc/client.js";
|
|
25
25
|
import { saveVoiceConfig, setPersistentSessionName, getPersistentSessionName, getAllPersistentSessionNames, removePersistentSessionName, lookupPersistentName } from "../core/persistence.js";
|
|
26
|
-
import { voiceConfig, setVoiceConfig, activeItermSessionId, lastRoutedSessionId, getAibpBridge, depositToSessionMailbox, drainSessionMailbox } from "../core/state.js";
|
|
26
|
+
import { voiceConfig, setVoiceConfig, activeItermSessionId, lastRoutedSessionId, getAibpBridge, depositToSessionMailbox, drainSessionMailbox, peekSessionMailbox } from "../core/state.js";
|
|
27
27
|
import { splitIntoChunks } from "../adapters/kokoro/media.js";
|
|
28
28
|
import { stripMarkdown } from "../core/markdown.js";
|
|
29
29
|
import { listPaiProjects, findPaiProject, launchPaiProject } from "./pai-projects.js";
|
|
@@ -31,7 +31,7 @@ import { readSessionContent, readAllSessionContent } from "./session-content.js"
|
|
|
31
31
|
import { promptUnsentText, inputLineDecision } from "./manage.js";
|
|
32
32
|
import { statusCache, hashContent } from "../core/status-cache.js";
|
|
33
33
|
import { clearAllPaiNames } from "../adapters/iterm/core.js";
|
|
34
|
-
import { snapshotAllSessions, setSessionTitle, itermViewerSessionId, aibrokerIdForPane, isClaudeSession } from "../transport/sync-facade.js";
|
|
34
|
+
import { snapshotAllSessions, setSessionTitle, itermViewerSessionId, aibrokerIdForPane, isClaudeSession, wasLastEnumerationReliable } from "../transport/sync-facade.js";
|
|
35
35
|
import { matchSession, resolveCallerSession } from "../core/session-match.js";
|
|
36
36
|
import { audit, noteInbound } from "./audit.js";
|
|
37
37
|
import { setItermSessionVar, setItermTabName, setItermBadge, revealItermSession } from "../adapters/iterm/sessions.js";
|
|
@@ -105,11 +105,24 @@ export function callerItermId(req) {
|
|
|
105
105
|
* demand to reply that the sender can never see), and no `[Session:...]`
|
|
106
106
|
* prefix is typed (the prefix tells the receiving Claude to route a reply
|
|
107
107
|
* back to a sender, but a beat's label is not a registered session).
|
|
108
|
+
*
|
|
109
|
+
* When a message IS deposited, only a one-line pointer is typed — never the
|
|
110
|
+
* full body. Typing the full body and pressing Enter submits it as an
|
|
111
|
+
* ordinary prompt, which fires the target's own `hooks/drain-mailbox.mjs`
|
|
112
|
+
* UserPromptSubmit hook on that same submission. That hook drains the very
|
|
113
|
+
* message this call just deposited and re-injects it as a system-reminder —
|
|
114
|
+
* so the full content showed up twice in one turn: once as the typed prompt,
|
|
115
|
+
* once again from the mailbox. Reproduced live 2026-09-23 on both an idle and
|
|
116
|
+
* a busy target — not a busy-only race. A short pointer and the deposited
|
|
117
|
+
* body are different strings, so the hook's drain is the only place the full
|
|
118
|
+
* content appears, and a multi-line body is never typed into a live pane at
|
|
119
|
+
* all — the failure mode where only the last line survived typing cannot
|
|
120
|
+
* recur here because there is nothing but one line to type.
|
|
108
121
|
*/
|
|
109
122
|
export function sendToSessionDelivery(message, senderLabel, noReply) {
|
|
110
123
|
return noReply
|
|
111
124
|
? { typed: message, deposit: false }
|
|
112
|
-
: { typed: `[Session:${senderLabel}]
|
|
125
|
+
: { typed: `[Session:${senderLabel}] message in your mailbox — shown above if your drain hook ran, otherwise read with aibroker_receive`, deposit: true };
|
|
113
126
|
}
|
|
114
127
|
function callerLabel(req) {
|
|
115
128
|
const id = callerItermId(req);
|
|
@@ -119,11 +132,34 @@ function callerLabel(req) {
|
|
|
119
132
|
const paiName = lookupPersistentName(getAllPersistentSessionNames(), snap.id, snap.aibrokerId);
|
|
120
133
|
// Namespaced per the audit multi-writer contract: bare names collide
|
|
121
134
|
// across producers once more than one writes to the trail.
|
|
122
|
-
|
|
135
|
+
// snap.id, not snap.name: the raw iTerm name reflects the terminal's
|
|
136
|
+
// OWN dynamic title (spinner glyph + tab title for a Claude Code
|
|
137
|
+
// session — "◑ Generic session content (claude)"), not an identity.
|
|
138
|
+
return `session:${paiName ?? snap.id}`;
|
|
123
139
|
}
|
|
124
140
|
}
|
|
125
141
|
return `session:${id ?? req.tmuxPane ?? req.sessionId ?? "unknown"}`;
|
|
126
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The sender's identity for the mailbox "from" field and the `[Session:X]`
|
|
145
|
+
* prefix — bare, unlike callerLabel()'s `session:`-prefixed audit actor.
|
|
146
|
+
*
|
|
147
|
+
* paiName first, then the session's own stable id — never the raw iTerm
|
|
148
|
+
* name/title, which is the terminal's OWN dynamic display text (a spinner
|
|
149
|
+
* glyph plus tab title for a busy Claude Code session) and not an identity.
|
|
150
|
+
* A sender not found in the live snapshot (worker child process, or a bare
|
|
151
|
+
* CLI call with no pane at all) falls back to whatever id it claimed — for a
|
|
152
|
+
* worker that id is a fabricated, non-live marker (see ipc/client.ts), so it
|
|
153
|
+
* naturally fails to match any session and a reply to it is refused rather
|
|
154
|
+
* than silently reaching the wrong pane.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveSenderLabel(req, snapshots) {
|
|
157
|
+
const senderItermId = callerItermId(req);
|
|
158
|
+
const senderSnap = senderItermId ? snapshots.find((s) => s.id === senderItermId) : undefined;
|
|
159
|
+
if (senderSnap)
|
|
160
|
+
return senderSnap.paiName ?? senderSnap.id;
|
|
161
|
+
return senderItermId ?? "unknown";
|
|
162
|
+
}
|
|
127
163
|
export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
128
164
|
server.on("register_adapter", async (req) => {
|
|
129
165
|
const { name, socketPath } = req.params;
|
|
@@ -152,7 +188,11 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
152
188
|
return {
|
|
153
189
|
index: i + 1,
|
|
154
190
|
sessionId: s.id,
|
|
155
|
-
name
|
|
191
|
+
// A chosen name IS the session's name; the tab title (auto-titler,
|
|
192
|
+
// process string) is only what it falls back to. Raw title stays
|
|
193
|
+
// beside it so a lying display can be told from a lying name.
|
|
194
|
+
name: paiName ?? s.tabTitle ?? s.name,
|
|
195
|
+
tabTitle: s.tabTitle,
|
|
156
196
|
paiName,
|
|
157
197
|
atPrompt: s.atPrompt,
|
|
158
198
|
// Measured first, guessed only when there is no measurement: a pane
|
|
@@ -162,7 +202,19 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
162
202
|
active: s.id === activeItermSessionId,
|
|
163
203
|
};
|
|
164
204
|
});
|
|
165
|
-
|
|
205
|
+
// A failed osascript enumeration reads identically to a truly empty
|
|
206
|
+
// machine unless this says otherwise — see wasLastEnumerationReliable().
|
|
207
|
+
const enumerationFailed = !wasLastEnumerationReliable();
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
result: {
|
|
211
|
+
sessions,
|
|
212
|
+
enumerationFailed,
|
|
213
|
+
...(enumerationFailed
|
|
214
|
+
? { enumerationDetail: "iTerm AppleScript enumeration failed; list may be incomplete" }
|
|
215
|
+
: {}),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
166
218
|
});
|
|
167
219
|
server.on("switch", async (req) => {
|
|
168
220
|
const { target } = req.params;
|
|
@@ -230,10 +282,15 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
230
282
|
const live = snapshotAllSessions();
|
|
231
283
|
const persistentNames = getAllPersistentSessionNames();
|
|
232
284
|
const activeSnap = live.find((s) => s.id === activeItermSessionId);
|
|
285
|
+
// Same reliability flag the `sessions` handler reports — the hub is only
|
|
286
|
+
// as healthy as its ability to see what is actually running.
|
|
287
|
+
const enumerationOk = wasLastEnumerationReliable();
|
|
233
288
|
return {
|
|
234
289
|
ok: true,
|
|
235
290
|
result: {
|
|
236
291
|
version: HUB_VERSION,
|
|
292
|
+
status: enumerationOk ? "ok" : "degraded",
|
|
293
|
+
...(enumerationOk ? {} : { detail: "iTerm AppleScript session enumeration is failing; session list may be stale or incomplete" }),
|
|
237
294
|
adapters: registry.list().map(a => a.name),
|
|
238
295
|
activeSessions: live.length,
|
|
239
296
|
activeSession: activeSnap
|
|
@@ -648,6 +705,18 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
648
705
|
server.on("session_content", async (req) => {
|
|
649
706
|
const { sessionId, lines } = req.params;
|
|
650
707
|
const lineCount = lines ?? 100;
|
|
708
|
+
// Display fields follow the same authority as the `sessions` handler: the
|
|
709
|
+
// chosen (persistent) name first, then the tab title, with the raw title
|
|
710
|
+
// kept in tabTitle. The content read reports the terminal's own process
|
|
711
|
+
// name, which is not what a human calls the session.
|
|
712
|
+
const snaps = new Map(snapshotAllSessions().map((s) => [s.id, s]));
|
|
713
|
+
const persistentNames = getAllPersistentSessionNames();
|
|
714
|
+
const display = (id, raw) => {
|
|
715
|
+
const snap = snaps.get(id);
|
|
716
|
+
const paiName = lookupPersistentName(persistentNames, id, snap?.aibrokerId) ?? raw.paiName;
|
|
717
|
+
const tabTitle = snap?.tabTitle ?? null;
|
|
718
|
+
return { name: paiName ?? tabTitle ?? raw.name, tabTitle, paiName };
|
|
719
|
+
};
|
|
651
720
|
if (sessionId) {
|
|
652
721
|
const content = readSessionContent(sessionId, lineCount);
|
|
653
722
|
if (!content)
|
|
@@ -663,6 +732,7 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
663
732
|
result: {
|
|
664
733
|
session: {
|
|
665
734
|
...content,
|
|
735
|
+
...display(sessionId, content),
|
|
666
736
|
contentHash,
|
|
667
737
|
changed,
|
|
668
738
|
cachedSummary: cached?.summary ?? null,
|
|
@@ -681,6 +751,7 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
681
751
|
statusCache.touch(c.sessionId);
|
|
682
752
|
return {
|
|
683
753
|
...c,
|
|
754
|
+
...display(c.sessionId, c),
|
|
684
755
|
contentHash,
|
|
685
756
|
changed,
|
|
686
757
|
cachedSummary: cached?.summary ?? null,
|
|
@@ -850,11 +921,12 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
850
921
|
* sendToSessionDelivery() for why both matter for a beat.
|
|
851
922
|
*/
|
|
852
923
|
server.on("send_to_session", async (req) => {
|
|
853
|
-
const { target, message, noReply = false } = req.params;
|
|
854
|
-
if (!
|
|
924
|
+
const { target: targetParam, message, noReply = false } = req.params;
|
|
925
|
+
if (!targetParam)
|
|
855
926
|
return { ok: false, error: "target is required" };
|
|
856
927
|
if (!message)
|
|
857
928
|
return { ok: false, error: "message is required" };
|
|
929
|
+
const target = targetParam;
|
|
858
930
|
/**
|
|
859
931
|
* `peer/session` goes to that machine, everything else stays here.
|
|
860
932
|
*
|
|
@@ -873,38 +945,30 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
873
945
|
: { ok: false, error: forwarded.error ?? "the peer refused it" };
|
|
874
946
|
}
|
|
875
947
|
}
|
|
876
|
-
const snapshots = snapshotAllSessions();
|
|
877
948
|
// Enrich with persistent names so target-by-name matches a renamed session
|
|
878
949
|
// (tmux names key on the durable @aibroker_id, not the volatile pane id).
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
let itermSessionId = null;
|
|
884
|
-
let resolvedName = null;
|
|
885
|
-
const asNumber = parseInt(target, 10);
|
|
886
|
-
if (!Number.isNaN(asNumber) && String(asNumber) === target.trim()) {
|
|
887
|
-
// Numeric index (1-based)
|
|
888
|
-
const snap = snapshots[asNumber - 1];
|
|
889
|
-
if (snap) {
|
|
890
|
-
itermSessionId = snap.id;
|
|
891
|
-
resolvedName = snap.paiName ?? snap.name;
|
|
950
|
+
function withPersistentNames(snaps) {
|
|
951
|
+
const persistentNames = getAllPersistentSessionNames();
|
|
952
|
+
for (const snap of snaps) {
|
|
953
|
+
snap.paiName = lookupPersistentName(persistentNames, snap.id, snap.aibrokerId);
|
|
892
954
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
955
|
+
return snaps;
|
|
956
|
+
}
|
|
957
|
+
function resolveTarget(snaps) {
|
|
958
|
+
const asNumber = parseInt(target, 10);
|
|
959
|
+
if (!Number.isNaN(asNumber) && String(asNumber) === target.trim()) {
|
|
960
|
+
// Numeric index (1-based)
|
|
961
|
+
const snap = snaps[asNumber - 1];
|
|
962
|
+
return snap ? { id: snap.id, name: snap.paiName ?? snap.name } : { id: null, name: null };
|
|
900
963
|
}
|
|
901
|
-
|
|
964
|
+
if (/^[0-9A-Fa-f-]{20,}$/.test(target)) {
|
|
965
|
+
// Looks like an iTerm UUID — use directly if it exists
|
|
966
|
+
const snap = snaps.find((s) => s.id === target);
|
|
967
|
+
if (snap)
|
|
968
|
+
return { id: snap.id, name: snap.paiName ?? snap.name };
|
|
902
969
|
// Trust the caller — they may have a valid ID not yet in the snapshot
|
|
903
|
-
|
|
904
|
-
resolvedName = target;
|
|
970
|
+
return { id: target, name: target };
|
|
905
971
|
}
|
|
906
|
-
}
|
|
907
|
-
else {
|
|
908
972
|
// Name match, case-insensitive, preferring paiName over the raw name.
|
|
909
973
|
//
|
|
910
974
|
// Ranked rather than first-match. A substring search over every tab will
|
|
@@ -920,31 +984,68 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
920
984
|
// `prefer` is what keeps a live Claude session ahead of a shell, and
|
|
921
985
|
// substring is enabled here (unlike dispatch) because a human typing a
|
|
922
986
|
// target expects "clickr" to find "Clickr (node)".
|
|
923
|
-
const best = matchSession([target],
|
|
987
|
+
const best = matchSession([target], snaps, {
|
|
924
988
|
kinds: ["exact", "normalised", "substring"],
|
|
925
989
|
prefer: (s) => (isClaudeSession(s.id) ? 1 : 0),
|
|
926
990
|
});
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
991
|
+
return best ? { id: best.session.id, name: best.label } : { id: null, name: null };
|
|
992
|
+
}
|
|
993
|
+
let snapshots = withPersistentNames(snapshotAllSessions());
|
|
994
|
+
let { id: itermSessionId, name: resolvedName } = resolveTarget(snapshots);
|
|
995
|
+
/*
|
|
996
|
+
* A failed/empty enumeration must not read as "no such session". Observed
|
|
997
|
+
* live: `send_to_session` reported target "AIBroker" not found, with an
|
|
998
|
+
* EMPTY session list, while that session was live — a listing seconds
|
|
999
|
+
* later (past the snapshot cache TTL) showed 14 sessions. Retry fresh a
|
|
1000
|
+
* couple of times before giving up, since osascript failures are usually
|
|
1001
|
+
* transient (iTerm busy, a slow poll).
|
|
1002
|
+
*/
|
|
1003
|
+
for (let attempt = 0; !itermSessionId && !wasLastEnumerationReliable() && attempt < 2; attempt++) {
|
|
1004
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1005
|
+
snapshots = withPersistentNames(snapshotAllSessions({ fresh: true }));
|
|
1006
|
+
({ id: itermSessionId, name: resolvedName } = resolveTarget(snapshots));
|
|
931
1007
|
}
|
|
932
1008
|
if (!itermSessionId) {
|
|
1009
|
+
// Still nothing — but if the enumeration itself never came back
|
|
1010
|
+
// reliable, "not found" is a lie: iTerm never actually answered, so
|
|
1011
|
+
// there is no evidence the target doesn't exist. Queue by the
|
|
1012
|
+
// requested name instead of dropping it, using the id the session was
|
|
1013
|
+
// last known under (persisted names survive an enumeration outage).
|
|
1014
|
+
if (!wasLastEnumerationReliable()) {
|
|
1015
|
+
const persistedId = Object.entries(getAllPersistentSessionNames())
|
|
1016
|
+
.find(([, name]) => name.toLowerCase() === target.toLowerCase())?.[0];
|
|
1017
|
+
if (persistedId) {
|
|
1018
|
+
const senderLabel = resolveSenderLabel(req, snapshots);
|
|
1019
|
+
const evicted = depositToSessionMailbox(persistedId, senderLabel, message);
|
|
1020
|
+
if (evicted) {
|
|
1021
|
+
audit({
|
|
1022
|
+
action: "send", actor: `session:${evicted.from}`, target,
|
|
1023
|
+
outcome: "evicted", body: evicted.content,
|
|
1024
|
+
reason: "mailbox full — oldest undrained message discarded to make room",
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
audit({
|
|
1028
|
+
action: "send", actor: `session:${senderLabel}`, target,
|
|
1029
|
+
outcome: "queued", body: message,
|
|
1030
|
+
reason: "iTerm enumeration was unreliable — could not confirm the session is live, queued by its last known id",
|
|
1031
|
+
});
|
|
1032
|
+
return {
|
|
1033
|
+
ok: true,
|
|
1034
|
+
result: {
|
|
1035
|
+
sent: true, delivered: false, queued: true,
|
|
1036
|
+
sessionId: persistedId, name: target,
|
|
1037
|
+
note: "iTerm did not answer reliably, so this could not be typed. Queued in the session's mailbox by its last known name; readable with aibroker_receive once it's live.",
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
933
1042
|
return {
|
|
934
1043
|
ok: false,
|
|
935
1044
|
error: `Session "${target}" not found. Available sessions: ${snapshots.map((s, i) => `${i + 1}:${s.paiName ?? s.name}`).join(", ")}`,
|
|
936
1045
|
};
|
|
937
1046
|
}
|
|
938
1047
|
// Resolve the sender's name for the mailbox "from" label and the prefix.
|
|
939
|
-
|
|
940
|
-
// and what it looks like when it does not happen.
|
|
941
|
-
const senderItermId = callerItermId(req);
|
|
942
|
-
const senderSnap = senderItermId
|
|
943
|
-
? snapshots.find((s) => s.id === senderItermId)
|
|
944
|
-
: undefined;
|
|
945
|
-
const senderLabel = senderSnap
|
|
946
|
-
? (senderSnap.paiName ?? senderSnap.name)
|
|
947
|
-
: (senderItermId ?? "unknown");
|
|
1048
|
+
const senderLabel = resolveSenderLabel(req, snapshots);
|
|
948
1049
|
// Refuse before depositing OR typing: if the target is a shell, the message
|
|
949
1050
|
// is not merely undeliverable, it is executable. Say so plainly rather than
|
|
950
1051
|
// reporting a generic write failure.
|
|
@@ -1076,6 +1177,13 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
1076
1177
|
*
|
|
1077
1178
|
* Returns all pending messages deposited by send_to_session from other sessions.
|
|
1078
1179
|
* The queue is cleared on read (drain semantics). Returns empty array if no messages.
|
|
1180
|
+
* Destructive in one call — interactive callers (aibroker_receive) only.
|
|
1181
|
+
*
|
|
1182
|
+
* The drain-mailbox hook must NOT use this: its 1.5 s client timeout can fire
|
|
1183
|
+
* after the daemon has already emptied the mailbox, losing the message (live
|
|
1184
|
+
* 2026-09-25). The hook uses the two-phase pair below instead:
|
|
1185
|
+
* session_mailbox_peek (read without clearing) → emit → session_mailbox_ack
|
|
1186
|
+
* (clear what was peeked). A lost ack means redelivery, not loss.
|
|
1079
1187
|
*
|
|
1080
1188
|
* The caller's iTerm session ID is taken from req.itermSessionId (set by IPC server
|
|
1081
1189
|
* from the session context) or from the explicit sessionId param as a fallback.
|
|
@@ -1083,17 +1191,44 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
1083
1191
|
* iTerm2 session IDs in env vars have the form "w0t0p0:UUID". We normalize to just
|
|
1084
1192
|
* the UUID so mailbox keys match snapshot IDs.
|
|
1085
1193
|
*/
|
|
1086
|
-
|
|
1194
|
+
const mailboxSessionId = (req) => {
|
|
1087
1195
|
const { sessionId: explicitSessionId } = req.params;
|
|
1088
1196
|
const rawId = req.itermSessionId ?? explicitSessionId ?? req.sessionId;
|
|
1089
|
-
if (!rawId)
|
|
1197
|
+
if (!rawId)
|
|
1198
|
+
return undefined;
|
|
1199
|
+
// Normalize "w0t0p0:UUID" → "UUID"
|
|
1200
|
+
return rawId.includes(":") ? rawId.split(":").pop() : rawId;
|
|
1201
|
+
};
|
|
1202
|
+
server.on("session_mailbox_receive", async (req) => {
|
|
1203
|
+
const itermSessionId = mailboxSessionId(req);
|
|
1204
|
+
if (!itermSessionId) {
|
|
1090
1205
|
return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
|
|
1091
1206
|
}
|
|
1092
|
-
// Normalize "w0t0p0:UUID" → "UUID"
|
|
1093
|
-
const itermSessionId = rawId.includes(":") ? rawId.split(":").pop() : rawId;
|
|
1094
1207
|
const messages = drainSessionMailbox(itermSessionId);
|
|
1095
1208
|
return { ok: true, result: { messages, sessionId: itermSessionId } };
|
|
1096
1209
|
});
|
|
1210
|
+
/** Phase 1 of the hook's drain: return the pending messages WITHOUT clearing. */
|
|
1211
|
+
server.on("session_mailbox_peek", async (req) => {
|
|
1212
|
+
const itermSessionId = mailboxSessionId(req);
|
|
1213
|
+
if (!itermSessionId) {
|
|
1214
|
+
return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
|
|
1215
|
+
}
|
|
1216
|
+
return { ok: true, result: { messages: peekSessionMailbox(itermSessionId), sessionId: itermSessionId } };
|
|
1217
|
+
});
|
|
1218
|
+
/**
|
|
1219
|
+
* Phase 2 of the hook's drain: clear what was peeked, return how many went.
|
|
1220
|
+
* An optional count clears only the oldest N, so a message deposited after
|
|
1221
|
+
* the peek survives for the next one.
|
|
1222
|
+
*/
|
|
1223
|
+
server.on("session_mailbox_ack", async (req) => {
|
|
1224
|
+
const itermSessionId = mailboxSessionId(req);
|
|
1225
|
+
if (!itermSessionId) {
|
|
1226
|
+
return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
|
|
1227
|
+
}
|
|
1228
|
+
const { count } = req.params;
|
|
1229
|
+
const cleared = drainSessionMailbox(itermSessionId, count).length;
|
|
1230
|
+
return { ok: true, result: { cleared, sessionId: itermSessionId } };
|
|
1231
|
+
});
|
|
1097
1232
|
// ── Unified MCP Support ──
|
|
1098
1233
|
/**
|
|
1099
1234
|
* adapter_call — Proxy an IPC call to a named adapter through the hub.
|
|
@@ -1465,7 +1600,16 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
1465
1600
|
if (req.tmuxPane) {
|
|
1466
1601
|
const tmuxPane = req.tmuxPane;
|
|
1467
1602
|
const durableId = aibrokerIdForPane(tmuxPane) ?? tmuxPane;
|
|
1468
|
-
|
|
1603
|
+
// One name, one live holder: refused here while another live session owns
|
|
1604
|
+
// it (2026-09-26 — a second tab took a live session's name, and the parked
|
|
1605
|
+
// sweeps two mornings later traced back to exactly that write).
|
|
1606
|
+
const claim = setPersistentSessionName(durableId, name);
|
|
1607
|
+
if (!claim.ok) {
|
|
1608
|
+
return {
|
|
1609
|
+
ok: false,
|
|
1610
|
+
error: `Name "${name}" is held by live session ${claim.heldBy?.slice(0, 8) ?? "unknown"} — release it there first (close that tab, or rename it).`,
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1469
1613
|
setSessionTitle(tmuxPane, name);
|
|
1470
1614
|
manager.updateName(durableId, name);
|
|
1471
1615
|
// Visible surfaces: if this pane is being viewed in an iTerm tab, set the
|
|
@@ -1516,6 +1660,24 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
1516
1660
|
log(`rename: caller claimed session ${claimedId.slice(0, 8)}, which is not open; ` +
|
|
1517
1661
|
`using ${itermSessionId.slice(0, 8)} found via tty ${req.callerTty}`);
|
|
1518
1662
|
}
|
|
1663
|
+
// One name, one live holder. The claim is checked BEFORE any surface is
|
|
1664
|
+
// written: on 2026-09-26 21:07 a second tab registered a name a live session
|
|
1665
|
+
// had held for days, the store then answered the name with both ids, and a
|
|
1666
|
+
// daemon restart bound dispatch to the newcomer — which was at a shell by
|
|
1667
|
+
// morning, so both daily sweeps parked without the real session ever being
|
|
1668
|
+
// asked. Refused here, the existing holder keeps the name untouched.
|
|
1669
|
+
let nameClaim = { ok: true };
|
|
1670
|
+
if (itermSessionId) {
|
|
1671
|
+
nameClaim = setPersistentSessionName(itermSessionId, name);
|
|
1672
|
+
if (!nameClaim.ok) {
|
|
1673
|
+
log(`rename: refused — name "${name}" held by live session ${nameClaim.heldBy?.slice(0, 8)}`);
|
|
1674
|
+
return {
|
|
1675
|
+
ok: false,
|
|
1676
|
+
error: `Name "${name}" is held by live session ${nameClaim.heldBy?.slice(0, 8) ?? "unknown"} — release it there first (close that tab, or rename it).`,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
log(`Persisted name "${name}" for iTerm session ${itermSessionId.slice(0, 8)}`);
|
|
1680
|
+
}
|
|
1519
1681
|
// Update in hub's session manager
|
|
1520
1682
|
if (itermSessionId) {
|
|
1521
1683
|
// updateName searches by backendSessionId (iTerm2 UUID)
|
|
@@ -1526,12 +1688,6 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
|
|
|
1526
1688
|
if (session)
|
|
1527
1689
|
manager.updateName(session.id, name);
|
|
1528
1690
|
}
|
|
1529
|
-
// Persist the user-chosen name so it survives daemon restarts and
|
|
1530
|
-
// can be re-asserted after Claude Code's auto-title overwrites it.
|
|
1531
|
-
if (itermSessionId) {
|
|
1532
|
-
setPersistentSessionName(itermSessionId, name);
|
|
1533
|
-
log(`Persisted name "${name}" for iTerm session ${itermSessionId.slice(0, 8)}`);
|
|
1534
|
-
}
|
|
1535
1691
|
// Set iTerm2 visuals directly if we know the session
|
|
1536
1692
|
if (itermSessionId) {
|
|
1537
1693
|
setItermSessionVar(itermSessionId, name);
|