@holon-run/agentinbox 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/store.js CHANGED
@@ -505,9 +505,9 @@ class AgentInboxStore {
505
505
  this.db.run(`
506
506
  insert into activation_targets (
507
507
  target_id, agent_id, kind, status, offline_since, consecutive_failures, last_delivered_at, last_error, mode, notify_lease_ms,
508
- runtime_kind, runtime_session_id, backend, tmux_pane_id, tty, term_program, iterm_session_id,
508
+ runtime_kind, runtime_session_id, runtime_pid, backend, tmux_pane_id, tty, term_program, iterm_session_id,
509
509
  created_at, updated_at, last_seen_at
510
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
510
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
511
511
  `, [
512
512
  target.targetId,
513
513
  target.agentId,
@@ -521,6 +521,7 @@ class AgentInboxStore {
521
521
  target.notifyLeaseMs,
522
522
  target.runtimeKind,
523
523
  target.runtimeSessionId ?? null,
524
+ target.runtimePid ?? null,
524
525
  target.backend,
525
526
  target.tmuxPaneId ?? null,
526
527
  target.tty ?? null,
@@ -537,11 +538,12 @@ class AgentInboxStore {
537
538
  this.db.run(`
538
539
  update activation_targets
539
540
  set status = 'active', offline_since = null, last_error = null, consecutive_failures = 0,
540
- runtime_kind = ?, runtime_session_id = ?, tmux_pane_id = ?, tty = ?, term_program = ?, iterm_session_id = ?, updated_at = ?, last_seen_at = ?
541
+ runtime_kind = ?, runtime_session_id = ?, runtime_pid = ?, tmux_pane_id = ?, tty = ?, term_program = ?, iterm_session_id = ?, updated_at = ?, last_seen_at = ?
541
542
  where target_id = ? and kind = 'terminal'
542
543
  `, [
543
544
  input.runtimeKind,
544
545
  input.runtimeSessionId ?? null,
546
+ input.runtimePid ?? null,
545
547
  input.tmuxPaneId ?? null,
546
548
  input.tty ?? null,
547
549
  input.termProgram ?? null,
@@ -606,6 +608,7 @@ class AgentInboxStore {
606
608
  }
607
609
  this.db.run("delete from subscription_lifecycle_retirements where subscription_id in (select subscription_id from subscriptions where agent_id = ?)", [agentId]);
608
610
  this.db.run("delete from source_idle_states where source_id in (select distinct source_id from subscriptions where agent_id = ?)", [agentId]);
611
+ this.db.run("delete from timers where agent_id = ?", [agentId]);
609
612
  this.db.run("delete from subscriptions where agent_id = ?", [agentId]);
610
613
  this.db.run("delete from activation_dispatch_states where agent_id = ?", [agentId]);
611
614
  this.db.run("delete from activation_targets where agent_id = ?", [agentId]);
@@ -631,6 +634,86 @@ class AgentInboxStore {
631
634
  const rows = this.getAll("select * from agents where status = 'offline' and offline_since is not null and offline_since <= ? order by offline_since asc", [cutoffIso]);
632
635
  return rows.map((row) => this.mapAgent(row));
633
636
  }
637
+ insertTimer(timer) {
638
+ this.db.run(`
639
+ insert into timers (
640
+ schedule_id, agent_id, status, mode, at, interval_ms, cron_expr, timezone,
641
+ message, sender, next_fire_at, last_fired_at, created_at, updated_at
642
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
643
+ `, [
644
+ timer.scheduleId,
645
+ timer.agentId,
646
+ timer.status,
647
+ timer.mode,
648
+ timer.at ?? null,
649
+ timer.intervalMs ?? null,
650
+ timer.cronExpr ?? null,
651
+ timer.timezone,
652
+ timer.message,
653
+ timer.sender ?? null,
654
+ timer.nextFireAt ?? null,
655
+ timer.lastFiredAt ?? null,
656
+ timer.createdAt,
657
+ timer.updatedAt,
658
+ ]);
659
+ this.persist();
660
+ }
661
+ getTimer(scheduleId) {
662
+ const row = this.getOne("select * from timers where schedule_id = ?", [scheduleId]);
663
+ return row ? this.mapTimer(row) : null;
664
+ }
665
+ listTimers() {
666
+ const rows = this.getAll("select * from timers order by created_at asc");
667
+ return rows.map((row) => this.mapTimer(row));
668
+ }
669
+ listTimersForAgent(agentId) {
670
+ const rows = this.getAll("select * from timers where agent_id = ? order by created_at asc", [agentId]);
671
+ return rows.map((row) => this.mapTimer(row));
672
+ }
673
+ listDueTimers(cutoffIso) {
674
+ const rows = this.getAll("select * from timers where status = 'active' and next_fire_at is not null and next_fire_at <= ? order by next_fire_at asc, schedule_id asc", [cutoffIso]);
675
+ return rows.map((row) => this.mapTimer(row));
676
+ }
677
+ getNearestActiveTimer() {
678
+ const row = this.getOne("select * from timers where status = 'active' and next_fire_at is not null order by next_fire_at asc, schedule_id asc limit 1");
679
+ return row ? this.mapTimer(row) : null;
680
+ }
681
+ updateTimer(scheduleId, input) {
682
+ const current = this.getTimer(scheduleId);
683
+ if (!current) {
684
+ throw new Error(`unknown timer: ${scheduleId}`);
685
+ }
686
+ this.db.run(`
687
+ update timers
688
+ set status = ?, mode = ?, at = ?, interval_ms = ?, cron_expr = ?, timezone = ?,
689
+ message = ?, sender = ?, next_fire_at = ?, last_fired_at = ?, updated_at = ?
690
+ where schedule_id = ?
691
+ `, [
692
+ input.status ?? current.status,
693
+ input.mode ?? current.mode,
694
+ input.at !== undefined ? input.at ?? null : current.at ?? null,
695
+ input.intervalMs !== undefined ? input.intervalMs ?? null : current.intervalMs ?? null,
696
+ input.cronExpr !== undefined ? input.cronExpr ?? null : current.cronExpr ?? null,
697
+ input.timezone ?? current.timezone,
698
+ input.message ?? current.message,
699
+ input.sender !== undefined ? input.sender ?? null : current.sender ?? null,
700
+ input.nextFireAt !== undefined ? input.nextFireAt ?? null : current.nextFireAt ?? null,
701
+ input.lastFiredAt !== undefined ? input.lastFiredAt ?? null : current.lastFiredAt ?? null,
702
+ input.updatedAt ?? current.updatedAt,
703
+ scheduleId,
704
+ ]);
705
+ this.persist();
706
+ return this.getTimer(scheduleId);
707
+ }
708
+ deleteTimer(scheduleId) {
709
+ const current = this.getTimer(scheduleId);
710
+ if (!current) {
711
+ return null;
712
+ }
713
+ this.db.run("delete from timers where schedule_id = ?", [scheduleId]);
714
+ this.persist();
715
+ return current;
716
+ }
634
717
  getActivationDispatchState(agentId, targetId) {
635
718
  const row = this.getOne("select * from activation_dispatch_states where agent_id = ? and target_id = ?", [agentId, targetId]);
636
719
  return row ? this.mapActivationDispatchState(row) : null;
@@ -1151,6 +1234,7 @@ class AgentInboxStore {
1151
1234
  notifyLeaseMs: Number(row.notify_lease_ms),
1152
1235
  runtimeKind: (row.runtime_kind ? String(row.runtime_kind) : "unknown"),
1153
1236
  runtimeSessionId: row.runtime_session_id ? String(row.runtime_session_id) : null,
1237
+ runtimePid: row.runtime_pid == null ? null : Number(row.runtime_pid),
1154
1238
  backend: row.backend,
1155
1239
  tmuxPaneId: row.tmux_pane_id ? String(row.tmux_pane_id) : null,
1156
1240
  tty: row.tty ? String(row.tty) : null,
@@ -1174,6 +1258,24 @@ class AgentInboxStore {
1174
1258
  updatedAt: String(row.updated_at),
1175
1259
  };
1176
1260
  }
1261
+ mapTimer(row) {
1262
+ return {
1263
+ scheduleId: String(row.schedule_id),
1264
+ agentId: String(row.agent_id),
1265
+ status: String(row.status),
1266
+ mode: String(row.mode),
1267
+ at: row.at ? String(row.at) : null,
1268
+ intervalMs: row.interval_ms == null ? null : Number(row.interval_ms),
1269
+ cronExpr: row.cron_expr ? String(row.cron_expr) : null,
1270
+ timezone: String(row.timezone),
1271
+ message: String(row.message),
1272
+ sender: row.sender ? String(row.sender) : null,
1273
+ nextFireAt: row.next_fire_at ? String(row.next_fire_at) : null,
1274
+ lastFiredAt: row.last_fired_at ? String(row.last_fired_at) : null,
1275
+ createdAt: String(row.created_at),
1276
+ updatedAt: String(row.updated_at),
1277
+ };
1278
+ }
1177
1279
  mapInboxItem(row) {
1178
1280
  return {
1179
1281
  itemId: String(row.item_id),
@@ -24,6 +24,7 @@ function detectTerminalContext(env = process.env) {
24
24
  return {
25
25
  runtimeKind: runtime.runtimeKind,
26
26
  runtimeSessionId: runtime.runtimeSessionId,
27
+ runtimePid: runtime.runtimePid,
27
28
  backend: "tmux",
28
29
  tmuxPaneId,
29
30
  tty,
@@ -35,6 +36,7 @@ function detectTerminalContext(env = process.env) {
35
36
  return {
36
37
  runtimeKind: runtime.runtimeKind,
37
38
  runtimeSessionId: runtime.runtimeSessionId,
39
+ runtimePid: runtime.runtimePid,
38
40
  backend: "iterm2",
39
41
  tty,
40
42
  termProgram,
@@ -44,7 +46,9 @@ function detectTerminalContext(env = process.env) {
44
46
  throw new Error("unable to detect a supported terminal context");
45
47
  }
46
48
  function renderAgentPrompt(input) {
47
- const base = `AgentInbox: ${input.newItemCount} new items arrived in inbox ${input.inboxId}.`;
49
+ const totalUnackedCount = input.totalUnackedCount ?? input.newItemCount ?? 0;
50
+ const itemWord = totalUnackedCount === 1 ? "item" : "items";
51
+ const base = `AgentInbox: ${totalUnackedCount} unacked ${itemWord} in inbox ${input.inboxId}.`;
48
52
  if (input.summary) {
49
53
  return `${base} Summary: ${input.summary}. Please read the inbox, process them, and ack when finished.`;
50
54
  }
@@ -77,7 +81,8 @@ class TerminalDispatcher {
77
81
  if (!target.tmuxPaneId) {
78
82
  throw new Error(`tmux terminal target ${target.targetId} is missing tmuxPaneId`);
79
83
  }
80
- await this.execAsync("tmux", ["send-keys", "-t", target.tmuxPaneId, prompt, "Enter"]);
84
+ await this.execAsync("tmux", ["send-keys", "-t", target.tmuxPaneId, "-l", prompt]);
85
+ await this.execAsync("tmux", ["send-keys", "-t", target.tmuxPaneId, "-l", "\r"]);
81
86
  return;
82
87
  }
83
88
  if (target.backend === "iterm2") {
@@ -87,31 +92,44 @@ class TerminalDispatcher {
87
92
  throw new Error(`unsupported terminal backend: ${String(target.backend)}`);
88
93
  }
89
94
  async probe(target) {
95
+ return (await this.probeStatus(target)) === "available";
96
+ }
97
+ async probeStatus(target) {
90
98
  try {
91
99
  if (target.backend === "tmux") {
92
100
  if (!target.tmuxPaneId) {
93
- return false;
101
+ return "unknown";
94
102
  }
95
103
  const result = await this.execAsync("tmux", ["display-message", "-p", "-t", target.tmuxPaneId, "#{pane_id}"]);
96
- return result.stdout.trim() === target.tmuxPaneId;
104
+ return result.stdout.trim() === target.tmuxPaneId ? "available" : "gone";
97
105
  }
98
106
  if (target.backend === "iterm2") {
99
107
  const sessionId = normalizeOptionalString(target.itermSessionId);
100
108
  if (!sessionId) {
101
- return false;
109
+ return "unknown";
102
110
  }
103
111
  const it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
104
112
  const result = await this.execAsync(it2api, ["list-sessions"]);
105
- return result.stdout.includes(sessionId);
113
+ return result.stdout.includes(sessionId) ? "available" : "gone";
106
114
  }
107
115
  }
108
- catch {
109
- return false;
116
+ catch (error) {
117
+ if (target.backend === "tmux" && isTmuxMissingPaneError(error)) {
118
+ return "gone";
119
+ }
120
+ return "unknown";
110
121
  }
111
- return false;
122
+ return "unknown";
112
123
  }
113
124
  }
114
125
  exports.TerminalDispatcher = TerminalDispatcher;
126
+ function isTmuxMissingPaneError(error) {
127
+ if (!(error instanceof Error)) {
128
+ return false;
129
+ }
130
+ const candidate = `${error.message} ${"stderr" in error ? String(error.stderr ?? "") : ""}`;
131
+ return candidate.includes("can't find pane");
132
+ }
115
133
  function detectTty(env) {
116
134
  const direct = tryReadCurrentTty();
117
135
  if (direct) {
@@ -164,15 +182,18 @@ function detectRuntimeContext(env) {
164
182
  return {
165
183
  runtimeKind: "codex",
166
184
  runtimeSessionId: codexThreadId,
185
+ runtimePid: findNamedProcessInAncestry((name) => name === "codex"),
167
186
  };
168
187
  }
169
188
  const claudeSessionId = normalizeOptionalString(env.CLAUDE_CODE_SESSION_ID
170
189
  ?? env.CLAUDECODE_SESSION_ID
171
190
  ?? env.CLAUDE_SESSION_ID);
172
191
  if (claudeSessionId) {
192
+ const claudePid = findNamedProcessInAncestry((name) => name.includes("claude"));
173
193
  return {
174
194
  runtimeKind: "claude_code",
175
195
  runtimeSessionId: claudeSessionId,
196
+ runtimePid: claudePid,
176
197
  };
177
198
  }
178
199
  // Fallback: try to detect Claude Code from filesystem
@@ -183,12 +204,13 @@ function detectRuntimeContext(env) {
183
204
  return {
184
205
  runtimeKind: "unknown",
185
206
  runtimeSessionId: null,
207
+ runtimePid: null,
186
208
  };
187
209
  }
188
210
  function detectClaudeCodeFromFileSystem(env) {
189
211
  try {
190
212
  // Walk up the process tree to find a claude process
191
- const claudePid = findClaudeProcessInAncestry();
213
+ const claudePid = findNamedProcessInAncestry((name) => name.includes("claude"));
192
214
  if (!claudePid) {
193
215
  return null;
194
216
  }
@@ -215,6 +237,7 @@ function detectClaudeCodeFromFileSystem(env) {
215
237
  return {
216
238
  runtimeKind: "claude_code",
217
239
  runtimeSessionId: sessionData.sessionId,
240
+ runtimePid: claudePid,
218
241
  };
219
242
  }
220
243
  catch {
@@ -222,13 +245,13 @@ function detectClaudeCodeFromFileSystem(env) {
222
245
  return null;
223
246
  }
224
247
  }
225
- function findClaudeProcessInAncestry() {
248
+ function findNamedProcessInAncestry(matcher) {
226
249
  let currentPid = process.ppid;
227
250
  const maxIterations = 10; // Prevent infinite loops
228
251
  let iterations = 0;
229
252
  while (currentPid && iterations < maxIterations) {
230
253
  const processName = tryGetProcessName(currentPid);
231
- if (processName?.includes("claude")) {
254
+ if (processName && matcher(processName)) {
232
255
  return currentPid;
233
256
  }
234
257
  // Get parent of current process
@@ -0,0 +1,22 @@
1
+ create table if not exists timers (
2
+ schedule_id text primary key not null,
3
+ agent_id text not null,
4
+ status text not null,
5
+ mode text not null,
6
+ at text,
7
+ interval_ms integer,
8
+ cron_expr text,
9
+ timezone text not null,
10
+ message text not null,
11
+ sender text,
12
+ next_fire_at text,
13
+ last_fired_at text,
14
+ created_at text not null,
15
+ updated_at text not null
16
+ );
17
+
18
+ create index if not exists idx_timers_agent_next_fire_at
19
+ on timers(agent_id, next_fire_at);
20
+
21
+ create index if not exists idx_timers_next_fire_at
22
+ on timers(next_fire_at);
@@ -0,0 +1 @@
1
+ alter table activation_targets add column runtime_pid integer;
package/drizzle/schema.ts CHANGED
@@ -24,6 +24,26 @@ export const sourceIdleStates = sqliteTable("source_idle_states", {
24
24
  autoPauseAtIdx: index("idx_source_idle_states_auto_pause_at").on(table.autoPauseAt),
25
25
  }));
26
26
 
27
+ export const timers = sqliteTable("timers", {
28
+ scheduleId: text("schedule_id").primaryKey(),
29
+ agentId: text("agent_id").notNull(),
30
+ status: text("status").notNull(),
31
+ mode: text("mode").notNull(),
32
+ at: text("at"),
33
+ intervalMs: integer("interval_ms"),
34
+ cronExpr: text("cron_expr"),
35
+ timezone: text("timezone").notNull(),
36
+ message: text("message").notNull(),
37
+ sender: text("sender"),
38
+ nextFireAt: text("next_fire_at"),
39
+ lastFiredAt: text("last_fired_at"),
40
+ createdAt: text("created_at").notNull(),
41
+ updatedAt: text("updated_at").notNull(),
42
+ }, (table) => ({
43
+ agentNextFireIdx: index("idx_timers_agent_next_fire_at").on(table.agentId, table.nextFireAt),
44
+ nextFireIdx: index("idx_timers_next_fire_at").on(table.nextFireAt),
45
+ }));
46
+
27
47
  export const agents = sqliteTable("agents", {
28
48
  agentId: text("agent_id").primaryKey(),
29
49
  status: text("status").notNull(),
@@ -84,6 +104,7 @@ export const activationTargets = sqliteTable("activation_targets", {
84
104
  url: text("url"),
85
105
  runtimeKind: text("runtime_kind"),
86
106
  runtimeSessionId: text("runtime_session_id"),
107
+ runtimePid: integer("runtime_pid"),
87
108
  backend: text("backend"),
88
109
  tmuxPaneId: text("tmux_pane_id"),
89
110
  tty: text("tty"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {