aibroker 0.61.8 → 0.62.3

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 (76) hide show
  1. package/dist/adapters/iterm/core.d.ts +8 -0
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +77 -17
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/adapters/iterm/sessions.d.ts +6 -0
  6. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  7. package/dist/adapters/iterm/sessions.js +25 -2
  8. package/dist/adapters/iterm/sessions.js.map +1 -1
  9. package/dist/adapters/pailot/gateway.d.ts +9 -0
  10. package/dist/adapters/pailot/gateway.d.ts.map +1 -1
  11. package/dist/adapters/pailot/gateway.js +60 -14
  12. package/dist/adapters/pailot/gateway.js.map +1 -1
  13. package/dist/adapters/pailot/message-queue.d.ts +10 -0
  14. package/dist/adapters/pailot/message-queue.d.ts.map +1 -1
  15. package/dist/adapters/pailot/message-queue.js +112 -12
  16. package/dist/adapters/pailot/message-queue.js.map +1 -1
  17. package/dist/adapters/pailot/mqtt-broker.d.ts +6 -0
  18. package/dist/adapters/pailot/mqtt-broker.d.ts.map +1 -1
  19. package/dist/adapters/pailot/mqtt-broker.js +8 -0
  20. package/dist/adapters/pailot/mqtt-broker.js.map +1 -1
  21. package/dist/core/claude-attach.d.ts +13 -0
  22. package/dist/core/claude-attach.d.ts.map +1 -0
  23. package/dist/core/claude-attach.js +22 -0
  24. package/dist/core/claude-attach.js.map +1 -0
  25. package/dist/core/state.d.ts +38 -5
  26. package/dist/core/state.d.ts.map +1 -1
  27. package/dist/core/state.js +107 -15
  28. package/dist/core/state.js.map +1 -1
  29. package/dist/daemon/agent-env.d.ts +7 -0
  30. package/dist/daemon/agent-env.d.ts.map +1 -0
  31. package/dist/daemon/agent-env.js +9 -0
  32. package/dist/daemon/agent-env.js.map +1 -0
  33. package/dist/daemon/cli.js +2 -0
  34. package/dist/daemon/cli.js.map +1 -1
  35. package/dist/daemon/core-handlers.d.ts +35 -3
  36. package/dist/daemon/core-handlers.d.ts.map +1 -1
  37. package/dist/daemon/core-handlers.js +143 -71
  38. package/dist/daemon/core-handlers.js.map +1 -1
  39. package/dist/daemon/forge-release.d.ts +41 -0
  40. package/dist/daemon/forge-release.d.ts.map +1 -0
  41. package/dist/daemon/forge-release.js +127 -0
  42. package/dist/daemon/forge-release.js.map +1 -0
  43. package/dist/daemon/inbound.d.ts +2 -0
  44. package/dist/daemon/inbound.d.ts.map +1 -1
  45. package/dist/daemon/inbound.js +17 -6
  46. package/dist/daemon/inbound.js.map +1 -1
  47. package/dist/daemon/index.js +2 -2
  48. package/dist/daemon/index.js.map +1 -1
  49. package/dist/daemon/mailbox-watch.js +1 -1
  50. package/dist/daemon/mailbox-watch.js.map +1 -1
  51. package/dist/daemon/manage.d.ts +147 -5
  52. package/dist/daemon/manage.d.ts.map +1 -1
  53. package/dist/daemon/manage.js +618 -72
  54. package/dist/daemon/manage.js.map +1 -1
  55. package/dist/daemon/pointer-guard.d.ts +37 -0
  56. package/dist/daemon/pointer-guard.d.ts.map +1 -0
  57. package/dist/daemon/pointer-guard.js +100 -0
  58. package/dist/daemon/pointer-guard.js.map +1 -0
  59. package/dist/daemon/session-content.d.ts +8 -0
  60. package/dist/daemon/session-content.d.ts.map +1 -1
  61. package/dist/daemon/session-content.js +28 -5
  62. package/dist/daemon/session-content.js.map +1 -1
  63. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  64. package/dist/daemon/todoist-webhook.js +8 -4
  65. package/dist/daemon/todoist-webhook.js.map +1 -1
  66. package/dist/ipc/client.d.ts.map +1 -1
  67. package/dist/ipc/client.js +37 -0
  68. package/dist/ipc/client.js.map +1 -1
  69. package/dist/mcp/index.js +14 -7
  70. package/dist/mcp/index.js.map +1 -1
  71. package/dist/types/ipc.d.ts +7 -0
  72. package/dist/types/ipc.d.ts.map +1 -1
  73. package/hooks/drain-mailbox.mjs +20 -3
  74. package/hooks/manage-hook.mjs +3 -1
  75. package/hooks/operator-manage-token.mjs +45 -0
  76. package/package.json +1 -1
@@ -23,15 +23,16 @@ 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, peekSessionMailbox } from "../core/state.js";
26
+ import { voiceConfig, setVoiceConfig, activeItermSessionId, lastRoutedSessionId, getAibpBridge, depositToSessionMailbox, drainSessionMailbox, markMailboxShown, peekSessionMailbox, setAttachAlias, hasAttachAlias, mailboxKeys, rememberIdSession, claudeSessionOfId } 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";
30
30
  import { readSessionContent, readAllSessionContent } from "./session-content.js";
31
- import { promptUnsentText, inputLineDecision } from "./manage.js";
31
+ import { typeWhenInputEmpty } from "./pointer-guard.js";
32
32
  import { statusCache, hashContent } from "../core/status-cache.js";
33
33
  import { clearAllPaiNames, findItermBundleIdImpostors } from "../adapters/iterm/core.js";
34
34
  import { snapshotAllSessions, setSessionTitle, itermViewerSessionId, aibrokerIdForPane, isClaudeSession, wasLastEnumerationReliable } from "../transport/sync-facade.js";
35
+ import { sameClaudeSession } from "../core/claude-attach.js";
35
36
  import { matchSession, resolveCallerSession } from "../core/session-match.js";
36
37
  import { audit, noteInbound } from "./audit.js";
37
38
  import { setItermSessionVar, setItermTabName, setItermBadge, revealItermSession } from "../adapters/iterm/sessions.js";
@@ -57,14 +58,6 @@ function getPackageVersion() {
57
58
  }
58
59
  }
59
60
  const HUB_VERSION = getPackageVersion();
60
- /**
61
- * How long send_to_session waits for the target to take the message.
62
- *
63
- * Much shorter than a dispatch budget: a caller is waiting on this reply, and
64
- * an unconfirmed message is not lost — it is in the mailbox. Better to say
65
- * "queued" quickly than to block for a minute to say "delivered".
66
- */
67
- const SEND_ACK_TIMEOUT_MS = 15_000;
68
61
  /**
69
62
  * Best human-readable identity for whoever made this call, for the audit trail.
70
63
  *
@@ -107,15 +100,18 @@ export function callerItermId(req) {
107
100
  * prefix is typed (the prefix tells the receiving Claude to route a reply
108
101
  * back to a sender, but a beat's label is not a registered session).
109
102
  *
110
- * When a message IS deposited, only a one-line pointer is typed — never the
111
- * full body. Typing the full body and pressing Enter submits it as an
103
+ * When a message IS deposited, only a one-line pointer is typed
104
+ * (`[Session:<sender>] <preview> — drain your mailbox (aibroker_receive) for
105
+ * the full text`) — a capped
106
+ * preview (messagePreview), never the full body. Typing the full body and pressing Enter submits it as an
112
107
  * ordinary prompt, which fires the target's own `hooks/drain-mailbox.mjs`
113
108
  * UserPromptSubmit hook on that same submission. That hook drains the very
114
109
  * message this call just deposited and re-injects it as a system-reminder —
115
110
  * so the full content showed up twice in one turn: once as the typed prompt,
116
111
  * once again from the mailbox. Reproduced live 2026-09-23 on both an idle and
117
112
  * a busy target — not a busy-only race. A short pointer and the deposited
118
- * body are different strings, so the hook's drain is the only place the full
113
+ * body are different strings (the preview is capped at ~100 chars, so a long
114
+ * body is never typed whole), so the hook's drain is the only place the full
119
115
  * content appears, and a multi-line body is never typed into a live pane at
120
116
  * all — the failure mode where only the last line survived typing cannot
121
117
  * recur here because there is nothing but one line to type.
@@ -123,12 +119,67 @@ export function callerItermId(req) {
123
119
  export function sendToSessionDelivery(message, senderLabel, noReply) {
124
120
  return noReply
125
121
  ? { typed: message, deposit: false }
126
- : { typed: `[Session:${senderLabel}] message in your mailbox — shown above if your drain hook ran, otherwise read with aibroker_receive`, deposit: true };
122
+ : { typed: mailboxPointer(senderLabel, messagePreview(message)), deposit: true };
123
+ }
124
+ /** The one line typed for a deposited message: it says the receiver must drain it. */
125
+ export function mailboxPointer(senderLabel, preview) {
126
+ return `[Session:${senderLabel}] ${preview} — drain your mailbox (aibroker_receive) for the full text`;
127
+ }
128
+ const PREVIEW_MAX = 100;
129
+ /**
130
+ * The short, single-line summary typed in the pointer — never the full body
131
+ * (see sendToSessionDelivery). Agentish v2 messages (a kind letter on the
132
+ * first line, then k=v lines) are summarised as `i=` `r=` and `g=` (T) or
133
+ * `z=`; anything else is flattened prose. Control characters (CR/LF, ESC, …)
134
+ * are dropped so nothing in it can act as a keystroke; the cut falls on a
135
+ * word boundary and ends with "…".
136
+ */
137
+ export function messagePreview(message) {
138
+ const lines = message.split(/\r?\n/);
139
+ let text = message;
140
+ if (/^[TRSQAX]$/.test(lines[0].trim())) {
141
+ const kv = new Map();
142
+ for (const l of lines.slice(1)) {
143
+ const m = /^([A-Za-z#]+)=(.*)$/.exec(l.trim());
144
+ if (m && !kv.has(m[1]))
145
+ kv.set(m[1], m[2]);
146
+ }
147
+ const parts = ["i", "r", lines[0].trim() === "T" ? "g" : "z"].map((k) => { const v = kv.get(k); return v && (k === "r" ? `r=${v}` : v); }).filter(Boolean);
148
+ if (parts.length)
149
+ text = parts.join(" ");
150
+ }
151
+ // eslint-disable-next-line no-control-regex
152
+ const flat = text.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
153
+ if (flat.length <= PREVIEW_MAX)
154
+ return flat;
155
+ const cut = flat.slice(0, PREVIEW_MAX + 1);
156
+ const sp = cut.lastIndexOf(" ");
157
+ return `${(sp > 0 ? cut.slice(0, sp) : cut.slice(0, PREVIEW_MAX)).trimEnd()}…`;
158
+ }
159
+ /**
160
+ * The pane a caller lives in. A caller whose own id has no live pane (its
161
+ * session was moved into a Claude background session, the original pane is a
162
+ * shell) is the pane that attaches to its session uuid.
163
+ */
164
+ export function snapForCaller(id, claudeSessionId, snapshots) {
165
+ const own = id ? snapshots.find((s) => s.id === id) : undefined;
166
+ if (own && !(own.isClaude === false && claudeSessionId))
167
+ return own;
168
+ const viewer = claudeSessionId ? snapshots.find((s) => s.attachUuid && sameClaudeSession(s.attachUuid, claudeSessionId)) : undefined;
169
+ return viewer ?? own;
170
+ }
171
+ /**
172
+ * The one answer to "which live pane is the caller": manage self, caller
173
+ * labels and sender labels all go through here. A caller whose own id is not a
174
+ * live Claude pane is found by its session uuid (`claude attach <short>`).
175
+ */
176
+ export function resolveCallerPane(req, snapshots = snapshotAllSessions()) {
177
+ return snapForCaller(callerItermId(req), req.claudeSessionId, snapshots);
127
178
  }
128
179
  function callerLabel(req) {
129
180
  const id = callerItermId(req);
130
181
  if (id) {
131
- const snap = snapshotAllSessions().find((s) => s.id === id);
182
+ const snap = resolveCallerPane(req);
132
183
  if (snap) {
133
184
  const paiName = lookupPersistentName(getAllPersistentSessionNames(), snap.id, snap.aibrokerId);
134
185
  // Namespaced per the audit multi-writer contract: bare names collide
@@ -154,9 +205,24 @@ function callerLabel(req) {
154
205
  * naturally fails to match any session and a reply to it is refused rather
155
206
  * than silently reaching the wrong pane.
156
207
  */
208
+ /**
209
+ * Mailbox keys for an id and/or Claude session uuid. When no attach pane is
210
+ * known for the uuid (daemon restarted, nothing sent since), scan live panes
211
+ * for its `claude attach` viewer once, which records the alias.
212
+ */
213
+ export function resolveMailboxKeys(id, uuid, scan) {
214
+ if (uuid && !hasAttachAlias(uuid)) {
215
+ try {
216
+ for (const snap of scan())
217
+ setAttachAlias(snap.id, snap.attachUuid);
218
+ }
219
+ catch { /* keep the own-id key */ }
220
+ }
221
+ return mailboxKeys(id, uuid);
222
+ }
157
223
  export function resolveSenderLabel(req, snapshots) {
158
224
  const senderItermId = callerItermId(req);
159
- const senderSnap = senderItermId ? snapshots.find((s) => s.id === senderItermId) : undefined;
225
+ const senderSnap = resolveCallerPane(req, snapshots);
160
226
  if (senderSnap)
161
227
  return senderSnap.paiName ?? senderSnap.id;
162
228
  return senderItermId ?? "unknown";
@@ -961,6 +1027,7 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
961
1027
  const persistentNames = getAllPersistentSessionNames();
962
1028
  for (const snap of snaps) {
963
1029
  snap.paiName = lookupPersistentName(persistentNames, snap.id, snap.aibrokerId);
1030
+ setAttachAlias(snap.id, snap.attachUuid);
964
1031
  }
965
1032
  return snaps;
966
1033
  }
@@ -972,8 +1039,14 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
972
1039
  return snap ? { id: snap.id, name: snap.paiName ?? snap.name } : { id: null, name: null };
973
1040
  }
974
1041
  if (/^[0-9A-Fa-f-]{20,}$/.test(target)) {
975
- // Looks like an iTerm UUID — use directly if it exists
1042
+ // Looks like an iTerm UUID — use directly if it exists. A pane that is
1043
+ // no longer Claude (or gone) whose session now runs behind an attach
1044
+ // pane is redirected to that pane.
976
1045
  const snap = snaps.find((s) => s.id === target);
1046
+ const uuid = claudeSessionOfId(target);
1047
+ const viewer = uuid && snap?.isClaude !== true ? snaps.find((s) => s.attachUuid && sameClaudeSession(s.attachUuid, uuid)) : undefined;
1048
+ if (viewer)
1049
+ return { id: viewer.id, name: viewer.paiName ?? viewer.name };
977
1050
  if (snap)
978
1051
  return { id: snap.id, name: snap.paiName ?? snap.name };
979
1052
  // Trust the caller — they may have a valid ID not yet in the snapshot
@@ -1001,6 +1074,7 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1001
1074
  return best ? { id: best.session.id, name: best.label } : { id: null, name: null };
1002
1075
  }
1003
1076
  let snapshots = withPersistentNames(snapshotAllSessions());
1077
+ rememberIdSession(callerItermId(req) ?? "", req.claudeSessionId);
1004
1078
  let { id: itermSessionId, name: resolvedName } = resolveTarget(snapshots);
1005
1079
  /*
1006
1080
  * A failed/empty enumeration must not read as "no such session". Observed
@@ -1075,42 +1149,6 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1075
1149
  `An ended session leaves its tab behind; close it, or target a session that is running.`,
1076
1150
  };
1077
1151
  }
1078
- // Refuse to type on top of unsent text already sitting in the target's
1079
- // input line — see manage.ts's inputLineDecision for the full policy
1080
- // (shared with arm()'s tick loop). This call site has no continuity
1081
- // across calls to tell a ghost from someone typing, so `sameForTicks: 1`
1082
- // pins it to "type" only when the line is empty and "skip" (never clear)
1083
- // otherwise — a single-shot send has no business ever hitting Ctrl-U on
1084
- // a session it does not own the arming loop for. The message is still
1085
- // deposited to the mailbox below, so nothing is lost — only the
1086
- // immediate typed delivery is withheld.
1087
- {
1088
- const targetContent = readSessionContent(itermSessionId, 5);
1089
- const onLine = promptUnsentText(targetContent?.content ?? "") ?? "";
1090
- const lineDecision = inputLineDecision({ text: onLine, sameForTicks: 1, idle: false });
1091
- if (lineDecision.action !== "type") {
1092
- const evicted = noReply ? undefined : depositToSessionMailbox(itermSessionId, senderLabel, message);
1093
- const reason = `input line holds '${onLine}'`;
1094
- audit({
1095
- action: "send", actor: `session:${senderLabel}`, target: resolvedName ?? target,
1096
- outcome: "refused", body: message,
1097
- reason: `not typed — ${reason}`, meta: { noReply },
1098
- });
1099
- if (evicted) {
1100
- audit({
1101
- action: "send", actor: `session:${evicted.from}`, target: resolvedName ?? target,
1102
- outcome: "evicted", body: evicted.content,
1103
- reason: "mailbox full — oldest undrained message discarded to make room",
1104
- });
1105
- }
1106
- return {
1107
- ok: false,
1108
- error: noReply
1109
- ? `not typed — ${reason}. Dropped — noReply carries no mailbox fallback.`
1110
- : `not typed — ${reason}. Queued in ${resolvedName ?? target}'s mailbox instead; it can drain it with aibroker_receive.`,
1111
- };
1112
- }
1113
- }
1114
1152
  // What gets typed and whether it goes to the mailbox — see
1115
1153
  // sendToSessionDelivery() for why noReply skips both.
1116
1154
  const { typed: prefixedMessage, deposit: shouldDeposit } = sendToSessionDelivery(message, senderLabel, noReply);
@@ -1145,8 +1183,16 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1145
1183
  // of the bug it was fixing, one delivery reported as three instead of three
1146
1184
  // reported as one. An unconfirmed message is queued in the mailbox, which
1147
1185
  // is what makes a single attempt safe.
1148
- const { submitAndConfirm } = await import("./dispatch.js");
1149
- const ack = await submitAndConfirm(itermSessionId, prefixedMessage, SEND_ACK_TIMEOUT_MS, undefined, 1);
1186
+ //
1187
+ // The input line is checked first (pointer-guard.ts): typing + Enter into a
1188
+ // line the operator is half-way through submits their sentence mid-word. A
1189
+ // held line (or an unreadable pane) types nothing; the deposit above
1190
+ // already made the message recoverable and a retry types the pointer once
1191
+ // the line clears.
1192
+ const guarded = await typeWhenInputEmpty(itermSessionId, prefixedMessage, {
1193
+ multi: shouldDeposit ? (n) => mailboxPointer(senderLabel, `${n} messages`) : undefined,
1194
+ });
1195
+ const ack = guarded.typed ? guarded.ack : "held";
1150
1196
  if (ack === "ok") {
1151
1197
  const eventId = audit({
1152
1198
  action: "send", actor: `session:${senderLabel}`, target: resolvedName ?? target,
@@ -1163,9 +1209,11 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1163
1209
  // Not delivered — but not lost either. Say which, in both the audit and the
1164
1210
  // result, because "queued" and "delivered" are different promises and the
1165
1211
  // caller is entitled to know which one it got.
1166
- const reason = ack === "unreadable"
1167
- ? "could not read the target terminal, so submission could not be confirmed"
1168
- : "typed but never observed leaving the input box — the target may be mid-task";
1212
+ const reason = !guarded.typed
1213
+ ? guarded.reason
1214
+ : ack === "unreadable"
1215
+ ? "could not read the target terminal, so submission could not be confirmed"
1216
+ : "typed but never observed leaving the input box — the target may be mid-task";
1169
1217
  const eventId = audit({
1170
1218
  action: "send", actor: `session:${senderLabel}`, target: resolvedName ?? target,
1171
1219
  outcome: "queued", body: message, reason, meta: { noReply },
@@ -1209,21 +1257,34 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1209
1257
  // Normalize "w0t0p0:UUID" → "UUID"
1210
1258
  return rawId.includes(":") ? rawId.split(":").pop() : rawId;
1211
1259
  };
1260
+ /** Own id (if any) plus any attach pane viewing the caller's Claude session; also remembers id → session. */
1261
+ const mailboxKeysFor = (req, id) => {
1262
+ const { claudeSessionId: explicit } = req.params;
1263
+ const uuid = req.claudeSessionId ?? explicit;
1264
+ if (id)
1265
+ rememberIdSession(id, uuid);
1266
+ return resolveMailboxKeys(id, uuid, snapshotAllSessions);
1267
+ };
1268
+ /** A request names a mailbox by iTerm id, Claude session uuid, or both. */
1269
+ const hasMailboxIdentity = (req) => {
1270
+ const { claudeSessionId: explicit } = req.params;
1271
+ return !!(mailboxSessionId(req) ?? req.claudeSessionId ?? explicit);
1272
+ };
1212
1273
  server.on("session_mailbox_receive", async (req) => {
1213
1274
  const itermSessionId = mailboxSessionId(req);
1214
- if (!itermSessionId) {
1275
+ if (!hasMailboxIdentity(req)) {
1215
1276
  return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
1216
1277
  }
1217
- const messages = drainSessionMailbox(itermSessionId);
1278
+ const messages = drainSessionMailbox(mailboxKeysFor(req, itermSessionId));
1218
1279
  return { ok: true, result: { messages, sessionId: itermSessionId } };
1219
1280
  });
1220
1281
  /** Phase 1 of the hook's drain: return the pending messages WITHOUT clearing. */
1221
1282
  server.on("session_mailbox_peek", async (req) => {
1222
1283
  const itermSessionId = mailboxSessionId(req);
1223
- if (!itermSessionId) {
1284
+ if (!hasMailboxIdentity(req)) {
1224
1285
  return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
1225
1286
  }
1226
- return { ok: true, result: { messages: peekSessionMailbox(itermSessionId), sessionId: itermSessionId } };
1287
+ return { ok: true, result: { messages: peekSessionMailbox(mailboxKeysFor(req, itermSessionId)), sessionId: itermSessionId } };
1227
1288
  });
1228
1289
  /**
1229
1290
  * Phase 2 of the hook's drain: clear what was peeked, return how many went.
@@ -1232,11 +1293,11 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1232
1293
  */
1233
1294
  server.on("session_mailbox_ack", async (req) => {
1234
1295
  const itermSessionId = mailboxSessionId(req);
1235
- if (!itermSessionId) {
1296
+ if (!hasMailboxIdentity(req)) {
1236
1297
  return { ok: false, error: "Cannot determine session ID — pass sessionId param or run inside an iTerm session" };
1237
1298
  }
1238
1299
  const { count } = req.params;
1239
- const cleared = drainSessionMailbox(itermSessionId, count).length;
1300
+ const cleared = markMailboxShown(mailboxKeysFor(req, itermSessionId), count ?? Infinity);
1240
1301
  return { ok: true, result: { cleared, sessionId: itermSessionId } };
1241
1302
  });
1242
1303
  // ── Unified MCP Support ──
@@ -1665,10 +1726,16 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1665
1726
  * a stale claim can be traded for the pane the caller is really in.
1666
1727
  */
1667
1728
  const live = snapshotAllSessions();
1668
- const itermSessionId = resolveCallerSession(claimedId, req.callerTty, live);
1729
+ rememberIdSession(claimedId ?? "", req.claudeSessionId);
1730
+ // A background session's pty is not an iTerm tty, so the uuid → attach pane
1731
+ // lookup comes first; the tty is the fallback for everything else.
1732
+ const viaAttach = claimedId && !live.some((s) => s.id === claimedId) ? resolveCallerPane(req, live)?.id : undefined;
1733
+ const itermSessionId = viaAttach ?? resolveCallerSession(claimedId, req.callerTty, live);
1669
1734
  if (claimedId && itermSessionId && itermSessionId !== claimedId) {
1670
1735
  log(`rename: caller claimed session ${claimedId.slice(0, 8)}, which is not open; ` +
1671
- `using ${itermSessionId.slice(0, 8)} found via tty ${req.callerTty}`);
1736
+ (viaAttach
1737
+ ? `using ${itermSessionId.slice(0, 8)} found via claude attach ${req.claudeSessionId?.slice(0, 8)}`
1738
+ : `using ${itermSessionId.slice(0, 8)} found via tty ${req.callerTty}`));
1672
1739
  }
1673
1740
  // One name, one live holder. The claim is checked BEFORE any surface is
1674
1741
  // written: on 2026-09-26 21:07 a second tab registered a name a live session
@@ -1856,11 +1923,16 @@ export function registerCoreHandlers(server, registry, _apiBackend, manager) {
1856
1923
  * anything to be idle, so this must not depend on a session's turn.
1857
1924
  */
1858
1925
  server.on("manage", async (req) => {
1859
- const { session, arg } = req.params;
1860
- if (!session)
1861
- return { ok: false, error: "session is required" };
1862
- const { handleManage } = await import("./manage.js");
1863
- const r = await handleManage(session, arg ?? "");
1926
+ const { session, arg, goal, until, every, operator } = req.params;
1927
+ const { handleManage, resolveManageTarget, refuseAgentManage } = await import("./manage.js");
1928
+ // "self" or omitted = the caller's own session, by its iTerm id.
1929
+ const target = resolveManageTarget(session, resolveCallerPane(req)?.id ?? callerItermId(req));
1930
+ if (!target)
1931
+ return { ok: false, error: "cannot tell which session is asking — name it" };
1932
+ const refusal = refuseAgentManage(target, arg ?? "", { until }, operator === true);
1933
+ if (refusal)
1934
+ return { ok: false, error: refusal };
1935
+ const r = await handleManage(target, arg ?? "", { goal, until, every });
1864
1936
  return r.ok
1865
1937
  ? { ok: true, result: { message: r.message, managed: r.managed ?? false } }
1866
1938
  : { ok: false, error: r.message };