@holon-run/agentinbox 0.5.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
+ }
@@ -1570,9 +1570,21 @@ class AgentInboxService {
1570
1570
  });
1571
1571
  return "deferred";
1572
1572
  }
1573
- const preview = totalUnackedCount === 1 && input.items.length === 1
1574
- ? (0, terminal_1.deriveInlineItemPreview)(input.items[0], input.summary)
1575
- : null;
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
+ }
1576
1588
  const prompt = (0, terminal_1.renderAgentPrompt)({
1577
1589
  inboxId: inbox.inboxId,
1578
1590
  totalUnackedCount,
@@ -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
@@ -780,6 +780,12 @@ class AgentInboxStore {
780
780
  ]);
781
781
  const inserted = this.changes() > before;
782
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]);
783
789
  this.persist();
784
790
  }
785
791
  return inserted;
@@ -792,14 +798,14 @@ class AgentInboxStore {
792
798
  filters.push("acked_at is null");
793
799
  }
794
800
  if (options?.afterItemId) {
795
- 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]);
796
802
  if (!anchor) {
797
803
  throw new Error(`unknown inbox item: ${options.afterItemId}`);
798
804
  }
799
- filters.push("((occurred_at > ?) or (occurred_at = ? and item_id > ?))");
800
- 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));
801
807
  }
802
- 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);
803
809
  return rows.map((row) => this.mapInboxItem(row));
804
810
  }
805
811
  ackItems(inboxId, itemIds, ackedAt) {
@@ -818,7 +824,7 @@ class AgentInboxStore {
818
824
  return changes;
819
825
  }
820
826
  ackItemsThrough(inboxId, itemId, ackedAt) {
821
- 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]);
822
828
  if (!anchor) {
823
829
  throw new Error(`unknown inbox item: ${itemId}`);
824
830
  }
@@ -827,8 +833,8 @@ class AgentInboxStore {
827
833
  set acked_at = ?
828
834
  where inbox_id = ?
829
835
  and acked_at is null
830
- and ((occurred_at < ?) or (occurred_at = ? and item_id <= ?))
831
- `, [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)]);
832
838
  const changes = this.changes();
833
839
  if (changes > 0) {
834
840
  this.persist();
@@ -7,6 +7,7 @@ exports.TerminalDispatcher = void 0;
7
7
  exports.detectTerminalContext = detectTerminalContext;
8
8
  exports.renderAgentPrompt = renderAgentPrompt;
9
9
  exports.deriveInlineItemPreview = deriveInlineItemPreview;
10
+ exports.normalizeInlinePreviewText = normalizeInlinePreviewText;
10
11
  exports.assignedAgentIdFromContext = assignedAgentIdFromContext;
11
12
  const node_child_process_1 = require("node:child_process");
12
13
  const node_util_1 = require("node:util");
@@ -78,13 +79,16 @@ function deriveInlineItemPreview(item, summary) {
78
79
  candidates.push(summary);
79
80
  }
80
81
  for (const candidate of candidates) {
81
- const preview = normalizeInlinePreview(candidate);
82
+ const preview = normalizeInlinePreviewText(candidate);
82
83
  if (preview) {
83
84
  return preview;
84
85
  }
85
86
  }
86
87
  return null;
87
88
  }
89
+ function normalizeInlinePreviewText(candidate) {
90
+ return normalizeInlinePreview(candidate);
91
+ }
88
92
  function assignedAgentIdFromContext(input) {
89
93
  const primary = normalizeOptionalString(input.runtimeSessionId)
90
94
  ?? normalizeOptionalString(input.tmuxPaneId)
@@ -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
@@ -139,6 +139,7 @@ export const inboxItems = sqliteTable("inbox_items", {
139
139
  sourceNativeId: text("source_native_id").notNull(),
140
140
  eventVariant: text("event_variant").notNull(),
141
141
  inboxId: text("inbox_id").notNull(),
142
+ inboxSequence: integer("inbox_sequence"),
142
143
  occurredAt: text("occurred_at").notNull(),
143
144
  metadataJson: text("metadata_json").notNull(),
144
145
  rawPayloadJson: text("raw_payload_json").notNull(),
@@ -147,8 +148,10 @@ export const inboxItems = sqliteTable("inbox_items", {
147
148
  }, (table) => ({
148
149
  sourceNativeEventInboxUnique: uniqueIndex("idx_inbox_items_source_native_event_inbox")
149
150
  .on(table.sourceId, table.sourceNativeId, table.eventVariant, table.inboxId),
151
+ inboxSequenceUnique: uniqueIndex("idx_inbox_items_inbox_sequence").on(table.inboxSequence),
150
152
  ackedAtIdx: index("idx_inbox_items_acked_at").on(table.ackedAt),
151
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),
152
155
  sourceOccurredAtIdx: index("idx_inbox_items_source_occurred_at").on(table.sourceId, table.occurredAt),
153
156
  }));
154
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "0.5.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"
@@ -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()