agent-ticketing 0.1.2 → 0.1.3

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
@@ -75,6 +75,19 @@ Runners are agent-specific — `mailbox-claude-runner` launches headless **Claud
75
75
  sessions. Runners for Codex and daemon-style agents (Hermes, OpenClaw) are planned;
76
76
  for a custom harness, embed the SDK instead (below).
77
77
 
78
+ ### Runner behavior (v0.1.3)
79
+
80
+ - Runs Claude with **`--permission-mode bypassPermissions`** — an autonomous runner
81
+ must edit files and run commands unattended. Override with
82
+ `CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`, `plan`).
83
+ - **Ask → pending → resume**: if Claude asks a question mid-task (AskUserQuestion),
84
+ the runner posts it to the ticket (`pending`), and after the requester replies it
85
+ **resumes the same Claude session** with the answer — full conversational loop.
86
+ - Announces the Claude **session id** in the ticket thread and the ndjson log
87
+ (`claude_session` event) — inspect a stuck run with `claude --resume <id>`.
88
+ - `CLAUDE_TIMEOUT_MS` (default 10 min, 0 disables) with SIGTERM→SIGKILL escalation;
89
+ JSON output is salvaged from timed-out processes when possible.
90
+
78
91
  ## Low-level client
79
92
 
80
93
  Every REST endpoint is also exposed directly if you don't want the loop:
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export type Ticket = {
20
20
  status: TicketStatus;
21
21
  requesterId: string;
22
22
  assigneeAgentId: string | null;
23
+ releasedFromAgentId?: string | null;
23
24
  claim: {
24
25
  agentId: string;
25
26
  agentInstanceId: string;
@@ -63,7 +64,7 @@ export type MailboxConfig = {
63
64
  };
64
65
  export type MailboxEvent = {
65
66
  ts: string;
66
- event: "poll_start" | "claimed" | "completed" | "released" | "claim_lost" | "handler_error" | "poll_error";
67
+ event: "poll_start" | "claimed" | "completed" | "released" | "claim_lost" | "handler_error" | "poll_error" | "asked" | (string & {});
67
68
  instanceId: string;
68
69
  ticketId?: string;
69
70
  subject?: string;
@@ -111,6 +112,10 @@ export type TicketContext = {
111
112
  ask(question: string, opts?: {
112
113
  pollSeconds?: number;
113
114
  }): Promise<string>;
115
+ /** Ask the human and EXIT the handler (for runners that resume later). The ticket
116
+ * goes pending; the claim expires naturally and the assignee is kept, so this
117
+ * agent re-claims after the human replies. */
118
+ askAsync(question: string): Promise<void>;
114
119
  /** Upload a file; returns a staged attachmentId to pass to post()/complete(). */
115
120
  attach(filePath: string, contentType?: string): Promise<string>;
116
121
  /** Mark the work done (ticket → done, awaiting human review). Ends the handler's claim. */
package/dist/index.js CHANGED
@@ -193,6 +193,12 @@ export class MailboxAgent {
193
193
  assertLive(claimLost.signal);
194
194
  await self.postMessage(ticket.id, body, { attachmentIds: opts?.attachmentIds });
195
195
  },
196
+ async askAsync(question) {
197
+ assertLive(claimLost.signal);
198
+ await self.postMessage(ticket.id, question, { askRequester: true });
199
+ finished = true;
200
+ o.emit({ event: "asked", ticketId: ticket.id });
201
+ },
196
202
  async ask(question, opts) {
197
203
  assertLive(claimLost.signal);
198
204
  const asked = await self.postMessage(ticket.id, question, { askRequester: true });
package/dist/runner.js CHANGED
@@ -203,58 +203,143 @@ async function fetchAttachments(ticket, messages) {
203
203
  return paths;
204
204
  }
205
205
  /**
206
- * Run `claude -p` with the prompt on STDIN (immune to ARG_MAX for long threads,
207
- * keeps ticket text out of `ps`). On failure, the error carries the exit code
208
- * AND the stderr tailso the release reason / ndjson log show the real cause.
206
+ * Run headless Claude Code (ideas borrowed from claude-code-telegram's runner):
207
+ * - prompt via STDIN (ARG_MAX-proof, quoting-proof, not in `ps`)
208
+ * - `--permission-mode bypassPermissions` by default an autonomous runner must
209
+ * be able to edit files / run commands (override: CLAUDE_PERMISSION_MODE)
210
+ * - `--output-format json` → { result, session_id, is_error, permission_denials }
211
+ * - SIGTERM → SIGKILL escalation; salvage JSON from a timed-out process
209
212
  */
210
- function runClaude(prompt, signal) {
211
- return new Promise((resolve, reject) => {
212
- // The runner is often launched from INSIDE a Claude session (setup prompt);
213
- // strip nested-session markers so the child behaves like a fresh CLI run.
213
+ const PERMISSION_MODE = process.env.CLAUDE_PERMISSION_MODE ?? "bypassPermissions";
214
+ const CLAUDE_TIMEOUT_MS = Number(process.env.CLAUDE_TIMEOUT_MS ?? String(10 * 60 * 1000));
215
+ function runClaude(prompt, session, signal) {
216
+ return new Promise((resolve) => {
214
217
  const env = { ...process.env };
215
218
  delete env.CLAUDECODE;
216
219
  delete env.CLAUDE_CODE_ENTRYPOINT;
217
- const child = spawn(claudeCmd, ["-p", "--output-format", "text"], {
218
- env,
219
- signal,
220
- timeout: 10 * 60 * 1000,
221
- stdio: ["pipe", "pipe", "pipe"],
222
- });
220
+ const args = ["-p", "--output-format", "json", "--permission-mode", PERMISSION_MODE];
221
+ if (session.resume)
222
+ args.push("--resume", session.resume);
223
+ else if (session.newId)
224
+ args.push("--session-id", session.newId);
225
+ const child = spawn(claudeCmd, args, { env, stdio: ["pipe", "pipe", "pipe"] });
223
226
  let stdout = "";
224
227
  let stderr = "";
225
- child.stdout.on("data", (d) => {
226
- if (stdout.length < 10 * 1024 * 1024)
227
- stdout += d.toString();
228
- });
229
- child.stderr.on("data", (d) => {
230
- if (stderr.length < 1024 * 1024)
231
- stderr += d.toString();
232
- });
233
- child.on("error", (e) => reject(new Error(`could not run "${claudeCmd}": ${e.message}`)));
228
+ let timedOut = false;
229
+ let killTimer;
230
+ const kill = () => {
231
+ try {
232
+ child.kill();
233
+ }
234
+ catch { /* already gone */ }
235
+ killTimer = setTimeout(() => {
236
+ try {
237
+ child.kill("SIGKILL");
238
+ }
239
+ catch { /* already gone */ }
240
+ }, 3_000);
241
+ };
242
+ const timeout = CLAUDE_TIMEOUT_MS > 0
243
+ ? setTimeout(() => { timedOut = true; kill(); }, CLAUDE_TIMEOUT_MS)
244
+ : undefined;
245
+ const onAbort = () => kill();
246
+ signal?.addEventListener("abort", onAbort, { once: true });
247
+ child.stdout.on("data", (d) => { if (stdout.length < 10 * 1024 * 1024)
248
+ stdout += d.toString(); });
249
+ child.stderr.on("data", (d) => { if (stderr.length < 1024 * 1024)
250
+ stderr += d.toString(); });
251
+ child.on("error", (e) => finish({ kind: "error", message: `could not run "${claudeCmd}": ${e.message}` }));
234
252
  child.on("close", (code, sig) => {
235
- if (code === 0)
236
- return resolve(stdout);
253
+ const parsed = parseClaudeOutput(stdout, stderr);
254
+ if (parsed)
255
+ return finish(parsed);
256
+ if (timedOut)
257
+ return finish({ kind: "error", message: `claude timed out after ${Math.round(CLAUDE_TIMEOUT_MS / 60000)} min` });
237
258
  const tail = stderr.trim().slice(-600) || stdout.trim().slice(-600) || "(no output)";
238
- reject(new Error(`${claudeCmd} exited with ${sig ? `signal ${sig}` : `code ${code}`}: ${tail}`));
259
+ finish({ kind: "error", message: `${claudeCmd} exited with ${sig ? `signal ${sig}` : `code ${code}`}: ${tail}` });
239
260
  });
240
- child.stdin.on("error", () => undefined); // child died before reading stdin; close() reports it
261
+ function finish(o) {
262
+ if (timeout)
263
+ clearTimeout(timeout);
264
+ if (killTimer)
265
+ clearTimeout(killTimer);
266
+ signal?.removeEventListener("abort", onAbort);
267
+ resolve(o);
268
+ }
269
+ child.stdin.on("error", () => undefined);
241
270
  child.stdin.write(prompt);
242
271
  child.stdin.end();
243
272
  });
244
273
  }
274
+ function parseClaudeOutput(stdout, stderr) {
275
+ if (/No conversation found with session ID/i.test(`${stdout}\n${stderr}`)) {
276
+ return { kind: "stale_session" };
277
+ }
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(stdout);
281
+ }
282
+ catch {
283
+ return null; // not JSON — let close-handler report exit code/stderr
284
+ }
285
+ if (parsed.is_error)
286
+ return { kind: "error", message: parsed.result || "claude reported an error" };
287
+ return { kind: "ok", result: (parsed.result ?? "").trim(), questions: extractQuestions(parsed.permission_denials) };
288
+ }
289
+ /** Headless mode auto-denies AskUserQuestion but preserves it in permission_denials —
290
+ * recover the questions so they become ask_requester on the ticket. */
291
+ function extractQuestions(denials) {
292
+ const out = [];
293
+ for (const d of denials ?? []) {
294
+ if (d?.tool_name !== "AskUserQuestion")
295
+ continue;
296
+ const qs = d.tool_input?.questions;
297
+ if (!Array.isArray(qs))
298
+ continue;
299
+ for (const q of qs) {
300
+ if (typeof q?.question !== "string")
301
+ continue;
302
+ let text = q.question;
303
+ const opts = (q.options ?? []).filter((o) => typeof o?.label === "string");
304
+ if (opts.length > 0) {
305
+ text += "\n" + opts.map((o) => `- **${o.label}**${o.description ? ` — ${o.description}` : ""}`).join("\n");
306
+ }
307
+ out.push(text);
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+ /** A prior run of this ticket announced its session in a progress note — find it. */
313
+ function findPreviousSession(messages) {
314
+ for (let i = messages.length - 1; i >= 0; i--) {
315
+ const m = messages[i];
316
+ if (m.kind !== "progress")
317
+ continue;
318
+ const match = /headless Claude Code session ([0-9a-f-]{36})/.exec(m.body);
319
+ if (match)
320
+ return match[1];
321
+ }
322
+ return null;
323
+ }
324
+ /** For resumed sessions: only what happened since the agent last spoke. */
325
+ function repliesSince(messages) {
326
+ let lastAgent = -1;
327
+ messages.forEach((m, i) => {
328
+ if (m.author.type === "agent")
329
+ lastAgent = i;
330
+ });
331
+ const fresh = messages.slice(lastAgent + 1).filter((m) => m.kind === "message" && m.author.type === "human");
332
+ return fresh.map((m) => m.body).join("\n\n");
333
+ }
245
334
  const controller = new AbortController();
246
335
  await mailbox.poll(async (ticket, ctx) => {
247
- const attachmentPaths = await fetchAttachments(ticket, ctx.messages);
248
- await ctx.progress(attachmentPaths.length > 0
249
- ? `launching headless Claude Code session (${attachmentPaths.length} attachment(s) downloaded)`
250
- : "launching headless Claude Code session");
251
- const stdout = await runClaude(promptFor(ticket, ctx.messages, attachmentPaths), ctx.signal);
252
- const result = stdout.trim();
253
- if (!result)
254
- throw new Error("claude produced no output");
255
- await ctx.complete(result.slice(0, 60_000));
256
- if (once)
257
- controller.abort();
336
+ try {
337
+ await workTicket(ticket, ctx);
338
+ }
339
+ finally {
340
+ if (once)
341
+ controller.abort(); // every outcome ends a --once run: complete, ask, or error
342
+ }
258
343
  }, {
259
344
  mode: interval ? "interval" : "continuous",
260
345
  every: interval ?? "5m",
@@ -262,3 +347,44 @@ await mailbox.poll(async (ticket, ctx) => {
262
347
  signal: controller.signal,
263
348
  onEvent,
264
349
  });
350
+ async function workTicket(ticket, ctx) {
351
+ const attachmentPaths = await fetchAttachments(ticket, ctx.messages);
352
+ const previous = findPreviousSession(ctx.messages);
353
+ const sessionId = previous ?? crypto.randomUUID();
354
+ onEvent?.({
355
+ ts: new Date().toISOString(),
356
+ event: "claude_session",
357
+ instanceId: mailbox.instanceId,
358
+ ticketId: ticket.id,
359
+ detail: { message: sessionId + (previous ? " (resumed)" : "") },
360
+ });
361
+ await ctx.progress(`headless Claude Code session ${sessionId}` +
362
+ (previous ? " (resumed)" : "") +
363
+ (attachmentPaths.length > 0 ? ` (${attachmentPaths.length} attachment(s) downloaded)` : ""));
364
+ const freshPrompt = promptFor(ticket, ctx.messages, attachmentPaths);
365
+ const resumePrompt = `The requester replied on the ticket you were working:\n\n${repliesSince(ctx.messages)}\n\n` +
366
+ `Continue the task. Reply with your final result (it is posted back as the ticket resolution), ` +
367
+ `or use the AskUserQuestion tool if you are blocked on the requester again.`;
368
+ let outcome = previous
369
+ ? await runClaude(resumePrompt, { resume: previous }, ctx.signal)
370
+ : await runClaude(freshPrompt, { newId: sessionId }, ctx.signal);
371
+ if (outcome.kind === "stale_session" && previous) {
372
+ // Session file vanished (cache cleaned / machine changed) — start over.
373
+ const freshId = crypto.randomUUID();
374
+ await ctx.progress(`previous session gone — fresh headless Claude Code session ${freshId}`);
375
+ outcome = await runClaude(freshPrompt, { newId: freshId }, ctx.signal);
376
+ }
377
+ if (outcome.kind === "error" || outcome.kind === "stale_session") {
378
+ const message = outcome.kind === "error" ? outcome.message : "session lost and restart failed";
379
+ throw new Error(`${message} [claude session ${sessionId}]`);
380
+ }
381
+ if (outcome.questions.length > 0) {
382
+ // Claude asked the human — ticket goes pending; we resume this session
383
+ // after the reply (assignee survives claim expiry by design).
384
+ await ctx.askAsync(outcome.questions.join("\n\n"));
385
+ return;
386
+ }
387
+ if (!outcome.result)
388
+ throw new Error(`claude produced no output [claude session ${sessionId}]`);
389
+ await ctx.complete(outcome.result.slice(0, 60_000));
390
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-ticketing",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Agent SDK for Agent Mailbox — poll tickets, heartbeat, ask the requester, complete. Zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",