agent-ticketing 0.1.3 → 0.1.5
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 +8 -0
- package/dist/runner.js +235 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,14 @@ for a custom harness, embed the SDK instead (below).
|
|
|
85
85
|
**resumes the same Claude session** with the answer — full conversational loop.
|
|
86
86
|
- Announces the Claude **session id** in the ticket thread and the ndjson log
|
|
87
87
|
(`claude_session` event) — inspect a stuck run with `claude --resume <id>`.
|
|
88
|
+
- **Live progress**: tails the session's JSONL and posts throttled ⚙ steps
|
|
89
|
+
("Bash: npm test", "Edit: app.ts") to the ticket — watch the agent work from
|
|
90
|
+
the dashboard. Sessions run in `MAILBOX_WORKDIR`
|
|
91
|
+
(default `~/.agent-ticketing/workspace`).
|
|
92
|
+
- **Deliverables**: the prompt tells Claude to save output files into a per-ticket
|
|
93
|
+
deliverables directory; the runner uploads them as ticket attachments on
|
|
94
|
+
completion (≤10 files, ≤25MB each — anything skipped is named in the summary).
|
|
95
|
+
Images preview inline in the dashboard.
|
|
88
96
|
- `CLAUDE_TIMEOUT_MS` (default 10 min, 0 disables) with SIGTERM→SIGKILL escalation;
|
|
89
97
|
JSON output is salvaged from timed-out processes when possible.
|
|
90
98
|
|
package/dist/runner.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* npx @mailbox/agent # or: mailbox-claude-runner [--interval 5m] [--once]
|
|
9
9
|
*/
|
|
10
10
|
import { execSync, spawn } from "node:child_process";
|
|
11
|
-
import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
12
|
+
import { open as fsOpen } from "node:fs/promises";
|
|
12
13
|
import { homedir, tmpdir } from "node:os";
|
|
13
|
-
import { basename, join } from "node:path";
|
|
14
|
+
import { basename, join, resolve } from "node:path";
|
|
14
15
|
import { MailboxAgent } from "./index.js";
|
|
15
16
|
const baseUrl = process.env.MAILBOX_URL;
|
|
16
17
|
const apiKey = process.env.MAILBOX_API_KEY;
|
|
@@ -158,6 +159,17 @@ const onEvent = logFile
|
|
|
158
159
|
: undefined;
|
|
159
160
|
// Non-null: every path reaching here passed the env guard (service handlers exit earlier).
|
|
160
161
|
const mailbox = new MailboxAgent({ baseUrl: baseUrl, apiKey: apiKey });
|
|
162
|
+
function deliverablesDir(ticketId) {
|
|
163
|
+
return join(WORK_DIR, "deliverables", ticketId);
|
|
164
|
+
}
|
|
165
|
+
function deliverablesInstruction(ticketId) {
|
|
166
|
+
return [
|
|
167
|
+
``,
|
|
168
|
+
`If the requester should receive any FILES (images, documents, code artifacts…),`,
|
|
169
|
+
`save them into this directory — everything in it is attached to your reply automatically:`,
|
|
170
|
+
`DELIVERABLES: ${deliverablesDir(ticketId)}`,
|
|
171
|
+
].join("\n");
|
|
172
|
+
}
|
|
161
173
|
function promptFor(ticket, messages, attachmentPaths) {
|
|
162
174
|
const thread = messages
|
|
163
175
|
.filter((m) => m.kind === "message")
|
|
@@ -179,6 +191,7 @@ function promptFor(ticket, messages, attachmentPaths) {
|
|
|
179
191
|
``,
|
|
180
192
|
thread,
|
|
181
193
|
...files,
|
|
194
|
+
deliverablesInstruction(ticket.id),
|
|
182
195
|
].join("\n");
|
|
183
196
|
}
|
|
184
197
|
/** Download the ticket's attachments so the headless session can open them. */
|
|
@@ -212,6 +225,10 @@ async function fetchAttachments(ticket, messages) {
|
|
|
212
225
|
*/
|
|
213
226
|
const PERMISSION_MODE = process.env.CLAUDE_PERMISSION_MODE ?? "bypassPermissions";
|
|
214
227
|
const CLAUDE_TIMEOUT_MS = Number(process.env.CLAUDE_TIMEOUT_MS ?? String(10 * 60 * 1000));
|
|
228
|
+
// Sessions run in a stable workspace so their JSONL lives at a predictable path
|
|
229
|
+
// (~/.claude/projects/<slug>/<sessionId>.jsonl) for the live-progress watcher.
|
|
230
|
+
const WORK_DIR = process.env.MAILBOX_WORKDIR ?? join(homedir(), ".agent-ticketing", "workspace");
|
|
231
|
+
mkdirSync(WORK_DIR, { recursive: true });
|
|
215
232
|
function runClaude(prompt, session, signal) {
|
|
216
233
|
return new Promise((resolve) => {
|
|
217
234
|
const env = { ...process.env };
|
|
@@ -222,7 +239,7 @@ function runClaude(prompt, session, signal) {
|
|
|
222
239
|
args.push("--resume", session.resume);
|
|
223
240
|
else if (session.newId)
|
|
224
241
|
args.push("--session-id", session.newId);
|
|
225
|
-
const child = spawn(claudeCmd, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
242
|
+
const child = spawn(claudeCmd, args, { env, cwd: WORK_DIR, stdio: ["pipe", "pipe", "pipe"] });
|
|
226
243
|
let stdout = "";
|
|
227
244
|
let stderr = "";
|
|
228
245
|
let timedOut = false;
|
|
@@ -309,6 +326,104 @@ function extractQuestions(denials) {
|
|
|
309
326
|
}
|
|
310
327
|
return out;
|
|
311
328
|
}
|
|
329
|
+
// ── live progress: tail the session JSONL and post ⚙ steps to the ticket ─────
|
|
330
|
+
// (adapted from claude-code-telegram/server/claude-stream.ts)
|
|
331
|
+
function sessionJsonlPath(sessionId) {
|
|
332
|
+
// Claude Code's project slug replaces EVERY non-alphanumeric char with "-"
|
|
333
|
+
// (verified: /Users/x/.agent-ticketing/workspace → -Users-x--agent-ticketing-workspace)
|
|
334
|
+
const slug = resolve(WORK_DIR).replace(/[^a-zA-Z0-9]/g, "-");
|
|
335
|
+
return join(homedir(), ".claude", "projects", slug, `${sessionId}.jsonl`);
|
|
336
|
+
}
|
|
337
|
+
function stepLabel(block) {
|
|
338
|
+
const name = block.name ?? "?";
|
|
339
|
+
const input = block.input ?? {};
|
|
340
|
+
const short = (v, n = 70) => {
|
|
341
|
+
const t = String(v ?? "");
|
|
342
|
+
return t.length > n ? t.slice(0, n) + "…" : t;
|
|
343
|
+
};
|
|
344
|
+
switch (name) {
|
|
345
|
+
case "Bash": return `Bash: ${short(input.command)}`;
|
|
346
|
+
case "Edit":
|
|
347
|
+
case "Write":
|
|
348
|
+
case "Read": return `${name}: ${short(basename(String(input.file_path ?? "")))}`;
|
|
349
|
+
case "Grep": return `Grep: ${short(input.pattern)}`;
|
|
350
|
+
case "Glob": return `Glob: ${short(input.pattern)}`;
|
|
351
|
+
case "WebFetch": return `WebFetch: ${short(input.url)}`;
|
|
352
|
+
case "WebSearch": return `WebSearch: ${short(input.query)}`;
|
|
353
|
+
case "Task": return `Task: ${short(input.description)}`;
|
|
354
|
+
case "TodoWrite": return "updating plan";
|
|
355
|
+
default: return name;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Tail the session JSONL (retrying until claude creates it) and report each
|
|
360
|
+
* tool-use step. Caller throttles/ships them to the ticket thread.
|
|
361
|
+
*/
|
|
362
|
+
function watchClaudeSession(sessionId, onStep) {
|
|
363
|
+
const path = sessionJsonlPath(sessionId);
|
|
364
|
+
let stopped = false;
|
|
365
|
+
let offset = 0;
|
|
366
|
+
let buf = "";
|
|
367
|
+
let fh = null;
|
|
368
|
+
let watcher = null;
|
|
369
|
+
let poll = null;
|
|
370
|
+
async function readNew() {
|
|
371
|
+
if (stopped || !fh)
|
|
372
|
+
return;
|
|
373
|
+
try {
|
|
374
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
375
|
+
for (;;) {
|
|
376
|
+
const { bytesRead } = await fh.read(chunk, 0, chunk.length, offset);
|
|
377
|
+
if (bytesRead === 0)
|
|
378
|
+
break;
|
|
379
|
+
offset += bytesRead;
|
|
380
|
+
buf += chunk.toString("utf8", 0, bytesRead);
|
|
381
|
+
let nl;
|
|
382
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
383
|
+
const line = buf.slice(0, nl).trim();
|
|
384
|
+
buf = buf.slice(nl + 1);
|
|
385
|
+
if (!line)
|
|
386
|
+
continue;
|
|
387
|
+
try {
|
|
388
|
+
const obj = JSON.parse(line);
|
|
389
|
+
if (obj.type !== "assistant" || !Array.isArray(obj.message?.content))
|
|
390
|
+
continue;
|
|
391
|
+
for (const block of obj.message.content) {
|
|
392
|
+
if (block?.type === "tool_use")
|
|
393
|
+
onStep(stepLabel(block));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch { /* partial/bad line */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch { /* transient read error */ }
|
|
401
|
+
}
|
|
402
|
+
async function tryOpen(attempt = 0) {
|
|
403
|
+
if (stopped)
|
|
404
|
+
return;
|
|
405
|
+
try {
|
|
406
|
+
fh = await fsOpen(path, "r");
|
|
407
|
+
// fresh sessions: read from the start; resumed: skip history
|
|
408
|
+
offset = 0;
|
|
409
|
+
watcher = watch(path, () => void readNew());
|
|
410
|
+
poll = setInterval(() => void readNew(), 2000);
|
|
411
|
+
void readNew();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
if (attempt < 30)
|
|
415
|
+
setTimeout(() => void tryOpen(attempt + 1), 1000); // file appears when the session starts
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
void tryOpen();
|
|
419
|
+
return () => {
|
|
420
|
+
stopped = true;
|
|
421
|
+
if (poll)
|
|
422
|
+
clearInterval(poll);
|
|
423
|
+
watcher?.close();
|
|
424
|
+
void fh?.close().catch(() => undefined);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
312
427
|
/** A prior run of this ticket announced its session in a progress note — find it. */
|
|
313
428
|
function findPreviousSession(messages) {
|
|
314
429
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -331,6 +446,17 @@ function repliesSince(messages) {
|
|
|
331
446
|
const fresh = messages.slice(lastAgent + 1).filter((m) => m.kind === "message" && m.author.type === "human");
|
|
332
447
|
return fresh.map((m) => m.body).join("\n\n");
|
|
333
448
|
}
|
|
449
|
+
// Live-step shipping cadence: min ms between ⚙ progress posts (each is a
|
|
450
|
+
// permanent thread row + an HTTP call, and Claude bursts tool calls) — but
|
|
451
|
+
// trailing-edge: the LATEST step always ships once the window opens.
|
|
452
|
+
const STEP_INTERVAL_MS = Math.max(2_000, Number(process.env.MAILBOX_STEP_INTERVAL_MS ?? "7000"));
|
|
453
|
+
const MAX_DELIVERABLES = 10; // server cap: attachments per message
|
|
454
|
+
const MAX_DELIVERABLE_BYTES = 25 * 1024 * 1024;
|
|
455
|
+
const MIME_BY_EXT = {
|
|
456
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
457
|
+
svg: "image/svg+xml", pdf: "application/pdf", txt: "text/plain", md: "text/markdown",
|
|
458
|
+
json: "application/json", csv: "text/csv", html: "text/html", zip: "application/zip",
|
|
459
|
+
};
|
|
334
460
|
const controller = new AbortController();
|
|
335
461
|
await mailbox.poll(async (ticket, ctx) => {
|
|
336
462
|
try {
|
|
@@ -348,6 +474,7 @@ await mailbox.poll(async (ticket, ctx) => {
|
|
|
348
474
|
onEvent,
|
|
349
475
|
});
|
|
350
476
|
async function workTicket(ticket, ctx) {
|
|
477
|
+
mkdirSync(deliverablesDir(ticket.id), { recursive: true });
|
|
351
478
|
const attachmentPaths = await fetchAttachments(ticket, ctx.messages);
|
|
352
479
|
const previous = findPreviousSession(ctx.messages);
|
|
353
480
|
const sessionId = previous ?? crypto.randomUUID();
|
|
@@ -361,19 +488,63 @@ async function workTicket(ticket, ctx) {
|
|
|
361
488
|
await ctx.progress(`headless Claude Code session ${sessionId}` +
|
|
362
489
|
(previous ? " (resumed)" : "") +
|
|
363
490
|
(attachmentPaths.length > 0 ? ` (${attachmentPaths.length} attachment(s) downloaded)` : ""));
|
|
491
|
+
// Live peek: steps from the session JSONL → throttled ⚙ entries on the ticket.
|
|
492
|
+
// Trailing-edge throttle: never more often than STEP_INTERVAL_MS, but the
|
|
493
|
+
// latest step is never lost — it ships when the window reopens.
|
|
494
|
+
let lastShipped = 0;
|
|
495
|
+
let lastLabel = "";
|
|
496
|
+
let pending = null;
|
|
497
|
+
let pendingTimer = null;
|
|
498
|
+
const ship = (label) => {
|
|
499
|
+
lastShipped = Date.now();
|
|
500
|
+
lastLabel = label;
|
|
501
|
+
ctx.progress(label).catch(() => undefined); // heartbeat + visible step; best-effort
|
|
502
|
+
};
|
|
503
|
+
const onStep = (label) => {
|
|
504
|
+
if (label === lastLabel || label === pending)
|
|
505
|
+
return;
|
|
506
|
+
const wait = lastShipped + STEP_INTERVAL_MS - Date.now();
|
|
507
|
+
if (wait <= 0)
|
|
508
|
+
return ship(label);
|
|
509
|
+
pending = label;
|
|
510
|
+
if (!pendingTimer) {
|
|
511
|
+
pendingTimer = setTimeout(() => {
|
|
512
|
+
pendingTimer = null;
|
|
513
|
+
const p = pending;
|
|
514
|
+
pending = null;
|
|
515
|
+
if (p && p !== lastLabel)
|
|
516
|
+
ship(p);
|
|
517
|
+
}, wait);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const stopWatch0 = watchClaudeSession(sessionId, onStep);
|
|
521
|
+
const stopWatch = () => {
|
|
522
|
+
stopWatch0();
|
|
523
|
+
if (pendingTimer)
|
|
524
|
+
clearTimeout(pendingTimer);
|
|
525
|
+
};
|
|
364
526
|
const freshPrompt = promptFor(ticket, ctx.messages, attachmentPaths);
|
|
365
527
|
const resumePrompt = `The requester replied on the ticket you were working:\n\n${repliesSince(ctx.messages)}\n\n` +
|
|
366
528
|
`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
|
|
529
|
+
`or use the AskUserQuestion tool if you are blocked on the requester again.` +
|
|
530
|
+
deliverablesInstruction(ticket.id);
|
|
368
531
|
let outcome = previous
|
|
369
532
|
? await runClaude(resumePrompt, { resume: previous }, ctx.signal)
|
|
370
533
|
: await runClaude(freshPrompt, { newId: sessionId }, ctx.signal);
|
|
371
534
|
if (outcome.kind === "stale_session" && previous) {
|
|
372
535
|
// Session file vanished (cache cleaned / machine changed) — start over.
|
|
536
|
+
stopWatch();
|
|
373
537
|
const freshId = crypto.randomUUID();
|
|
374
538
|
await ctx.progress(`previous session gone — fresh headless Claude Code session ${freshId}`);
|
|
375
|
-
|
|
539
|
+
const stopWatch2 = watchClaudeSession(freshId, onStep);
|
|
540
|
+
try {
|
|
541
|
+
outcome = await runClaude(freshPrompt, { newId: freshId }, ctx.signal);
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
stopWatch2();
|
|
545
|
+
}
|
|
376
546
|
}
|
|
547
|
+
stopWatch();
|
|
377
548
|
if (outcome.kind === "error" || outcome.kind === "stale_session") {
|
|
378
549
|
const message = outcome.kind === "error" ? outcome.message : "session lost and restart failed";
|
|
379
550
|
throw new Error(`${message} [claude session ${sessionId}]`);
|
|
@@ -386,5 +557,63 @@ async function workTicket(ticket, ctx) {
|
|
|
386
557
|
}
|
|
387
558
|
if (!outcome.result)
|
|
388
559
|
throw new Error(`claude produced no output [claude session ${sessionId}]`);
|
|
389
|
-
await
|
|
560
|
+
const { attachmentIds, note } = await uploadDeliverables(ticket.id, ctx);
|
|
561
|
+
const summary = outcome.result.slice(0, 60_000) + note;
|
|
562
|
+
if (attachmentIds.length > 0)
|
|
563
|
+
await ctx.complete(summary, { attachmentIds });
|
|
564
|
+
else
|
|
565
|
+
await ctx.complete(summary);
|
|
566
|
+
rmSync(deliverablesDir(ticket.id), { recursive: true, force: true });
|
|
567
|
+
}
|
|
568
|
+
function listFilesRecursive(dir) {
|
|
569
|
+
const out = [];
|
|
570
|
+
let entries;
|
|
571
|
+
try {
|
|
572
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
for (const e of entries) {
|
|
578
|
+
const full = join(dir, e.name);
|
|
579
|
+
if (e.isDirectory())
|
|
580
|
+
out.push(...listFilesRecursive(full));
|
|
581
|
+
else if (e.isFile())
|
|
582
|
+
out.push(full);
|
|
583
|
+
}
|
|
584
|
+
return out.sort();
|
|
585
|
+
}
|
|
586
|
+
/** Upload everything Claude saved to the deliverables dir; report what was skipped. */
|
|
587
|
+
async function uploadDeliverables(ticketId, ctx) {
|
|
588
|
+
void ctx; // (claim is held by caller; uploads use the client directly)
|
|
589
|
+
const files = listFilesRecursive(deliverablesDir(ticketId));
|
|
590
|
+
if (files.length === 0)
|
|
591
|
+
return { attachmentIds: [], note: "" };
|
|
592
|
+
const attachmentIds = [];
|
|
593
|
+
const skipped = [];
|
|
594
|
+
for (const path of files) {
|
|
595
|
+
const name = basename(path);
|
|
596
|
+
if (attachmentIds.length >= MAX_DELIVERABLES) {
|
|
597
|
+
skipped.push(`${name} (over ${MAX_DELIVERABLES}-file limit)`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
if (statSync(path).size > MAX_DELIVERABLE_BYTES) {
|
|
602
|
+
skipped.push(`${name} (larger than 25MB)`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
606
|
+
const id = await mailbox.uploadAttachment(ticketId, {
|
|
607
|
+
filename: name,
|
|
608
|
+
contentType: MIME_BY_EXT[ext] ?? "application/octet-stream",
|
|
609
|
+
data: new Uint8Array(readFileSync(path)),
|
|
610
|
+
});
|
|
611
|
+
attachmentIds.push(id);
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
skipped.push(`${name} (upload failed: ${e instanceof Error ? e.message : String(e)})`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const note = skipped.length > 0 ? `\n\n---\n⚠️ Deliverables not attached: ${skipped.join(", ")}` : "";
|
|
618
|
+
return { attachmentIds, note };
|
|
390
619
|
}
|
package/package.json
CHANGED