@holon-run/agentinbox 0.4.0 → 0.6.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.
@@ -140,6 +140,12 @@ class AdapterRegistry {
140
140
  }
141
141
  return this.remoteSource.expandSubscriptionShortcut(source, input);
142
142
  }
143
+ async deriveInlinePreview(source, item) {
144
+ if (source.sourceType === "local_event") {
145
+ return null;
146
+ }
147
+ return this.remoteSource.deriveInlinePreview(source, item);
148
+ }
143
149
  status() {
144
150
  return {
145
151
  remote: this.remoteSource.status?.() ?? {},
@@ -11,8 +11,8 @@ const node_fs_1 = __importDefault(require("node:fs"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
12
  const node_os_1 = __importDefault(require("node:os"));
13
13
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
14
- const DEFAULT_ITERM2_SAMPLE_DELAY_MS = 250;
15
- const DEFAULT_TMUX_SAMPLE_DELAY_MS = 250;
14
+ const DEFAULT_ITERM2_SAMPLE_DELAY_MS = 900;
15
+ const DEFAULT_TMUX_SAMPLE_DELAY_MS = 900;
16
16
  const ACTIVE_BUFFER_MARKERS = [
17
17
  "esc to interrupt",
18
18
  "Working (",
@@ -170,6 +170,96 @@ class Iterm2TerminalStateProbe {
170
170
  if (!sessionId) {
171
171
  return { presence: "unknown", busy: "unknown" };
172
172
  }
173
+ // Try Python API probe first (if enabled)
174
+ if (this.options.enablePythonProbe !== false) {
175
+ const pythonResult = await this.tryPythonApiProbe(sessionId, target.runtimeKind);
176
+ if (pythonResult && pythonResult.presence !== "unknown") {
177
+ return pythonResult;
178
+ }
179
+ }
180
+ // Fallback to command-line tools
181
+ return this.checkWithCommandLineTools(sessionId);
182
+ }
183
+ async tryPythonApiProbe(sessionId, runtimeKind) {
184
+ try {
185
+ const scriptPath = this.options.pythonScriptPath ?? resolvePythonProbeScript();
186
+ const result = await this.execAsync("python3", [scriptPath, sessionId], {
187
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
188
+ });
189
+ const data = JSON.parse(result.stdout);
190
+ if (data.status === "gone") {
191
+ return { presence: "gone", busy: "unknown" };
192
+ }
193
+ if (data.status === "available") {
194
+ // If we have cursor and lines data, use cursor-aware detection
195
+ if (data.cursor && data.lines) {
196
+ const bufferTail = data.lines.join("\n");
197
+ const cursorPosition = { x: data.cursor.x, y: data.cursor.y };
198
+ const screenHeight = data.screen_height;
199
+ const startLine = data.start_line ?? 0;
200
+ // Perform cursor-aware typing detection
201
+ const busyStatus = evaluatePythonCursorAwareTypingPrompt(bufferTail, cursorPosition, screenHeight, startLine, runtimeKind);
202
+ // If cursor-aware detection indicates busy, return immediately
203
+ if (busyStatus === "busy") {
204
+ return { presence: "available", busy: "busy" };
205
+ }
206
+ // If cursor-aware detection is certain it's not busy, don't apply prompt heuristics
207
+ if (busyStatus === "not_busy") {
208
+ // Still check for buffer changes and active markers
209
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
210
+ const secondResult = await this.execAsync("python3", [scriptPath, sessionId], {
211
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
212
+ });
213
+ const secondData = JSON.parse(secondResult.stdout);
214
+ if (secondData.status === "available" && secondData.lines) {
215
+ const secondBufferTail = secondData.lines.join("\n");
216
+ if (bufferTail !== secondBufferTail) {
217
+ return { presence: "available", busy: "busy" };
218
+ }
219
+ }
220
+ // Check for active buffer markers
221
+ if (containsActiveBufferMarker(bufferTail)) {
222
+ return { presence: "available", busy: "busy" };
223
+ }
224
+ return { presence: "available", busy: "idle" };
225
+ }
226
+ // If cursor-aware detection is uncertain, fall back to buffer change detection only
227
+ // Do NOT use generic typing prompts to avoid false positives (see #116)
228
+ if (busyStatus === "unknown") {
229
+ await (this.options.sleep ?? sleep)(this.options.sampleDelayMs ?? DEFAULT_ITERM2_SAMPLE_DELAY_MS);
230
+ const secondResult = await this.execAsync("python3", [scriptPath, sessionId], {
231
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
232
+ });
233
+ const secondData = JSON.parse(secondResult.stdout);
234
+ if (secondData.status === "available" && secondData.lines) {
235
+ const secondBufferTail = secondData.lines.join("\n");
236
+ if (bufferTail !== secondBufferTail) {
237
+ return { presence: "available", busy: "busy" };
238
+ }
239
+ }
240
+ // Check for active buffer markers
241
+ if (containsActiveBufferMarker(bufferTail)) {
242
+ return { presence: "available", busy: "busy" };
243
+ }
244
+ // Return unknown when cursor-aware detection is uncertain
245
+ // Do NOT fall back to generic typing prompts to avoid false positives
246
+ return { presence: "available", busy: "unknown" };
247
+ }
248
+ // This should never be reached as all cases are handled above
249
+ return { presence: "available", busy: "unknown" };
250
+ }
251
+ // If we don't have cursor data, return unknown to trigger fallback
252
+ return null;
253
+ }
254
+ // Any other status means we should fall back
255
+ return null;
256
+ }
257
+ catch {
258
+ // Python API probe failed, fall back to command-line tools
259
+ return null;
260
+ }
261
+ }
262
+ async checkWithCommandLineTools(sessionId) {
173
263
  let it2api;
174
264
  try {
175
265
  it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
@@ -197,9 +287,7 @@ class Iterm2TerminalStateProbe {
197
287
  if (containsActiveBufferMarker(second)) {
198
288
  return { presence: "available", busy: "busy" };
199
289
  }
200
- if (hasVisibleTypingPrompt(second)) {
201
- return { presence: "available", busy: "busy" };
202
- }
290
+ // Do NOT use generic typing prompts to avoid false positives (see #116)
203
291
  return { presence: "available", busy: "unknown" };
204
292
  }
205
293
  async checkSessionPresence(it2api, sessionId) {
@@ -271,21 +359,33 @@ class TmuxTerminalStateProbe {
271
359
  if (containsActiveBufferMarker(second)) {
272
360
  return { presence: "available", busy: "busy" };
273
361
  }
274
- if (paneState.active && hasVisibleTypingPrompt(second)) {
362
+ // Use cursor-aware detection when available
363
+ const cursorHint = evaluateCursorAwareTypingPrompt(target, paneState, second);
364
+ if (cursorHint === "busy") {
275
365
  return { presence: "available", busy: "busy" };
276
366
  }
367
+ // Do NOT fall back to generic typing prompts when cursorHint is unknown to avoid false positives (see #116)
277
368
  return { presence: "available", busy: "unknown" };
278
369
  }
279
370
  async readPaneState(paneId) {
280
371
  try {
281
- const result = await this.execAsync("tmux", ["display-message", "-p", "-t", paneId, "#{pane_active} #{pane_dead}"]);
282
- const [active, dead] = result.stdout.trim().split(/\s+/, 2);
372
+ const result = await this.execAsync("tmux", [
373
+ "display-message",
374
+ "-p",
375
+ "-t",
376
+ paneId,
377
+ "#{pane_active} #{pane_dead} #{cursor_x} #{cursor_y} #{pane_height}",
378
+ ]);
379
+ const [active, dead, cursorX, cursorY, height] = result.stdout.trim().split(/\s+/, 5);
283
380
  if (!active || !dead) {
284
381
  return "unknown";
285
382
  }
286
383
  return {
287
384
  active: active === "1",
288
385
  dead: dead === "1",
386
+ cursorX: parseOptionalInt(cursorX),
387
+ cursorY: parseOptionalInt(cursorY),
388
+ height: parseOptionalInt(height),
289
389
  };
290
390
  }
291
391
  catch (error) {
@@ -309,7 +409,7 @@ exports.TmuxTerminalStateProbe = TmuxTerminalStateProbe;
309
409
  function containsActiveBufferMarker(bufferTail) {
310
410
  return ACTIVE_BUFFER_MARKERS.some((marker) => bufferTail.includes(marker));
311
411
  }
312
- function hasVisibleTypingPrompt(bufferTail) {
412
+ function hasVisibleTypingPrompt(bufferTail, prefixes = TYPING_PROMPT_PREFIXES) {
313
413
  const lines = bufferTail
314
414
  .split("\n")
315
415
  .map((line) => line.trimEnd())
@@ -318,13 +418,64 @@ function hasVisibleTypingPrompt(bufferTail) {
318
418
  if (!lastLine) {
319
419
  return false;
320
420
  }
321
- for (const prefix of TYPING_PROMPT_PREFIXES) {
421
+ for (const prefix of prefixes) {
322
422
  if (lastLine.startsWith(prefix) && lastLine.slice(prefix.length).trim().length > 0) {
323
423
  return true;
324
424
  }
325
425
  }
326
426
  return false;
327
427
  }
428
+ function evaluateCursorAwareTypingPrompt(target, paneState, bufferTail) {
429
+ const prefix = runtimeTypingPromptPrefix(target.runtimeKind);
430
+ if (!prefix) {
431
+ return "unknown";
432
+ }
433
+ if (paneState.cursorX == null || paneState.cursorY == null || paneState.height == null) {
434
+ return "unknown";
435
+ }
436
+ const lines = bufferTail
437
+ .replace(/\r/g, "")
438
+ .split("\n")
439
+ .map((line) => line.trimEnd());
440
+ if (lines.length === 0) {
441
+ return "unknown";
442
+ }
443
+ const captureStartRow = Math.max(0, paneState.height - lines.length);
444
+ const lineIndex = paneState.cursorY - captureStartRow;
445
+ if (lineIndex < 0 || lineIndex >= lines.length) {
446
+ return "unknown";
447
+ }
448
+ const line = lines[lineIndex] ?? "";
449
+ if (!line.startsWith(prefix)) {
450
+ return "not_busy";
451
+ }
452
+ return paneState.cursorX > prefix.length ? "busy" : "not_busy";
453
+ }
454
+ function runtimeTypingPromptPrefix(runtimeKind) {
455
+ if (runtimeKind === "codex") {
456
+ return "› ";
457
+ }
458
+ if (runtimeKind === "claude_code") {
459
+ return "❯ ";
460
+ }
461
+ return null;
462
+ }
463
+ function typingPromptPrefixesForRuntime(runtimeKind) {
464
+ if (runtimeKind === "codex") {
465
+ return ["› "];
466
+ }
467
+ if (runtimeKind === "claude_code") {
468
+ return ["❯ ", "› "];
469
+ }
470
+ return TYPING_PROMPT_PREFIXES;
471
+ }
472
+ function parseOptionalInt(value) {
473
+ if (!value) {
474
+ return null;
475
+ }
476
+ const parsed = Number.parseInt(value, 10);
477
+ return Number.isFinite(parsed) ? parsed : null;
478
+ }
328
479
  function normalizeBufferTail(buffer) {
329
480
  const normalized = buffer
330
481
  .replace(/\r/g, "")
@@ -643,3 +794,79 @@ function parseIterm2SessionIds(stdout) {
643
794
  })
644
795
  .filter((id) => id !== null);
645
796
  }
797
+ function resolvePythonProbeScript(override) {
798
+ if (override) {
799
+ return override;
800
+ }
801
+ const scriptRelativePath = node_path_1.default.join("scripts", "iterm2_cursor_probe.py");
802
+ const seen = new Set();
803
+ const candidates = [];
804
+ const addCandidate = (candidate) => {
805
+ const normalized = node_path_1.default.normalize(candidate);
806
+ if (!seen.has(normalized)) {
807
+ seen.add(normalized);
808
+ candidates.push(normalized);
809
+ }
810
+ };
811
+ // Search upward from the current module's directory
812
+ let currentDir = __dirname;
813
+ for (let i = 0; i < 5; i++) { // Limit upward search depth
814
+ addCandidate(node_path_1.default.join(currentDir, scriptRelativePath));
815
+ addCandidate(node_path_1.default.join(currentDir, "dist", scriptRelativePath));
816
+ addCandidate(node_path_1.default.join(currentDir, "dist", "src", scriptRelativePath));
817
+ const parentDir = node_path_1.default.dirname(currentDir);
818
+ if (parentDir === currentDir) {
819
+ break;
820
+ }
821
+ currentDir = parentDir;
822
+ }
823
+ // Check process working directory and its dist paths
824
+ addCandidate(node_path_1.default.join(process.cwd(), scriptRelativePath));
825
+ addCandidate(node_path_1.default.join(process.cwd(), "dist", scriptRelativePath));
826
+ addCandidate(node_path_1.default.join(process.cwd(), "dist", "src", scriptRelativePath));
827
+ // Check all candidates
828
+ for (const candidate of candidates) {
829
+ if (node_fs_1.default.existsSync(candidate)) {
830
+ return candidate;
831
+ }
832
+ }
833
+ throw new Error(`Unable to locate iterm2_cursor_probe.py. Checked: ${candidates.join(", ")}. ` +
834
+ "Ensure scripts/iterm2_cursor_probe.py is shipped with the package or copied into dist/ during build.");
835
+ }
836
+ function evaluatePythonCursorAwareTypingPrompt(bufferTail, cursorPosition, screenHeight, startLine, runtimeKind) {
837
+ const lines = bufferTail.split("\n");
838
+ const lastLineIndex = lines.length - 1;
839
+ // Cursor coordinates are in full-screen rows, while bufferTail only contains
840
+ // the lines from startLine to end of screen. First ensure the cursor is on the
841
+ // terminal's last visible row, then map that row into the captured window.
842
+ if (cursorPosition.y !== screenHeight - 1) {
843
+ return "not_busy";
844
+ }
845
+ // Map the cursor position into the captured buffer window using startLine
846
+ const cursorRowInBuffer = cursorPosition.y - startLine;
847
+ // If the last visible screen row is not represented by the captured tail,
848
+ // or does not map to the last captured line, we cannot evaluate reliably.
849
+ if (cursorRowInBuffer < 0 || cursorRowInBuffer > lastLineIndex) {
850
+ return "unknown";
851
+ }
852
+ if (cursorRowInBuffer !== lastLineIndex) {
853
+ return "not_busy";
854
+ }
855
+ const lastLine = lines[lastLineIndex] || "";
856
+ const promptPrefix = runtimeTypingPromptPrefix(runtimeKind);
857
+ if (!promptPrefix) {
858
+ return "unknown";
859
+ }
860
+ // Check if the last line starts with the runtime-specific prompt prefix
861
+ if (lastLine.startsWith(promptPrefix)) {
862
+ // Check if cursor is after the prompt prefix
863
+ if (cursorPosition.x > promptPrefix.length) {
864
+ const afterPrompt = lastLine.slice(promptPrefix.length).trim();
865
+ // If there's content after the prompt, user is typing
866
+ if (afterPrompt.length > 0) {
867
+ return "busy";
868
+ }
869
+ }
870
+ }
871
+ return "not_busy";
872
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ActivationDispatcher = exports.AgentInboxService = void 0;
4
+ const node_crypto_1 = require("node:crypto");
4
5
  const backend_1 = require("./backend");
5
6
  const util_1 = require("./util");
6
7
  const source_schema_1 = require("./source_schema");
@@ -1407,15 +1408,26 @@ class AgentInboxService {
1407
1408
  const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
1408
1409
  if (!state) {
1409
1410
  const inbox = this.ensureInboxForAgent(buffer.agentId);
1411
+ const unackedItems = this.store.listInboxItems(inbox.inboxId, { includeAcked: false }).map((item) => ({
1412
+ itemId: item.itemId,
1413
+ sourceId: item.sourceId,
1414
+ sourceNativeId: item.sourceNativeId,
1415
+ eventVariant: item.eventVariant,
1416
+ inboxId: item.inboxId,
1417
+ occurredAt: item.occurredAt,
1418
+ metadata: item.metadata,
1419
+ rawPayload: item.rawPayload,
1420
+ deliveryHandle: item.deliveryHandle,
1421
+ }));
1410
1422
  const dispatched = await this.dispatchActivationTarget({
1411
1423
  agentId: buffer.agentId,
1412
1424
  targetId: buffer.targetId,
1413
1425
  newItemCount: entries.length,
1414
- totalUnackedCount: this.store.countInboxItems(inbox.inboxId, false),
1415
- summary: entries[0]?.summary ?? null,
1426
+ totalUnackedCount: unackedItems.length,
1427
+ summary: latestSummary(entries),
1416
1428
  subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1417
1429
  sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1418
- items: entries.map((entry) => entry.item),
1430
+ items: unackedItems,
1419
1431
  });
1420
1432
  if (dispatched === "retryable_failure") {
1421
1433
  this.upsertDirtyDispatchState(buffer.agentId, buffer.targetId, entries);
@@ -1427,8 +1439,9 @@ class AgentInboxService {
1427
1439
  targetId: buffer.targetId,
1428
1440
  status: "dirty",
1429
1441
  leaseExpiresAt: state.leaseExpiresAt,
1442
+ lastNotifiedFingerprint: state.lastNotifiedFingerprint,
1430
1443
  pendingNewItemCount: state.pendingNewItemCount + entries.length,
1431
- pendingSummary: state.pendingSummary ?? entries[0]?.summary ?? null,
1444
+ pendingSummary: latestSummary(entries) ?? state.pendingSummary,
1432
1445
  pendingSubscriptionIds: uniqueSortedNullable([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
1433
1446
  pendingSourceIds: uniqueSorted([...state.pendingSourceIds, ...entries.map((entry) => entry.sourceId)]),
1434
1447
  updatedAt: (0, util_1.nowIso)(),
@@ -1477,6 +1490,20 @@ class AgentInboxService {
1477
1490
  if (reason === "ack" && state.status !== "dirty") {
1478
1491
  return;
1479
1492
  }
1493
+ if (reason === "lease" && state.status === "notified") {
1494
+ const target = this.getActivationTarget(targetId);
1495
+ if (target.kind !== "terminal") {
1496
+ // Webhook targets keep the existing lease-based behavior.
1497
+ }
1498
+ else {
1499
+ this.store.upsertActivationDispatchState({
1500
+ ...state,
1501
+ leaseExpiresAt: new Date(Date.now() + target.notifyLeaseMs).toISOString(),
1502
+ updatedAt: (0, util_1.nowIso)(),
1503
+ });
1504
+ return;
1505
+ }
1506
+ }
1480
1507
  const dispatched = await this.dispatchActivationTarget({
1481
1508
  agentId,
1482
1509
  targetId,
@@ -1515,9 +1542,11 @@ class AgentInboxService {
1515
1542
  if (target.status === "offline") {
1516
1543
  return "offline";
1517
1544
  }
1545
+ const state = this.store.getActivationDispatchState(input.agentId, input.targetId);
1518
1546
  const inbox = this.ensureInboxForAgent(input.agentId);
1519
1547
  const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
1520
1548
  const totalUnackedCount = input.totalUnackedCount ?? this.store.countInboxItems(inbox.inboxId, false);
1549
+ let terminalPrompt = null;
1521
1550
  try {
1522
1551
  if (target.kind === "terminal") {
1523
1552
  const gate = await this.activationGate.evaluate(target);
@@ -1532,6 +1561,7 @@ class AgentInboxService {
1532
1561
  targetId: input.targetId,
1533
1562
  status: "dirty",
1534
1563
  leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1564
+ lastNotifiedFingerprint: state?.lastNotifiedFingerprint ?? null,
1535
1565
  pendingNewItemCount: input.newItemCount,
1536
1566
  pendingSummary: input.summary,
1537
1567
  pendingSubscriptionIds: uniqueSortedNullable(input.subscriptionIds),
@@ -1540,11 +1570,44 @@ class AgentInboxService {
1540
1570
  });
1541
1571
  return "deferred";
1542
1572
  }
1573
+ let preview = null;
1574
+ if (totalUnackedCount === 1 && input.items.length === 1) {
1575
+ const singleItem = input.items[0];
1576
+ const source = this.store.getSource(singleItem.sourceId);
1577
+ if (source) {
1578
+ try {
1579
+ const sourcePreview = await this.adapters.deriveInlinePreview(source, singleItem);
1580
+ preview = typeof sourcePreview === "string" ? (0, terminal_1.normalizeInlinePreviewText)(sourcePreview) : null;
1581
+ }
1582
+ catch {
1583
+ preview = null;
1584
+ }
1585
+ }
1586
+ preview ??= (0, terminal_1.deriveInlineItemPreview)(singleItem, input.summary);
1587
+ }
1543
1588
  const prompt = (0, terminal_1.renderAgentPrompt)({
1544
1589
  inboxId: inbox.inboxId,
1545
1590
  totalUnackedCount,
1546
1591
  summary: input.summary,
1592
+ preview,
1547
1593
  });
1594
+ terminalPrompt = prompt;
1595
+ const promptFingerprint = terminalReminderFingerprint(prompt, input.items);
1596
+ if (state?.status === "dirty" && state.lastNotifiedFingerprint === promptFingerprint) {
1597
+ this.store.upsertActivationDispatchState({
1598
+ agentId: input.agentId,
1599
+ targetId: input.targetId,
1600
+ status: "notified",
1601
+ leaseExpiresAt: new Date(Date.now() + target.notifyLeaseMs).toISOString(),
1602
+ lastNotifiedFingerprint: promptFingerprint,
1603
+ pendingNewItemCount: 0,
1604
+ pendingSummary: null,
1605
+ pendingSubscriptionIds: [],
1606
+ pendingSourceIds: [],
1607
+ updatedAt: (0, util_1.nowIso)(),
1608
+ });
1609
+ return "suppressed";
1610
+ }
1548
1611
  await this.terminalDispatcher.dispatch(target, prompt);
1549
1612
  }
1550
1613
  else {
@@ -1573,6 +1636,9 @@ class AgentInboxService {
1573
1636
  targetId: input.targetId,
1574
1637
  status: "notified",
1575
1638
  leaseExpiresAt: new Date(Date.now() + target.notifyLeaseMs).toISOString(),
1639
+ lastNotifiedFingerprint: target.kind === "terminal"
1640
+ ? terminalReminderFingerprint(terminalPrompt ?? "", input.items)
1641
+ : state?.lastNotifiedFingerprint ?? null,
1576
1642
  pendingNewItemCount: 0,
1577
1643
  pendingSummary: null,
1578
1644
  pendingSubscriptionIds: [],
@@ -1602,8 +1668,9 @@ class AgentInboxService {
1602
1668
  targetId,
1603
1669
  status: "dirty",
1604
1670
  leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1671
+ lastNotifiedFingerprint: this.store.getActivationDispatchState(agentId, targetId)?.lastNotifiedFingerprint ?? null,
1605
1672
  pendingNewItemCount: entries.length,
1606
- pendingSummary: entries[0]?.summary ?? null,
1673
+ pendingSummary: latestSummary(entries),
1607
1674
  pendingSubscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1608
1675
  pendingSourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1609
1676
  updatedAt: (0, util_1.nowIso)(),
@@ -2288,6 +2355,24 @@ function summarizeActivation(inboxId, newItemCount, firstSummary) {
2288
2355
  }
2289
2356
  return `${newItemCount} new ${itemWord} in ${inboxId}`;
2290
2357
  }
2358
+ function latestSummary(entries) {
2359
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
2360
+ const summary = entries[index]?.summary;
2361
+ if (summary) {
2362
+ return summary;
2363
+ }
2364
+ }
2365
+ return null;
2366
+ }
2367
+ function terminalReminderFingerprint(prompt, items) {
2368
+ const payload = JSON.stringify({
2369
+ prompt,
2370
+ itemIds: items
2371
+ .map((item) => item.itemId)
2372
+ .sort((left, right) => left.localeCompare(right)),
2373
+ });
2374
+ return (0, node_crypto_1.createHash)("sha256").update(payload).digest("hex");
2375
+ }
2291
2376
  function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
2292
2377
  if (sourceType === "github_repo_ci") {
2293
2378
  const parts = eventVariant.split(".");
@@ -191,6 +191,16 @@ class RemoteSourceRuntime {
191
191
  source: profileInputSource(source),
192
192
  });
193
193
  }
194
+ async deriveInlinePreview(source, item) {
195
+ if (!REMOTE_SOURCE_TYPES.has(source.sourceType)) {
196
+ return null;
197
+ }
198
+ const profile = await this.profileRegistry.resolve(source, this.homeDir);
199
+ if (typeof profile.deriveInlinePreview !== "function") {
200
+ return null;
201
+ }
202
+ return profile.deriveInlinePreview(item, profileInputSource(source));
203
+ }
194
204
  async syncAll() {
195
205
  const sources = this.store
196
206
  .listSources()
@@ -95,6 +95,7 @@ function validateProfileContract(profile, sourcePath) {
95
95
  validateOptionalHook(profile.expandSubscriptionShortcut, "expandSubscriptionShortcut", sourcePath);
96
96
  validateOptionalHook(profile.deriveTrackedResource, "deriveTrackedResource", sourcePath);
97
97
  validateOptionalHook(profile.projectLifecycleSignal, "projectLifecycleSignal", sourcePath);
98
+ validateOptionalHook(profile.deriveInlinePreview, "deriveInlinePreview", sourcePath);
98
99
  }
99
100
  function validateOptionalHook(value, name, sourcePath) {
100
101
  if (value !== undefined && typeof value !== "function") {
@@ -267,6 +268,36 @@ const GITHUB_REPO_CI_PROFILE = {
267
268
  eventVariantExamples: ["workflow_run.ci.completed.failure", "workflow_run.nightly_checks.observed"],
268
269
  };
269
270
  },
271
+ deriveInlinePreview(item) {
272
+ const repoFullName = asNonEmptyString(item.metadata.repoFullName);
273
+ const workflowName = asNonEmptyString(item.metadata.name)
274
+ ?? asNonEmptyString(item.metadata.displayTitle)
275
+ ?? asNonEmptyString(item.rawPayload.name)
276
+ ?? asNonEmptyString(item.rawPayload.display_title);
277
+ const headBranch = asNonEmptyString(item.metadata.headBranch) ?? asNonEmptyString(item.rawPayload.head_branch);
278
+ const conclusion = asNonEmptyString(item.metadata.conclusion) ?? asNonEmptyString(item.rawPayload.conclusion);
279
+ const status = asNonEmptyString(item.metadata.status) ?? asNonEmptyString(item.rawPayload.status);
280
+ const parts = [];
281
+ if (workflowName) {
282
+ parts.push(workflowName);
283
+ }
284
+ else {
285
+ parts.push("Workflow run");
286
+ }
287
+ if (conclusion) {
288
+ parts.push(`completed ${conclusion}`);
289
+ }
290
+ else if (status) {
291
+ parts.push(status);
292
+ }
293
+ if (repoFullName) {
294
+ parts.push(`for ${repoFullName}`);
295
+ }
296
+ if (headBranch) {
297
+ parts.push(`on ${headBranch}`);
298
+ }
299
+ return parts.join(" ");
300
+ },
270
301
  validateConfig(source) {
271
302
  (0, github_ci_1.parseGithubCiSourceConfig)(source);
272
303
  },
package/dist/src/store.js CHANGED
@@ -729,12 +729,13 @@ class AgentInboxStore {
729
729
  upsertActivationDispatchState(state) {
730
730
  this.db.run(`
731
731
  insert into activation_dispatch_states (
732
- agent_id, target_id, status, lease_expires_at, pending_new_item_count, pending_summary,
732
+ agent_id, target_id, status, lease_expires_at, last_notified_fingerprint, pending_new_item_count, pending_summary,
733
733
  pending_subscription_ids_json, pending_source_ids_json, updated_at
734
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
734
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
735
735
  on conflict(agent_id, target_id) do update set
736
736
  status = excluded.status,
737
737
  lease_expires_at = excluded.lease_expires_at,
738
+ last_notified_fingerprint = excluded.last_notified_fingerprint,
738
739
  pending_new_item_count = excluded.pending_new_item_count,
739
740
  pending_summary = excluded.pending_summary,
740
741
  pending_subscription_ids_json = excluded.pending_subscription_ids_json,
@@ -745,6 +746,7 @@ class AgentInboxStore {
745
746
  state.targetId,
746
747
  state.status,
747
748
  state.leaseExpiresAt ?? null,
749
+ state.lastNotifiedFingerprint ?? null,
748
750
  state.pendingNewItemCount,
749
751
  state.pendingSummary ?? null,
750
752
  JSON.stringify(state.pendingSubscriptionIds),
@@ -778,6 +780,12 @@ class AgentInboxStore {
778
780
  ]);
779
781
  const inserted = this.changes() > before;
780
782
  if (inserted) {
783
+ const rowId = this.lastInsertRowId();
784
+ this.db.run(`
785
+ update inbox_items
786
+ set inbox_sequence = ?
787
+ where rowid = ? and inbox_sequence is null
788
+ `, [rowId, rowId]);
781
789
  this.persist();
782
790
  }
783
791
  return inserted;
@@ -790,14 +798,14 @@ class AgentInboxStore {
790
798
  filters.push("acked_at is null");
791
799
  }
792
800
  if (options?.afterItemId) {
793
- const anchor = this.getOne("select occurred_at, item_id from inbox_items where inbox_id = ? and item_id = ?", [inboxId, options.afterItemId]);
801
+ const anchor = this.getOne("select coalesce(inbox_sequence, rowid) as inbox_sequence from inbox_items where inbox_id = ? and item_id = ?", [inboxId, options.afterItemId]);
794
802
  if (!anchor) {
795
803
  throw new Error(`unknown inbox item: ${options.afterItemId}`);
796
804
  }
797
- filters.push("((occurred_at > ?) or (occurred_at = ? and item_id > ?))");
798
- params.push(String(anchor.occurred_at), String(anchor.occurred_at), String(anchor.item_id));
805
+ filters.push("coalesce(inbox_sequence, rowid) > ?");
806
+ params.push(Number(anchor.inbox_sequence));
799
807
  }
800
- const rows = this.getAll(`select * from inbox_items where ${filters.join(" and ")} order by occurred_at asc, item_id asc`, params);
808
+ const rows = this.getAll(`select * from inbox_items where ${filters.join(" and ")} order by coalesce(inbox_sequence, rowid) asc`, params);
801
809
  return rows.map((row) => this.mapInboxItem(row));
802
810
  }
803
811
  ackItems(inboxId, itemIds, ackedAt) {
@@ -816,7 +824,7 @@ class AgentInboxStore {
816
824
  return changes;
817
825
  }
818
826
  ackItemsThrough(inboxId, itemId, ackedAt) {
819
- const anchor = this.getOne("select occurred_at, item_id from inbox_items where inbox_id = ? and item_id = ?", [inboxId, itemId]);
827
+ const anchor = this.getOne("select coalesce(inbox_sequence, rowid) as inbox_sequence from inbox_items where inbox_id = ? and item_id = ?", [inboxId, itemId]);
820
828
  if (!anchor) {
821
829
  throw new Error(`unknown inbox item: ${itemId}`);
822
830
  }
@@ -825,8 +833,8 @@ class AgentInboxStore {
825
833
  set acked_at = ?
826
834
  where inbox_id = ?
827
835
  and acked_at is null
828
- and ((occurred_at < ?) or (occurred_at = ? and item_id <= ?))
829
- `, [ackedAt, inboxId, String(anchor.occurred_at), String(anchor.occurred_at), String(anchor.item_id)]);
836
+ and coalesce(inbox_sequence, rowid) <= ?
837
+ `, [ackedAt, inboxId, Number(anchor.inbox_sequence)]);
830
838
  const changes = this.changes();
831
839
  if (changes > 0) {
832
840
  this.persist();
@@ -1251,6 +1259,7 @@ class AgentInboxStore {
1251
1259
  targetId: String(row.target_id),
1252
1260
  status: row.status,
1253
1261
  leaseExpiresAt: row.lease_expires_at ? String(row.lease_expires_at) : null,
1262
+ lastNotifiedFingerprint: row.last_notified_fingerprint ? String(row.last_notified_fingerprint) : null,
1254
1263
  pendingNewItemCount: Number(row.pending_new_item_count),
1255
1264
  pendingSummary: row.pending_summary ? String(row.pending_summary) : null,
1256
1265
  pendingSubscriptionIds: parseJson(row.pending_subscription_ids_json),
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.TerminalDispatcher = void 0;
7
7
  exports.detectTerminalContext = detectTerminalContext;
8
8
  exports.renderAgentPrompt = renderAgentPrompt;
9
+ exports.deriveInlineItemPreview = deriveInlineItemPreview;
10
+ exports.normalizeInlinePreviewText = normalizeInlinePreviewText;
9
11
  exports.assignedAgentIdFromContext = assignedAgentIdFromContext;
10
12
  const node_child_process_1 = require("node:child_process");
11
13
  const node_util_1 = require("node:util");
@@ -49,11 +51,44 @@ function renderAgentPrompt(input) {
49
51
  const totalUnackedCount = input.totalUnackedCount ?? input.newItemCount ?? 0;
50
52
  const itemWord = totalUnackedCount === 1 ? "item" : "items";
51
53
  const base = `AgentInbox: ${totalUnackedCount} unacked ${itemWord} in inbox ${input.inboxId}.`;
54
+ if (totalUnackedCount === 1 && input.preview) {
55
+ const suffix = /(?:[.!?…]|\.{3})$/.test(input.preview) ? "" : ".";
56
+ return `${base} Preview: ${input.preview}${suffix} Read the inbox for full details if needed.`;
57
+ }
52
58
  if (input.summary) {
53
59
  return `${base} Summary: ${input.summary}. Please read the inbox, process them, and ack when finished.`;
54
60
  }
55
61
  return `${base} Please read the inbox, process them, and ack when finished.`;
56
62
  }
63
+ const MAX_INLINE_PREVIEW_INPUT_CHARS = 240;
64
+ const MAX_INLINE_PREVIEW_CHARS = 96;
65
+ const INLINE_PREVIEW_KEYS = ["message", "text", "body", "title"];
66
+ function deriveInlineItemPreview(item, summary) {
67
+ const candidates = [];
68
+ for (const key of INLINE_PREVIEW_KEYS) {
69
+ const rawPayloadValue = item.rawPayload[key];
70
+ if (typeof rawPayloadValue === "string") {
71
+ candidates.push(rawPayloadValue);
72
+ }
73
+ const metadataValue = item.metadata[key];
74
+ if (typeof metadataValue === "string") {
75
+ candidates.push(metadataValue);
76
+ }
77
+ }
78
+ if (isPreviewFriendlySummary(summary)) {
79
+ candidates.push(summary);
80
+ }
81
+ for (const candidate of candidates) {
82
+ const preview = normalizeInlinePreviewText(candidate);
83
+ if (preview) {
84
+ return preview;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ function normalizeInlinePreviewText(candidate) {
90
+ return normalizeInlinePreview(candidate);
91
+ }
57
92
  function assignedAgentIdFromContext(input) {
58
93
  const primary = normalizeOptionalString(input.runtimeSessionId)
59
94
  ?? normalizeOptionalString(input.tmuxPaneId)
@@ -176,6 +211,44 @@ function normalizeItermSessionId(raw) {
176
211
  const parts = value.split(":");
177
212
  return parts[parts.length - 1] || null;
178
213
  }
214
+ function normalizeInlinePreview(value) {
215
+ const normalized = value.replace(/\s+/g, " ").trim();
216
+ if (!normalized) {
217
+ return null;
218
+ }
219
+ if (normalized.length > MAX_INLINE_PREVIEW_INPUT_CHARS) {
220
+ return null;
221
+ }
222
+ if (looksStructured(normalized)) {
223
+ return null;
224
+ }
225
+ if (normalized.length <= MAX_INLINE_PREVIEW_CHARS) {
226
+ return normalized;
227
+ }
228
+ return `${normalized.slice(0, MAX_INLINE_PREVIEW_CHARS - 3).trimEnd()}...`;
229
+ }
230
+ function isPreviewFriendlySummary(summary) {
231
+ if (typeof summary !== "string") {
232
+ return false;
233
+ }
234
+ const normalized = summary.trim();
235
+ if (!normalized) {
236
+ return false;
237
+ }
238
+ if (!normalized.includes(" ")) {
239
+ return false;
240
+ }
241
+ if (/^[a-z0-9_.-]+:/i.test(normalized)) {
242
+ return false;
243
+ }
244
+ return true;
245
+ }
246
+ function looksStructured(value) {
247
+ if ((value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"))) {
248
+ return true;
249
+ }
250
+ return false;
251
+ }
179
252
  function detectRuntimeContext(env) {
180
253
  const codexThreadId = normalizeOptionalString(env.CODEX_THREAD_ID);
181
254
  if (codexThreadId) {
@@ -0,0 +1,2 @@
1
+ alter table activation_dispatch_states
2
+ add column last_notified_fingerprint text;
@@ -0,0 +1,21 @@
1
+ alter table inbox_items add column inbox_sequence integer;
2
+
3
+ update inbox_items
4
+ set inbox_sequence = rowid
5
+ where inbox_sequence is null;
6
+
7
+ create unique index if not exists idx_inbox_items_inbox_sequence
8
+ on inbox_items(inbox_sequence);
9
+
10
+ create index if not exists idx_inbox_items_inbox_sequence_order
11
+ on inbox_items(inbox_id, inbox_sequence);
12
+
13
+ create trigger if not exists trg_inbox_items_set_sequence_after_insert
14
+ after insert on inbox_items
15
+ for each row
16
+ when new.inbox_sequence is null
17
+ begin
18
+ update inbox_items
19
+ set inbox_sequence = new.rowid
20
+ where rowid = new.rowid;
21
+ end;
package/drizzle/schema.ts CHANGED
@@ -122,6 +122,7 @@ export const activationDispatchStates = sqliteTable("activation_dispatch_states"
122
122
  targetId: text("target_id").notNull(),
123
123
  status: text("status").notNull(),
124
124
  leaseExpiresAt: text("lease_expires_at"),
125
+ lastNotifiedFingerprint: text("last_notified_fingerprint"),
125
126
  pendingNewItemCount: integer("pending_new_item_count").notNull(),
126
127
  pendingSummary: text("pending_summary"),
127
128
  pendingSubscriptionIdsJson: text("pending_subscription_ids_json").notNull(),
@@ -138,6 +139,7 @@ export const inboxItems = sqliteTable("inbox_items", {
138
139
  sourceNativeId: text("source_native_id").notNull(),
139
140
  eventVariant: text("event_variant").notNull(),
140
141
  inboxId: text("inbox_id").notNull(),
142
+ inboxSequence: integer("inbox_sequence"),
141
143
  occurredAt: text("occurred_at").notNull(),
142
144
  metadataJson: text("metadata_json").notNull(),
143
145
  rawPayloadJson: text("raw_payload_json").notNull(),
@@ -146,8 +148,10 @@ export const inboxItems = sqliteTable("inbox_items", {
146
148
  }, (table) => ({
147
149
  sourceNativeEventInboxUnique: uniqueIndex("idx_inbox_items_source_native_event_inbox")
148
150
  .on(table.sourceId, table.sourceNativeId, table.eventVariant, table.inboxId),
151
+ inboxSequenceUnique: uniqueIndex("idx_inbox_items_inbox_sequence").on(table.inboxSequence),
149
152
  ackedAtIdx: index("idx_inbox_items_acked_at").on(table.ackedAt),
150
153
  inboxAckedAtIdx: index("idx_inbox_items_inbox_acked_at").on(table.inboxId, table.ackedAt),
154
+ inboxSequenceIdx: index("idx_inbox_items_inbox_sequence_order").on(table.inboxId, table.inboxSequence),
151
155
  sourceOccurredAtIdx: index("idx_inbox_items_source_occurred_at").on(table.sourceId, table.occurredAt),
152
156
  }));
153
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "files": [
21
21
  "dist/src/",
22
+ "scripts/",
22
23
  "drizzle/",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -49,8 +50,8 @@
49
50
  "devDependencies": {
50
51
  "@types/node": "^24.5.2",
51
52
  "@types/sql.js": "^1.4.9",
52
- "drizzle-kit": "^0.31.6",
53
- "drizzle-orm": "^0.44.2",
53
+ "drizzle-kit": "1.0.0-beta.21",
54
+ "drizzle-orm": "^0.45.2",
54
55
  "mdorigin": "^0.5.0",
55
56
  "ts-node": "^10.9.2",
56
57
  "typescript": "^5.9.2"
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ iTerm2 Python API cursor probe for AgentInbox.
4
+ Returns session state including cursor position and buffer contents.
5
+ """
6
+
7
+ import sys
8
+ import json
9
+ import asyncio
10
+
11
+
12
+ def probe_session(connection, session_id):
13
+ """Probe iTerm2 session state and return cursor position with buffer contents."""
14
+ try:
15
+ import iterm2
16
+
17
+ async def get_session_state():
18
+ try:
19
+ app = await iterm2.async_get_app(connection)
20
+ session = app.get_session_by_id(session_id)
21
+
22
+ if not session:
23
+ return {"status": "gone"}
24
+
25
+ screen = await session.async_get_screen_contents()
26
+ cursor = screen.cursor_coord
27
+
28
+ # Get the last few lines of content
29
+ lines = []
30
+ start_line = max(0, screen.number_of_lines - 5)
31
+ for i in range(start_line, screen.number_of_lines):
32
+ try:
33
+ line = screen.line(i)
34
+ line_str = line.string if hasattr(line, 'string') else str(line)
35
+ lines.append(line_str)
36
+ except Exception:
37
+ break
38
+
39
+ return {
40
+ "status": "available",
41
+ "cursor": {"x": cursor.x, "y": cursor.y},
42
+ "screen_height": screen.number_of_lines,
43
+ "start_line": start_line,
44
+ "lines": lines
45
+ }
46
+ except Exception as e:
47
+ return {"status": "error", "error": str(e)}
48
+
49
+ return get_session_state()
50
+
51
+ except ImportError:
52
+ return {"status": "error", "error": "iterm2 module not installed"}
53
+
54
+
55
+ def main():
56
+ if len(sys.argv) < 2:
57
+ result = {"status": "error", "error": "missing session_id argument"}
58
+ print(json.dumps(result))
59
+ sys.exit(1)
60
+
61
+ session_id = sys.argv[1]
62
+
63
+ # Try to import iterm2 and run the probe
64
+ try:
65
+ import iterm2
66
+ result = iterm2.run_until_complete(probe_session, session_id)
67
+ print(json.dumps(result))
68
+
69
+ # Exit with error code if status is not "available" or "gone"
70
+ if result.get("status") not in ["available", "gone"]:
71
+ sys.exit(1)
72
+ else:
73
+ sys.exit(0)
74
+
75
+ except ImportError:
76
+ result = {"status": "error", "error": "iterm2 module not installed"}
77
+ print(json.dumps(result))
78
+ sys.exit(1)
79
+ except Exception as e:
80
+ result = {"status": "error", "error": str(e)}
81
+ print(json.dumps(result))
82
+ sys.exit(1)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()