@holon-run/agentinbox 0.3.0 → 0.5.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/README.md CHANGED
@@ -136,12 +136,40 @@ Create a local source and publish an event:
136
136
  agentinbox source add local_event local-demo
137
137
  agentinbox subscription add <source_id>
138
138
  agentinbox subscription add <source_id> --agent-id <agent_id>
139
+ agentinbox source schema <source_id>
140
+ agentinbox subscription add <source_id> --shortcut pr --shortcut-args-json '{"number":373}'
139
141
  agentinbox subscription add <source_id> --tracked-resource-ref pr:373 --cleanup-policy-json '{"mode":"manual"}'
140
142
  agentinbox subscription add <source_id> --filter-file ./filter.json
141
143
  cat filter.json | agentinbox subscription add <source_id> --filter-stdin
142
144
  agentinbox source event <source_id> --native-id demo-1 --event local.demo
143
145
  agentinbox inbox read
144
146
  agentinbox inbox read --agent-id <agent_id>
147
+ agentinbox inbox ack --agent-id <agent_id> --through <last_item_id>
148
+ ```
149
+
150
+ When acking a reviewed batch, prefer `--through <last_item_id>` over `--all`.
151
+ That preserves the boundary of the items you actually inspected. Use
152
+ `ack --all` only when you have intentionally verified that every current
153
+ unacked item should be cleared.
154
+
155
+ Send a direct text message into an agent inbox:
156
+
157
+ ```bash
158
+ agentinbox inbox send --agent-id <agent_id> --message "Please review PR #87"
159
+ agentinbox inbox send --agent-id <agent_id> --message "CI failed on main" --sender operator
160
+ ```
161
+
162
+ Schedule reminder messages with timers:
163
+
164
+ ```bash
165
+ agentinbox timer add --agent-id <agent_id> --at <RFC3339_TIMESTAMP> --message "Check the morning build"
166
+ agentinbox timer add --agent-id <agent_id> --every 24h --message "Review today's open PRs"
167
+ agentinbox timer add --agent-id <agent_id> --cron "0 8 * * *" --timezone Asia/Shanghai --message "Daily triage"
168
+ agentinbox timer list
169
+ agentinbox timer list --agent-id <agent_id>
170
+ agentinbox timer pause <schedule_id>
171
+ agentinbox timer resume <schedule_id>
172
+ agentinbox timer remove <schedule_id>
145
173
  ```
146
174
 
147
175
  Update an existing source in place:
@@ -164,6 +192,16 @@ Remove a task-specific subscription without deleting the whole agent:
164
192
  agentinbox subscription remove <subscription_id>
165
193
  ```
166
194
 
195
+ For implementation-backed sources, inspect resolved subscription capabilities
196
+ before adding task-scoped subscriptions:
197
+
198
+ ```bash
199
+ agentinbox source schema <source_id>
200
+ ```
201
+
202
+ If the source advertises subscription shortcuts, prefer them over manually
203
+ repeating the equivalent filter, tracked resource ref, and cleanup policy.
204
+
167
205
  ## Docs
168
206
 
169
207
  Public docs live in the mdorigin site under [`docs/site`](./docs/site).
package/dist/src/cli.js CHANGED
@@ -213,6 +213,14 @@ async function main() {
213
213
  await printRemote(client, `/agents/${encodeURIComponent(agentId)}`, undefined, "DELETE");
214
214
  return;
215
215
  }
216
+ if (command === "agent" && normalized[1] === "resume") {
217
+ const agentId = normalized[2];
218
+ if (!agentId) {
219
+ throw new Error("usage: agentinbox agent resume <agentId>");
220
+ }
221
+ await printRemote(client, `/agents/${encodeURIComponent(agentId)}/resume`, {});
222
+ return;
223
+ }
216
224
  if (command === "agent" && normalized[1] === "target" && normalized[2] === "add" && normalized[3] === "webhook") {
217
225
  const agentId = normalized[4];
218
226
  const url = takeFlagValue(normalized, "--url");
@@ -243,6 +251,14 @@ async function main() {
243
251
  await printRemote(client, `/agents/${encodeURIComponent(agentId)}/targets/${encodeURIComponent(targetId)}`, undefined, "DELETE");
244
252
  return;
245
253
  }
254
+ if (command === "agent" && normalized[1] === "target" && normalized[2] === "resume") {
255
+ const [agentId, targetId] = normalized.slice(3, 5);
256
+ if (!agentId || !targetId) {
257
+ throw new Error("usage: agentinbox agent target resume <agentId> <targetId>");
258
+ }
259
+ await printRemote(client, `/agents/${encodeURIComponent(agentId)}/targets/${encodeURIComponent(targetId)}/resume`, {});
260
+ return;
261
+ }
246
262
  if (command === "subscription" && normalized[1] === "add") {
247
263
  const args = normalized.slice(2);
248
264
  const positionals = positionalArgs(args, [
@@ -905,9 +921,11 @@ Usage:
905
921
  agentinbox agent current
906
922
  agentinbox agent show <agentId>
907
923
  agentinbox agent remove <agentId>
924
+ agentinbox agent resume <agentId>
908
925
  agentinbox agent target add webhook <agentId> --url URL [--activation-mode MODE] [--notify-lease-ms N]
909
926
  agentinbox agent target list <agentId>
910
927
  agentinbox agent target remove <agentId> <targetId>
928
+ agentinbox agent target resume <agentId> <targetId>
911
929
  `,
912
930
  timer: `agentinbox timer
913
931
 
@@ -15,6 +15,7 @@ const node_child_process_1 = require("node:child_process");
15
15
  const client_1 = require("./client");
16
16
  const paths_1 = require("./paths");
17
17
  const START_TIMEOUT_MS = 15_000;
18
+ const PACKAGE_VERSION = readOwnPackageVersion();
18
19
  async function ensureDaemonForClient(options = {}) {
19
20
  const transport = resolveDaemonClientTransport(options);
20
21
  if (options.noAutoStart || transport.kind !== "socket") {
@@ -95,14 +96,23 @@ async function daemonStatus(options = {}) {
95
96
  return {
96
97
  running: false,
97
98
  pid: null,
99
+ version: null,
100
+ startedAt: null,
101
+ command: null,
102
+ nodeVersion: null,
98
103
  pidPath,
99
104
  logPath,
100
105
  transport,
101
106
  };
102
107
  }
108
+ const processInfo = pid == null ? null : readProcessMetadata(pid);
103
109
  return {
104
110
  running: await canReachHealthz(transport),
105
111
  pid,
112
+ version: pid == null ? null : PACKAGE_VERSION,
113
+ startedAt: processInfo?.startedAt ?? null,
114
+ command: processInfo?.command ?? null,
115
+ nodeVersion: processInfo?.nodeVersion ?? null,
106
116
  pidPath,
107
117
  logPath,
108
118
  transport,
@@ -179,6 +189,24 @@ async function canReachHealthz(transport) {
179
189
  return false;
180
190
  }
181
191
  }
192
+ function readOwnPackageVersion() {
193
+ const candidatePaths = [
194
+ node_path_1.default.join(__dirname, "..", "package.json"),
195
+ node_path_1.default.join(__dirname, "..", "..", "package.json"),
196
+ ];
197
+ for (const candidate of candidatePaths) {
198
+ try {
199
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(candidate, "utf8"));
200
+ if (typeof parsed.version === "string" && parsed.version.length > 0) {
201
+ return parsed.version;
202
+ }
203
+ }
204
+ catch {
205
+ continue;
206
+ }
207
+ }
208
+ return null;
209
+ }
182
210
  async function waitForHealthz(transport, timeoutMs) {
183
211
  const startedAt = Date.now();
184
212
  while (Date.now() - startedAt < timeoutMs) {
@@ -222,6 +250,47 @@ function readPidFile(pidPath) {
222
250
  return null;
223
251
  }
224
252
  }
253
+ function readProcessMetadata(pid) {
254
+ try {
255
+ const output = (0, node_child_process_1.execFileSync)("ps", ["-o", "lstart=", "-o", "command=", "-p", String(pid)], {
256
+ encoding: "utf8",
257
+ stdio: ["ignore", "pipe", "ignore"],
258
+ }).trimEnd();
259
+ if (!output) {
260
+ return null;
261
+ }
262
+ const firstNonSpace = output.search(/\S/);
263
+ if (firstNonSpace < 0) {
264
+ return null;
265
+ }
266
+ const trimmed = output.slice(firstNonSpace);
267
+ const match = trimmed.match(/^([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d\d:\d\d:\d\d\s+\d{4})\s+(.*)$/);
268
+ if (!match) {
269
+ return {
270
+ startedAt: null,
271
+ command: trimmed,
272
+ nodeVersion: inferNodeVersionFromCommand(trimmed),
273
+ };
274
+ }
275
+ const [, startedAtRaw, command] = match;
276
+ const startedAtMs = Date.parse(startedAtRaw);
277
+ return {
278
+ startedAt: Number.isNaN(startedAtMs) ? null : new Date(startedAtMs).toISOString(),
279
+ command: command || null,
280
+ nodeVersion: inferNodeVersionFromCommand(command),
281
+ };
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
287
+ function inferNodeVersionFromCommand(command) {
288
+ if (!command) {
289
+ return null;
290
+ }
291
+ const match = command.match(/node\/(v\d+\.\d+\.\d+)\//);
292
+ return match ? match[1] : null;
293
+ }
225
294
  function isProcessAlive(pid) {
226
295
  try {
227
296
  process.kill(pid, 0);
package/dist/src/http.js CHANGED
@@ -487,6 +487,24 @@ function buildFastifyServer(service) {
487
487
  const params = request.params;
488
488
  return service.removeAgent(decodeURIComponent(params.agentId));
489
489
  });
490
+ app.post("/agents/:agentId/resume", {
491
+ schema: {
492
+ tags: ["agents"],
493
+ params: {
494
+ type: "object",
495
+ required: ["agentId"],
496
+ properties: {
497
+ agentId: { type: "string", minLength: 1 },
498
+ },
499
+ },
500
+ response: {
501
+ 200: jsonObjectSchema,
502
+ },
503
+ },
504
+ }, async (request) => {
505
+ const params = request.params;
506
+ return service.resumeAgent(decodeURIComponent(params.agentId));
507
+ });
490
508
  app.get("/agents/:agentId/targets", {
491
509
  schema: {
492
510
  tags: ["agents"],
@@ -565,6 +583,25 @@ function buildFastifyServer(service) {
565
583
  const params = request.params;
566
584
  return service.removeActivationTarget(decodeURIComponent(params.agentId), decodeURIComponent(params.targetId));
567
585
  });
586
+ app.post("/agents/:agentId/targets/:targetId/resume", {
587
+ schema: {
588
+ tags: ["agents"],
589
+ params: {
590
+ type: "object",
591
+ required: ["agentId", "targetId"],
592
+ properties: {
593
+ agentId: { type: "string", minLength: 1 },
594
+ targetId: { type: "string", minLength: 1 },
595
+ },
596
+ },
597
+ response: {
598
+ 200: jsonObjectSchema,
599
+ },
600
+ },
601
+ }, async (request) => {
602
+ const params = request.params;
603
+ return service.resumeActivationTarget(decodeURIComponent(params.agentId), decodeURIComponent(params.targetId));
604
+ });
568
605
  app.get("/timers", {
569
606
  schema: {
570
607
  tags: ["timers"],
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ClaudeCodeTerminalStateProbe = exports.ClaudeCodeRuntimePresenceProbe = exports.TmuxTerminalStateProbe = exports.Iterm2TerminalStateProbe = exports.CodexRuntimePresenceProbe = exports.DefaultActivationGate = void 0;
6
+ exports.ClaudeCodeTerminalStateProbe = exports.ClaudeCodeRuntimePresenceProbe = exports.TmuxTerminalStateProbe = exports.Iterm2TerminalStateProbe = exports.CodexTerminalStateProbe = exports.CodexRuntimePresenceProbe = exports.DefaultActivationGate = void 0;
7
+ exports.readFirstLine = readFirstLine;
7
8
  const node_child_process_1 = require("node:child_process");
8
9
  const node_util_1 = require("node:util");
9
10
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -28,6 +29,7 @@ class DefaultActivationGate {
28
29
  new CodexRuntimePresenceProbe(),
29
30
  new ClaudeCodeRuntimePresenceProbe(),
30
31
  ], terminalProbes = [
32
+ new CodexTerminalStateProbe(),
31
33
  new Iterm2TerminalStateProbe(),
32
34
  new ClaudeCodeTerminalStateProbe(),
33
35
  new TmuxTerminalStateProbe(),
@@ -38,27 +40,32 @@ class DefaultActivationGate {
38
40
  async evaluate(target) {
39
41
  const runtimeProbe = this.runtimeProbes.find((probe) => probe.supports(target));
40
42
  const runtimePresence = runtimeProbe ? await runtimeProbe.check(target) : "unknown";
43
+ if (runtimePresence === "gone") {
44
+ return { outcome: "offline", reason: "runtime_gone" };
45
+ }
41
46
  const terminalProbe = this.terminalProbes.find((probe) => probe.supports(target));
42
47
  const terminalState = terminalProbe
43
48
  ? await terminalProbe.check(target)
44
49
  : { presence: "unknown", busy: "unknown" };
45
- if (runtimePresence === "gone") {
46
- return { outcome: "offline", reason: "runtime_gone" };
47
- }
48
50
  if (terminalState.presence === "gone") {
49
51
  return { outcome: "offline", reason: "terminal_gone" };
50
52
  }
51
53
  if (terminalState.busy === "busy") {
52
54
  return { outcome: "defer", reason: "terminal_busy" };
53
55
  }
56
+ if (target.runtimeKind === "codex" && terminalState.busy === "idle") {
57
+ return { outcome: "defer", reason: "terminal_recently_active" };
58
+ }
54
59
  return { outcome: "inject", reason: "gate_unknown_or_idle" };
55
60
  }
56
61
  }
57
62
  exports.DefaultActivationGate = DefaultActivationGate;
58
63
  class CodexRuntimePresenceProbe {
59
64
  killFn;
60
- constructor(killFn = (pid, signal) => process.kill(pid, signal)) {
65
+ sessionFileReader;
66
+ constructor(killFn = (pid, signal) => process.kill(pid, signal), sessionFileReader) {
61
67
  this.killFn = killFn;
68
+ this.sessionFileReader = sessionFileReader;
62
69
  }
63
70
  supports(target) {
64
71
  return target.runtimeKind === "codex" && Number.isInteger(target.runtimePid);
@@ -69,6 +76,16 @@ class CodexRuntimePresenceProbe {
69
76
  }
70
77
  try {
71
78
  this.killFn(target.runtimePid, 0);
79
+ if (!target.runtimeSessionId) {
80
+ return "unknown";
81
+ }
82
+ const sessionInfo = await (this.sessionFileReader ?? defaultCodexSessionFileReader)(target.runtimeSessionId);
83
+ if (sessionInfo === "unknown") {
84
+ return "unknown";
85
+ }
86
+ if (!sessionInfo || sessionInfo.sessionId !== target.runtimeSessionId) {
87
+ return "gone";
88
+ }
72
89
  return "alive";
73
90
  }
74
91
  catch (error) {
@@ -81,6 +98,58 @@ class CodexRuntimePresenceProbe {
81
98
  }
82
99
  }
83
100
  exports.CodexRuntimePresenceProbe = CodexRuntimePresenceProbe;
101
+ class CodexTerminalStateProbe {
102
+ iTermProbe;
103
+ tmuxProbe;
104
+ sessionFileReader;
105
+ statReader;
106
+ nowFn;
107
+ constructor(iTermProbe = new Iterm2TerminalStateProbe(), tmuxProbe = new TmuxTerminalStateProbe(), sessionFileReader, statReader, nowFn = () => Date.now()) {
108
+ this.iTermProbe = iTermProbe;
109
+ this.tmuxProbe = tmuxProbe;
110
+ this.sessionFileReader = sessionFileReader;
111
+ this.statReader = statReader;
112
+ this.nowFn = nowFn;
113
+ }
114
+ supports(target) {
115
+ return target.runtimeKind === "codex";
116
+ }
117
+ async check(target) {
118
+ const terminalState = await this.readTerminalState(target);
119
+ if (!target.runtimeSessionId) {
120
+ return terminalState;
121
+ }
122
+ const sessionInfo = await (this.sessionFileReader ?? defaultCodexSessionFileReader)(target.runtimeSessionId);
123
+ if (sessionInfo === "unknown" || !sessionInfo || sessionInfo.sessionId !== target.runtimeSessionId) {
124
+ return terminalState;
125
+ }
126
+ const stat = await (this.statReader ?? defaultFileStatReader)(sessionInfo.filePath);
127
+ if (!stat) {
128
+ return terminalState;
129
+ }
130
+ if (terminalState.busy === "busy" || terminalState.presence === "gone") {
131
+ return terminalState;
132
+ }
133
+ const ageMs = this.nowFn() - stat.mtimeMs;
134
+ if (ageMs < 5000) {
135
+ return { presence: terminalState.presence, busy: "busy" };
136
+ }
137
+ if (ageMs < 30000) {
138
+ return { presence: terminalState.presence, busy: "idle" };
139
+ }
140
+ return terminalState;
141
+ }
142
+ async readTerminalState(target) {
143
+ if (this.iTermProbe.supports(target)) {
144
+ return this.iTermProbe.check(target);
145
+ }
146
+ if (this.tmuxProbe.supports(target)) {
147
+ return this.tmuxProbe.check(target);
148
+ }
149
+ return { presence: "unknown", busy: "unknown" };
150
+ }
151
+ }
152
+ exports.CodexTerminalStateProbe = CodexTerminalStateProbe;
84
153
  class Iterm2TerminalStateProbe {
85
154
  execAsync;
86
155
  options;
@@ -128,16 +197,16 @@ class Iterm2TerminalStateProbe {
128
197
  if (containsActiveBufferMarker(second)) {
129
198
  return { presence: "available", busy: "busy" };
130
199
  }
200
+ if (hasVisibleTypingPrompt(second)) {
201
+ return { presence: "available", busy: "busy" };
202
+ }
131
203
  return { presence: "available", busy: "unknown" };
132
204
  }
133
205
  async checkSessionPresence(it2api, sessionId) {
134
206
  try {
135
207
  const result = await this.execAsync(it2api, ["list-sessions"]);
136
- const sessions = result.stdout
137
- .split(/\r?\n/)
138
- .map((line) => line.trim())
139
- .filter((line) => line.length > 0);
140
- return sessions.includes(sessionId) ? "available" : "gone";
208
+ const sessionIds = parseIterm2SessionIds(result.stdout);
209
+ return sessionIds.includes(sessionId) ? "available" : "gone";
141
210
  }
142
211
  catch {
143
212
  return "unknown";
@@ -284,6 +353,114 @@ function resolveIterm2ApiPath(override) {
284
353
  async function sleep(ms) {
285
354
  await new Promise((resolve) => setTimeout(resolve, ms));
286
355
  }
356
+ async function defaultFileStatReader(filePath) {
357
+ try {
358
+ const stats = await node_fs_1.default.promises.stat(filePath);
359
+ return { mtimeMs: stats.mtimeMs };
360
+ }
361
+ catch {
362
+ return null;
363
+ }
364
+ }
365
+ async function defaultCodexSessionFileReader(sessionId) {
366
+ const sessionsRoot = node_path_1.default.join(node_os_1.default.homedir(), ".codex", "sessions");
367
+ const filePath = await findCodexSessionFile(sessionsRoot, sessionId).catch(() => "unknown");
368
+ if (filePath === "unknown") {
369
+ return "unknown";
370
+ }
371
+ if (!filePath) {
372
+ return null;
373
+ }
374
+ try {
375
+ const firstLine = await readFirstLine(filePath);
376
+ if (!firstLine) {
377
+ return "unknown";
378
+ }
379
+ const record = JSON.parse(firstLine);
380
+ const parsedSessionId = typeof record.payload?.id === "string" ? record.payload.id : null;
381
+ if (record.type !== "session_meta" || parsedSessionId !== sessionId) {
382
+ return "unknown";
383
+ }
384
+ return {
385
+ sessionId: parsedSessionId,
386
+ filePath,
387
+ cwd: typeof record.payload?.cwd === "string" ? record.payload.cwd : null,
388
+ };
389
+ }
390
+ catch {
391
+ return "unknown";
392
+ }
393
+ }
394
+ async function findCodexSessionFile(root, sessionId) {
395
+ let years;
396
+ try {
397
+ years = await node_fs_1.default.promises.readdir(root, { withFileTypes: true });
398
+ }
399
+ catch {
400
+ return null;
401
+ }
402
+ for (const year of years) {
403
+ if (!year.isDirectory()) {
404
+ continue;
405
+ }
406
+ const yearPath = node_path_1.default.join(root, year.name);
407
+ const match = await walkCodexSessionLevel(yearPath, sessionId, 2);
408
+ if (match) {
409
+ return match;
410
+ }
411
+ }
412
+ return null;
413
+ }
414
+ async function walkCodexSessionLevel(dir, sessionId, depth) {
415
+ let entries;
416
+ try {
417
+ entries = await node_fs_1.default.promises.readdir(dir, { withFileTypes: true });
418
+ }
419
+ catch {
420
+ return null;
421
+ }
422
+ for (const entry of entries) {
423
+ const entryPath = node_path_1.default.join(dir, entry.name);
424
+ if (entry.isFile() && entry.name.endsWith(`${sessionId}.jsonl`)) {
425
+ return entryPath;
426
+ }
427
+ if (entry.isDirectory() && depth > 0) {
428
+ const match = await walkCodexSessionLevel(entryPath, sessionId, depth - 1);
429
+ if (match) {
430
+ return match;
431
+ }
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+ async function readFirstLine(filePath) {
437
+ const handle = await node_fs_1.default.promises.open(filePath, "r");
438
+ try {
439
+ const chunkSize = 4096;
440
+ const chunks = [];
441
+ let position = 0;
442
+ while (true) {
443
+ const buffer = Buffer.alloc(chunkSize);
444
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
445
+ if (bytesRead <= 0) {
446
+ break;
447
+ }
448
+ const chunk = buffer.toString("utf8", 0, bytesRead);
449
+ const newline = chunk.indexOf("\n");
450
+ if (newline >= 0) {
451
+ chunks.push(chunk.slice(0, newline));
452
+ break;
453
+ }
454
+ chunks.push(chunk);
455
+ position += bytesRead;
456
+ }
457
+ const firstLine = chunks.join("").trim();
458
+ return firstLine.length > 0 ? firstLine : null;
459
+ }
460
+ finally {
461
+ await handle.close();
462
+ }
463
+ }
287
464
  function isTmuxMissingPaneError(error) {
288
465
  if (!(error instanceof Error)) {
289
466
  return false;
@@ -446,14 +623,7 @@ class ClaudeCodeTerminalStateProbe {
446
623
  try {
447
624
  const it2api = resolveIterm2ApiPath();
448
625
  const result = await execFileAsync(it2api, ["list-sessions"]);
449
- const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
450
- // Parse session IDs from iTerm2 output format: "Session \"...\" id=<UUID> ..."
451
- const sessionIds = lines
452
- .map((line) => {
453
- const match = line.match(/id=([A-F0-9-]+)/);
454
- return match ? match[1] : null;
455
- })
456
- .filter((id) => id !== null);
626
+ const sessionIds = parseIterm2SessionIds(result.stdout);
457
627
  return sessionIds.includes(itermSessionId) ? "available" : "gone";
458
628
  }
459
629
  catch {
@@ -462,3 +632,14 @@ class ClaudeCodeTerminalStateProbe {
462
632
  }
463
633
  }
464
634
  exports.ClaudeCodeTerminalStateProbe = ClaudeCodeTerminalStateProbe;
635
+ function parseIterm2SessionIds(stdout) {
636
+ return stdout
637
+ .split(/\r?\n/)
638
+ .map((line) => line.trim())
639
+ .filter((line) => line.length > 0)
640
+ .map((line) => {
641
+ const match = line.match(/\bid=([A-F0-9-]+)/i);
642
+ return match ? match[1] : null;
643
+ })
644
+ .filter((id) => id !== null);
645
+ }
@@ -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");
@@ -531,6 +532,57 @@ class AgentInboxService {
531
532
  this.reconcileAgentStatus(agentId);
532
533
  return { removed: true };
533
534
  }
535
+ async resumeAgent(agentId) {
536
+ this.getAgent(agentId);
537
+ const targets = this.store.listActivationTargetsForAgent(agentId);
538
+ const results = [];
539
+ for (const target of targets) {
540
+ results.push(await this.resumeActivationTarget(agentId, target.targetId));
541
+ }
542
+ this.reconcileAgentStatus(agentId);
543
+ return {
544
+ resumed: results.some((result) => result.resumed),
545
+ agent: this.getAgent(agentId),
546
+ targets: results,
547
+ };
548
+ }
549
+ async resumeActivationTarget(agentId, targetId) {
550
+ const target = this.getActivationTarget(targetId);
551
+ if (target.agentId !== agentId) {
552
+ throw new Error(`activation target ${targetId} does not belong to agent ${agentId}`);
553
+ }
554
+ if (target.status === "active") {
555
+ return {
556
+ targetId: target.targetId,
557
+ resumed: false,
558
+ status: target.status,
559
+ reason: "already_active",
560
+ };
561
+ }
562
+ let result;
563
+ if (target.kind === "terminal") {
564
+ result = await this.resumeTerminalActivationTarget(target);
565
+ }
566
+ else {
567
+ const now = (0, util_1.nowIso)();
568
+ this.store.updateActivationTargetRuntime(target.targetId, {
569
+ status: "active",
570
+ offlineSince: null,
571
+ consecutiveFailures: 0,
572
+ lastError: null,
573
+ updatedAt: now,
574
+ lastSeenAt: now,
575
+ });
576
+ result = {
577
+ targetId: target.targetId,
578
+ resumed: true,
579
+ status: "active",
580
+ reason: "webhook_resumed",
581
+ };
582
+ }
583
+ this.reconcileAgentStatus(agentId);
584
+ return result;
585
+ }
534
586
  async registerSubscription(input) {
535
587
  if (input.shortcut) {
536
588
  const source = this.store.getSource(input.sourceId);
@@ -1355,14 +1407,27 @@ class AgentInboxService {
1355
1407
  try {
1356
1408
  const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
1357
1409
  if (!state) {
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
+ }));
1358
1422
  const dispatched = await this.dispatchActivationTarget({
1359
1423
  agentId: buffer.agentId,
1360
1424
  targetId: buffer.targetId,
1361
1425
  newItemCount: entries.length,
1362
- summary: entries[0]?.summary ?? null,
1426
+ totalUnackedCount: unackedItems.length,
1427
+ summary: latestSummary(entries),
1363
1428
  subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1364
1429
  sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1365
- items: entries.map((entry) => entry.item),
1430
+ items: unackedItems,
1366
1431
  });
1367
1432
  if (dispatched === "retryable_failure") {
1368
1433
  this.upsertDirtyDispatchState(buffer.agentId, buffer.targetId, entries);
@@ -1374,8 +1439,9 @@ class AgentInboxService {
1374
1439
  targetId: buffer.targetId,
1375
1440
  status: "dirty",
1376
1441
  leaseExpiresAt: state.leaseExpiresAt,
1442
+ lastNotifiedFingerprint: state.lastNotifiedFingerprint,
1377
1443
  pendingNewItemCount: state.pendingNewItemCount + entries.length,
1378
- pendingSummary: state.pendingSummary ?? entries[0]?.summary ?? null,
1444
+ pendingSummary: latestSummary(entries) ?? state.pendingSummary,
1379
1445
  pendingSubscriptionIds: uniqueSortedNullable([...state.pendingSubscriptionIds, ...entries.map((entry) => entry.subscriptionId)]),
1380
1446
  pendingSourceIds: uniqueSorted([...state.pendingSourceIds, ...entries.map((entry) => entry.sourceId)]),
1381
1447
  updatedAt: (0, util_1.nowIso)(),
@@ -1424,10 +1490,25 @@ class AgentInboxService {
1424
1490
  if (reason === "ack" && state.status !== "dirty") {
1425
1491
  return;
1426
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
+ }
1427
1507
  const dispatched = await this.dispatchActivationTarget({
1428
1508
  agentId,
1429
1509
  targetId,
1430
1510
  newItemCount: state.pendingNewItemCount > 0 ? state.pendingNewItemCount : unacked,
1511
+ totalUnackedCount: unacked,
1431
1512
  summary: state.pendingSummary,
1432
1513
  subscriptionIds: state.pendingSubscriptionIds,
1433
1514
  sourceIds: state.pendingSourceIds,
@@ -1461,8 +1542,11 @@ class AgentInboxService {
1461
1542
  if (target.status === "offline") {
1462
1543
  return "offline";
1463
1544
  }
1545
+ const state = this.store.getActivationDispatchState(input.agentId, input.targetId);
1464
1546
  const inbox = this.ensureInboxForAgent(input.agentId);
1465
1547
  const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
1548
+ const totalUnackedCount = input.totalUnackedCount ?? this.store.countInboxItems(inbox.inboxId, false);
1549
+ let terminalPrompt = null;
1466
1550
  try {
1467
1551
  if (target.kind === "terminal") {
1468
1552
  const gate = await this.activationGate.evaluate(target);
@@ -1477,6 +1561,7 @@ class AgentInboxService {
1477
1561
  targetId: input.targetId,
1478
1562
  status: "dirty",
1479
1563
  leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1564
+ lastNotifiedFingerprint: state?.lastNotifiedFingerprint ?? null,
1480
1565
  pendingNewItemCount: input.newItemCount,
1481
1566
  pendingSummary: input.summary,
1482
1567
  pendingSubscriptionIds: uniqueSortedNullable(input.subscriptionIds),
@@ -1485,11 +1570,32 @@ class AgentInboxService {
1485
1570
  });
1486
1571
  return "deferred";
1487
1572
  }
1573
+ const preview = totalUnackedCount === 1 && input.items.length === 1
1574
+ ? (0, terminal_1.deriveInlineItemPreview)(input.items[0], input.summary)
1575
+ : null;
1488
1576
  const prompt = (0, terminal_1.renderAgentPrompt)({
1489
1577
  inboxId: inbox.inboxId,
1490
- newItemCount: input.newItemCount,
1578
+ totalUnackedCount,
1491
1579
  summary: input.summary,
1580
+ preview,
1492
1581
  });
1582
+ terminalPrompt = prompt;
1583
+ const promptFingerprint = terminalReminderFingerprint(prompt, input.items);
1584
+ if (state?.status === "dirty" && state.lastNotifiedFingerprint === promptFingerprint) {
1585
+ this.store.upsertActivationDispatchState({
1586
+ agentId: input.agentId,
1587
+ targetId: input.targetId,
1588
+ status: "notified",
1589
+ leaseExpiresAt: new Date(Date.now() + target.notifyLeaseMs).toISOString(),
1590
+ lastNotifiedFingerprint: promptFingerprint,
1591
+ pendingNewItemCount: 0,
1592
+ pendingSummary: null,
1593
+ pendingSubscriptionIds: [],
1594
+ pendingSourceIds: [],
1595
+ updatedAt: (0, util_1.nowIso)(),
1596
+ });
1597
+ return "suppressed";
1598
+ }
1493
1599
  await this.terminalDispatcher.dispatch(target, prompt);
1494
1600
  }
1495
1601
  else {
@@ -1518,6 +1624,9 @@ class AgentInboxService {
1518
1624
  targetId: input.targetId,
1519
1625
  status: "notified",
1520
1626
  leaseExpiresAt: new Date(Date.now() + target.notifyLeaseMs).toISOString(),
1627
+ lastNotifiedFingerprint: target.kind === "terminal"
1628
+ ? terminalReminderFingerprint(terminalPrompt ?? "", input.items)
1629
+ : state?.lastNotifiedFingerprint ?? null,
1521
1630
  pendingNewItemCount: 0,
1522
1631
  pendingSummary: null,
1523
1632
  pendingSubscriptionIds: [],
@@ -1547,8 +1656,9 @@ class AgentInboxService {
1547
1656
  targetId,
1548
1657
  status: "dirty",
1549
1658
  leaseExpiresAt: new Date(Date.now() + DEFAULT_NOTIFY_RETRY_MS).toISOString(),
1659
+ lastNotifiedFingerprint: this.store.getActivationDispatchState(agentId, targetId)?.lastNotifiedFingerprint ?? null,
1550
1660
  pendingNewItemCount: entries.length,
1551
- pendingSummary: entries[0]?.summary ?? null,
1661
+ pendingSummary: latestSummary(entries),
1552
1662
  pendingSubscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1553
1663
  pendingSourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
1554
1664
  updatedAt: (0, util_1.nowIso)(),
@@ -1644,6 +1754,46 @@ class AgentInboxService {
1644
1754
  lastSeenAt: agent.lastSeenAt,
1645
1755
  });
1646
1756
  }
1757
+ async resumeTerminalActivationTarget(target) {
1758
+ const probeStatus = await this.terminalDispatcher.probeStatus(target);
1759
+ const now = (0, util_1.nowIso)();
1760
+ if (probeStatus === "available") {
1761
+ this.store.updateActivationTargetRuntime(target.targetId, {
1762
+ status: "active",
1763
+ offlineSince: null,
1764
+ consecutiveFailures: 0,
1765
+ lastError: null,
1766
+ updatedAt: now,
1767
+ lastSeenAt: now,
1768
+ });
1769
+ return {
1770
+ targetId: target.targetId,
1771
+ resumed: true,
1772
+ status: "active",
1773
+ reason: "terminal_available",
1774
+ };
1775
+ }
1776
+ if (probeStatus === "gone") {
1777
+ this.store.updateActivationTargetRuntime(target.targetId, {
1778
+ status: "offline",
1779
+ offlineSince: target.offlineSince ?? now,
1780
+ updatedAt: now,
1781
+ lastError: target.lastError ?? "terminal gone",
1782
+ });
1783
+ return {
1784
+ targetId: target.targetId,
1785
+ resumed: false,
1786
+ status: "offline",
1787
+ reason: "terminal_gone",
1788
+ };
1789
+ }
1790
+ return {
1791
+ targetId: target.targetId,
1792
+ resumed: false,
1793
+ status: "offline",
1794
+ reason: "probe_unknown",
1795
+ };
1796
+ }
1647
1797
  runLifecycleCleanupPass(nowMs) {
1648
1798
  this.lastLifecycleCleanupAt = nowMs;
1649
1799
  const removed = new Set();
@@ -2193,6 +2343,24 @@ function summarizeActivation(inboxId, newItemCount, firstSummary) {
2193
2343
  }
2194
2344
  return `${newItemCount} new ${itemWord} in ${inboxId}`;
2195
2345
  }
2346
+ function latestSummary(entries) {
2347
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
2348
+ const summary = entries[index]?.summary;
2349
+ if (summary) {
2350
+ return summary;
2351
+ }
2352
+ }
2353
+ return null;
2354
+ }
2355
+ function terminalReminderFingerprint(prompt, items) {
2356
+ const payload = JSON.stringify({
2357
+ prompt,
2358
+ itemIds: items
2359
+ .map((item) => item.itemId)
2360
+ .sort((left, right) => left.localeCompare(right)),
2361
+ });
2362
+ return (0, node_crypto_1.createHash)("sha256").update(payload).digest("hex");
2363
+ }
2196
2364
  function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
2197
2365
  if (sourceType === "github_repo_ci") {
2198
2366
  const parts = eventVariant.split(".");
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),
@@ -1251,6 +1253,7 @@ class AgentInboxStore {
1251
1253
  targetId: String(row.target_id),
1252
1254
  status: row.status,
1253
1255
  leaseExpiresAt: row.lease_expires_at ? String(row.lease_expires_at) : null,
1256
+ lastNotifiedFingerprint: row.last_notified_fingerprint ? String(row.last_notified_fingerprint) : null,
1254
1257
  pendingNewItemCount: Number(row.pending_new_item_count),
1255
1258
  pendingSummary: row.pending_summary ? String(row.pending_summary) : null,
1256
1259
  pendingSubscriptionIds: parseJson(row.pending_subscription_ids_json),
@@ -6,6 +6,7 @@ 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;
9
10
  exports.assignedAgentIdFromContext = assignedAgentIdFromContext;
10
11
  const node_child_process_1 = require("node:child_process");
11
12
  const node_util_1 = require("node:util");
@@ -46,12 +47,44 @@ function detectTerminalContext(env = process.env) {
46
47
  throw new Error("unable to detect a supported terminal context");
47
48
  }
48
49
  function renderAgentPrompt(input) {
49
- const base = `AgentInbox: ${input.newItemCount} new items arrived in inbox ${input.inboxId}.`;
50
+ const totalUnackedCount = input.totalUnackedCount ?? input.newItemCount ?? 0;
51
+ const itemWord = totalUnackedCount === 1 ? "item" : "items";
52
+ const base = `AgentInbox: ${totalUnackedCount} unacked ${itemWord} in inbox ${input.inboxId}.`;
53
+ if (totalUnackedCount === 1 && input.preview) {
54
+ const suffix = /(?:[.!?…]|\.{3})$/.test(input.preview) ? "" : ".";
55
+ return `${base} Preview: ${input.preview}${suffix} Read the inbox for full details if needed.`;
56
+ }
50
57
  if (input.summary) {
51
58
  return `${base} Summary: ${input.summary}. Please read the inbox, process them, and ack when finished.`;
52
59
  }
53
60
  return `${base} Please read the inbox, process them, and ack when finished.`;
54
61
  }
62
+ const MAX_INLINE_PREVIEW_INPUT_CHARS = 240;
63
+ const MAX_INLINE_PREVIEW_CHARS = 96;
64
+ const INLINE_PREVIEW_KEYS = ["message", "text", "body", "title"];
65
+ function deriveInlineItemPreview(item, summary) {
66
+ const candidates = [];
67
+ for (const key of INLINE_PREVIEW_KEYS) {
68
+ const rawPayloadValue = item.rawPayload[key];
69
+ if (typeof rawPayloadValue === "string") {
70
+ candidates.push(rawPayloadValue);
71
+ }
72
+ const metadataValue = item.metadata[key];
73
+ if (typeof metadataValue === "string") {
74
+ candidates.push(metadataValue);
75
+ }
76
+ }
77
+ if (isPreviewFriendlySummary(summary)) {
78
+ candidates.push(summary);
79
+ }
80
+ for (const candidate of candidates) {
81
+ const preview = normalizeInlinePreview(candidate);
82
+ if (preview) {
83
+ return preview;
84
+ }
85
+ }
86
+ return null;
87
+ }
55
88
  function assignedAgentIdFromContext(input) {
56
89
  const primary = normalizeOptionalString(input.runtimeSessionId)
57
90
  ?? normalizeOptionalString(input.tmuxPaneId)
@@ -90,31 +123,44 @@ class TerminalDispatcher {
90
123
  throw new Error(`unsupported terminal backend: ${String(target.backend)}`);
91
124
  }
92
125
  async probe(target) {
126
+ return (await this.probeStatus(target)) === "available";
127
+ }
128
+ async probeStatus(target) {
93
129
  try {
94
130
  if (target.backend === "tmux") {
95
131
  if (!target.tmuxPaneId) {
96
- return false;
132
+ return "unknown";
97
133
  }
98
134
  const result = await this.execAsync("tmux", ["display-message", "-p", "-t", target.tmuxPaneId, "#{pane_id}"]);
99
- return result.stdout.trim() === target.tmuxPaneId;
135
+ return result.stdout.trim() === target.tmuxPaneId ? "available" : "gone";
100
136
  }
101
137
  if (target.backend === "iterm2") {
102
138
  const sessionId = normalizeOptionalString(target.itermSessionId);
103
139
  if (!sessionId) {
104
- return false;
140
+ return "unknown";
105
141
  }
106
142
  const it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
107
143
  const result = await this.execAsync(it2api, ["list-sessions"]);
108
- return result.stdout.includes(sessionId);
144
+ return result.stdout.includes(sessionId) ? "available" : "gone";
109
145
  }
110
146
  }
111
- catch {
112
- return false;
147
+ catch (error) {
148
+ if (target.backend === "tmux" && isTmuxMissingPaneError(error)) {
149
+ return "gone";
150
+ }
151
+ return "unknown";
113
152
  }
114
- return false;
153
+ return "unknown";
115
154
  }
116
155
  }
117
156
  exports.TerminalDispatcher = TerminalDispatcher;
157
+ function isTmuxMissingPaneError(error) {
158
+ if (!(error instanceof Error)) {
159
+ return false;
160
+ }
161
+ const candidate = `${error.message} ${"stderr" in error ? String(error.stderr ?? "") : ""}`;
162
+ return candidate.includes("can't find pane");
163
+ }
118
164
  function detectTty(env) {
119
165
  const direct = tryReadCurrentTty();
120
166
  if (direct) {
@@ -161,6 +207,44 @@ function normalizeItermSessionId(raw) {
161
207
  const parts = value.split(":");
162
208
  return parts[parts.length - 1] || null;
163
209
  }
210
+ function normalizeInlinePreview(value) {
211
+ const normalized = value.replace(/\s+/g, " ").trim();
212
+ if (!normalized) {
213
+ return null;
214
+ }
215
+ if (normalized.length > MAX_INLINE_PREVIEW_INPUT_CHARS) {
216
+ return null;
217
+ }
218
+ if (looksStructured(normalized)) {
219
+ return null;
220
+ }
221
+ if (normalized.length <= MAX_INLINE_PREVIEW_CHARS) {
222
+ return normalized;
223
+ }
224
+ return `${normalized.slice(0, MAX_INLINE_PREVIEW_CHARS - 3).trimEnd()}...`;
225
+ }
226
+ function isPreviewFriendlySummary(summary) {
227
+ if (typeof summary !== "string") {
228
+ return false;
229
+ }
230
+ const normalized = summary.trim();
231
+ if (!normalized) {
232
+ return false;
233
+ }
234
+ if (!normalized.includes(" ")) {
235
+ return false;
236
+ }
237
+ if (/^[a-z0-9_.-]+:/i.test(normalized)) {
238
+ return false;
239
+ }
240
+ return true;
241
+ }
242
+ function looksStructured(value) {
243
+ if ((value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"))) {
244
+ return true;
245
+ }
246
+ return false;
247
+ }
164
248
  function detectRuntimeContext(env) {
165
249
  const codexThreadId = normalizeOptionalString(env.CODEX_THREAD_ID);
166
250
  if (codexThreadId) {
@@ -0,0 +1,2 @@
1
+ alter table activation_dispatch_states
2
+ add column last_notified_fingerprint text;
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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@types/node": "^24.5.2",
51
51
  "@types/sql.js": "^1.4.9",
52
- "drizzle-kit": "^0.31.6",
53
- "drizzle-orm": "^0.44.2",
52
+ "drizzle-kit": "1.0.0-beta.21",
53
+ "drizzle-orm": "^0.45.2",
54
54
  "mdorigin": "^0.5.0",
55
55
  "ts-node": "^10.9.2",
56
56
  "typescript": "^5.9.2"