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.
Files changed (84) hide show
  1. package/dist/adapters/iterm/core.d.ts +12 -1
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +32 -5
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/adapters/iterm/sessions.d.ts +28 -0
  6. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  7. package/dist/adapters/iterm/sessions.js +101 -4
  8. package/dist/adapters/iterm/sessions.js.map +1 -1
  9. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  10. package/dist/adapters/pailot/gateway.js +46 -11
  11. package/dist/adapters/pailot/gateway.js.map +1 -1
  12. package/dist/core/hybrid.d.ts +10 -1
  13. package/dist/core/hybrid.d.ts.map +1 -1
  14. package/dist/core/hybrid.js +50 -15
  15. package/dist/core/hybrid.js.map +1 -1
  16. package/dist/core/persistence.d.ts +51 -2
  17. package/dist/core/persistence.d.ts.map +1 -1
  18. package/dist/core/persistence.js +45 -1
  19. package/dist/core/persistence.js.map +1 -1
  20. package/dist/core/session-match.d.ts +23 -0
  21. package/dist/core/session-match.d.ts.map +1 -1
  22. package/dist/core/session-match.js +25 -11
  23. package/dist/core/session-match.js.map +1 -1
  24. package/dist/core/state.d.ts +7 -1
  25. package/dist/core/state.d.ts.map +1 -1
  26. package/dist/core/state.js +13 -3
  27. package/dist/core/state.js.map +1 -1
  28. package/dist/daemon/cli.js +3 -0
  29. package/dist/daemon/cli.js.map +1 -1
  30. package/dist/daemon/commands.d.ts.map +1 -1
  31. package/dist/daemon/commands.js +8 -0
  32. package/dist/daemon/commands.js.map +1 -1
  33. package/dist/daemon/core-handlers.d.ts +28 -0
  34. package/dist/daemon/core-handlers.d.ts.map +1 -1
  35. package/dist/daemon/core-handlers.js +215 -59
  36. package/dist/daemon/core-handlers.js.map +1 -1
  37. package/dist/daemon/dispatch.d.ts +60 -1
  38. package/dist/daemon/dispatch.d.ts.map +1 -1
  39. package/dist/daemon/dispatch.js +389 -18
  40. package/dist/daemon/dispatch.js.map +1 -1
  41. package/dist/daemon/inbound.d.ts +4 -0
  42. package/dist/daemon/inbound.d.ts.map +1 -1
  43. package/dist/daemon/inbound.js +134 -29
  44. package/dist/daemon/inbound.js.map +1 -1
  45. package/dist/daemon/index.d.ts.map +1 -1
  46. package/dist/daemon/index.js +42 -0
  47. package/dist/daemon/index.js.map +1 -1
  48. package/dist/daemon/manage.d.ts +37 -0
  49. package/dist/daemon/manage.d.ts.map +1 -1
  50. package/dist/daemon/manage.js +179 -13
  51. package/dist/daemon/manage.js.map +1 -1
  52. package/dist/daemon/session-content.d.ts.map +1 -1
  53. package/dist/daemon/session-content.js +4 -2
  54. package/dist/daemon/session-content.js.map +1 -1
  55. package/dist/daemon/todoist-pending.d.ts +45 -0
  56. package/dist/daemon/todoist-pending.d.ts.map +1 -0
  57. package/dist/daemon/todoist-pending.js +58 -0
  58. package/dist/daemon/todoist-pending.js.map +1 -0
  59. package/dist/daemon/todoist-reply.d.ts +10 -0
  60. package/dist/daemon/todoist-reply.d.ts.map +1 -1
  61. package/dist/daemon/todoist-reply.js +1 -0
  62. package/dist/daemon/todoist-reply.js.map +1 -1
  63. package/dist/daemon/todoist-webhook.d.ts +29 -0
  64. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  65. package/dist/daemon/todoist-webhook.js +334 -209
  66. package/dist/daemon/todoist-webhook.js.map +1 -1
  67. package/dist/ipc/client.d.ts.map +1 -1
  68. package/dist/ipc/client.js +24 -3
  69. package/dist/ipc/client.js.map +1 -1
  70. package/dist/ipc/validate.d.ts +2 -0
  71. package/dist/ipc/validate.d.ts.map +1 -1
  72. package/dist/ipc/validate.js +6 -1
  73. package/dist/ipc/validate.js.map +1 -1
  74. package/dist/mcp/index.js +19 -2
  75. package/dist/mcp/index.js.map +1 -1
  76. package/dist/transport/screen.d.ts.map +1 -1
  77. package/dist/transport/screen.js +30 -4
  78. package/dist/transport/screen.js.map +1 -1
  79. package/dist/transport/sync-facade.d.ts +15 -2
  80. package/dist/transport/sync-facade.d.ts.map +1 -1
  81. package/dist/transport/sync-facade.js +16 -3
  82. package/dist/transport/sync-facade.js.map +1 -1
  83. package/hooks/drain-mailbox.mjs +34 -8
  84. 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;AAoB9D,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;;;;;;;;;GASG;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;AAgBD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,QAAQ,EAAE,eAAe,EACzB,WAAW,EAAE,UAAU,EACvB,OAAO,EAAE,oBAAoB,GAC5B,IAAI,CAy6DN"}
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}] ${message}`, deposit: true };
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
- return `session:${paiName ?? snap.name}`;
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: s.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
- return { ok: true, result: { sessions } };
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 (!target)
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
- const persistentNames = getAllPersistentSessionNames();
880
- for (const snap of snapshots) {
881
- snap.paiName = lookupPersistentName(persistentNames, snap.id, snap.aibrokerId);
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
- else if (/^[0-9A-Fa-f-]{20,}$/.test(target)) {
895
- // Looks like an iTerm UUID — use directly if it exists
896
- const snap = snapshots.find((s) => s.id === target);
897
- if (snap) {
898
- itermSessionId = snap.id;
899
- resolvedName = snap.paiName ?? snap.name;
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
- else {
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
- itermSessionId = target;
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], snapshots, {
987
+ const best = matchSession([target], snaps, {
924
988
  kinds: ["exact", "normalised", "substring"],
925
989
  prefer: (s) => (isClaudeSession(s.id) ? 1 : 0),
926
990
  });
927
- if (best) {
928
- itermSessionId = best.session.id;
929
- resolvedName = best.label;
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
- // Takes the id from either field — see callerItermId for why that matters
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
- server.on("session_mailbox_receive", async (req) => {
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
- setPersistentSessionName(durableId, name);
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);