@holon-run/agentinbox 0.3.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/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
+ }
@@ -531,6 +531,57 @@ class AgentInboxService {
531
531
  this.reconcileAgentStatus(agentId);
532
532
  return { removed: true };
533
533
  }
534
+ async resumeAgent(agentId) {
535
+ this.getAgent(agentId);
536
+ const targets = this.store.listActivationTargetsForAgent(agentId);
537
+ const results = [];
538
+ for (const target of targets) {
539
+ results.push(await this.resumeActivationTarget(agentId, target.targetId));
540
+ }
541
+ this.reconcileAgentStatus(agentId);
542
+ return {
543
+ resumed: results.some((result) => result.resumed),
544
+ agent: this.getAgent(agentId),
545
+ targets: results,
546
+ };
547
+ }
548
+ async resumeActivationTarget(agentId, targetId) {
549
+ const target = this.getActivationTarget(targetId);
550
+ if (target.agentId !== agentId) {
551
+ throw new Error(`activation target ${targetId} does not belong to agent ${agentId}`);
552
+ }
553
+ if (target.status === "active") {
554
+ return {
555
+ targetId: target.targetId,
556
+ resumed: false,
557
+ status: target.status,
558
+ reason: "already_active",
559
+ };
560
+ }
561
+ let result;
562
+ if (target.kind === "terminal") {
563
+ result = await this.resumeTerminalActivationTarget(target);
564
+ }
565
+ else {
566
+ const now = (0, util_1.nowIso)();
567
+ this.store.updateActivationTargetRuntime(target.targetId, {
568
+ status: "active",
569
+ offlineSince: null,
570
+ consecutiveFailures: 0,
571
+ lastError: null,
572
+ updatedAt: now,
573
+ lastSeenAt: now,
574
+ });
575
+ result = {
576
+ targetId: target.targetId,
577
+ resumed: true,
578
+ status: "active",
579
+ reason: "webhook_resumed",
580
+ };
581
+ }
582
+ this.reconcileAgentStatus(agentId);
583
+ return result;
584
+ }
534
585
  async registerSubscription(input) {
535
586
  if (input.shortcut) {
536
587
  const source = this.store.getSource(input.sourceId);
@@ -1355,10 +1406,12 @@ class AgentInboxService {
1355
1406
  try {
1356
1407
  const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
1357
1408
  if (!state) {
1409
+ const inbox = this.ensureInboxForAgent(buffer.agentId);
1358
1410
  const dispatched = await this.dispatchActivationTarget({
1359
1411
  agentId: buffer.agentId,
1360
1412
  targetId: buffer.targetId,
1361
1413
  newItemCount: entries.length,
1414
+ totalUnackedCount: this.store.countInboxItems(inbox.inboxId, false),
1362
1415
  summary: entries[0]?.summary ?? null,
1363
1416
  subscriptionIds: uniqueSortedNullable(entries.map((entry) => entry.subscriptionId)),
1364
1417
  sourceIds: uniqueSorted(entries.map((entry) => entry.sourceId)),
@@ -1428,6 +1481,7 @@ class AgentInboxService {
1428
1481
  agentId,
1429
1482
  targetId,
1430
1483
  newItemCount: state.pendingNewItemCount > 0 ? state.pendingNewItemCount : unacked,
1484
+ totalUnackedCount: unacked,
1431
1485
  summary: state.pendingSummary,
1432
1486
  subscriptionIds: state.pendingSubscriptionIds,
1433
1487
  sourceIds: state.pendingSourceIds,
@@ -1463,6 +1517,7 @@ class AgentInboxService {
1463
1517
  }
1464
1518
  const inbox = this.ensureInboxForAgent(input.agentId);
1465
1519
  const summary = summarizeActivation(inbox.inboxId, input.newItemCount, input.summary);
1520
+ const totalUnackedCount = input.totalUnackedCount ?? this.store.countInboxItems(inbox.inboxId, false);
1466
1521
  try {
1467
1522
  if (target.kind === "terminal") {
1468
1523
  const gate = await this.activationGate.evaluate(target);
@@ -1487,7 +1542,7 @@ class AgentInboxService {
1487
1542
  }
1488
1543
  const prompt = (0, terminal_1.renderAgentPrompt)({
1489
1544
  inboxId: inbox.inboxId,
1490
- newItemCount: input.newItemCount,
1545
+ totalUnackedCount,
1491
1546
  summary: input.summary,
1492
1547
  });
1493
1548
  await this.terminalDispatcher.dispatch(target, prompt);
@@ -1644,6 +1699,46 @@ class AgentInboxService {
1644
1699
  lastSeenAt: agent.lastSeenAt,
1645
1700
  });
1646
1701
  }
1702
+ async resumeTerminalActivationTarget(target) {
1703
+ const probeStatus = await this.terminalDispatcher.probeStatus(target);
1704
+ const now = (0, util_1.nowIso)();
1705
+ if (probeStatus === "available") {
1706
+ this.store.updateActivationTargetRuntime(target.targetId, {
1707
+ status: "active",
1708
+ offlineSince: null,
1709
+ consecutiveFailures: 0,
1710
+ lastError: null,
1711
+ updatedAt: now,
1712
+ lastSeenAt: now,
1713
+ });
1714
+ return {
1715
+ targetId: target.targetId,
1716
+ resumed: true,
1717
+ status: "active",
1718
+ reason: "terminal_available",
1719
+ };
1720
+ }
1721
+ if (probeStatus === "gone") {
1722
+ this.store.updateActivationTargetRuntime(target.targetId, {
1723
+ status: "offline",
1724
+ offlineSince: target.offlineSince ?? now,
1725
+ updatedAt: now,
1726
+ lastError: target.lastError ?? "terminal gone",
1727
+ });
1728
+ return {
1729
+ targetId: target.targetId,
1730
+ resumed: false,
1731
+ status: "offline",
1732
+ reason: "terminal_gone",
1733
+ };
1734
+ }
1735
+ return {
1736
+ targetId: target.targetId,
1737
+ resumed: false,
1738
+ status: "offline",
1739
+ reason: "probe_unknown",
1740
+ };
1741
+ }
1647
1742
  runLifecycleCleanupPass(nowMs) {
1648
1743
  this.lastLifecycleCleanupAt = nowMs;
1649
1744
  const removed = new Set();
@@ -46,7 +46,9 @@ function detectTerminalContext(env = process.env) {
46
46
  throw new Error("unable to detect a supported terminal context");
47
47
  }
48
48
  function renderAgentPrompt(input) {
49
- 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}.`;
50
52
  if (input.summary) {
51
53
  return `${base} Summary: ${input.summary}. Please read the inbox, process them, and ack when finished.`;
52
54
  }
@@ -90,31 +92,44 @@ class TerminalDispatcher {
90
92
  throw new Error(`unsupported terminal backend: ${String(target.backend)}`);
91
93
  }
92
94
  async probe(target) {
95
+ return (await this.probeStatus(target)) === "available";
96
+ }
97
+ async probeStatus(target) {
93
98
  try {
94
99
  if (target.backend === "tmux") {
95
100
  if (!target.tmuxPaneId) {
96
- return false;
101
+ return "unknown";
97
102
  }
98
103
  const result = await this.execAsync("tmux", ["display-message", "-p", "-t", target.tmuxPaneId, "#{pane_id}"]);
99
- return result.stdout.trim() === target.tmuxPaneId;
104
+ return result.stdout.trim() === target.tmuxPaneId ? "available" : "gone";
100
105
  }
101
106
  if (target.backend === "iterm2") {
102
107
  const sessionId = normalizeOptionalString(target.itermSessionId);
103
108
  if (!sessionId) {
104
- return false;
109
+ return "unknown";
105
110
  }
106
111
  const it2api = resolveIterm2ApiPath(this.options.iterm2ApiPath);
107
112
  const result = await this.execAsync(it2api, ["list-sessions"]);
108
- return result.stdout.includes(sessionId);
113
+ return result.stdout.includes(sessionId) ? "available" : "gone";
109
114
  }
110
115
  }
111
- catch {
112
- return false;
116
+ catch (error) {
117
+ if (target.backend === "tmux" && isTmuxMissingPaneError(error)) {
118
+ return "gone";
119
+ }
120
+ return "unknown";
113
121
  }
114
- return false;
122
+ return "unknown";
115
123
  }
116
124
  }
117
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
+ }
118
133
  function detectTty(env) {
119
134
  const direct = tryReadCurrentTty();
120
135
  if (direct) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "0.3.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": {