agent-ticketing 0.1.2 → 0.1.4
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 +21 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -0
- package/dist/runner.js +373 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,27 @@ 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
|
+
- **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.
|
|
96
|
+
- `CLAUDE_TIMEOUT_MS` (default 10 min, 0 disables) with SIGTERM→SIGKILL escalation;
|
|
97
|
+
JSON output is salvaged from timed-out processes when possible.
|
|
98
|
+
|
|
78
99
|
## Low-level client
|
|
79
100
|
|
|
80
101
|
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
|
@@ -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. */
|
|
@@ -203,58 +216,252 @@ async function fetchAttachments(ticket, messages) {
|
|
|
203
216
|
return paths;
|
|
204
217
|
}
|
|
205
218
|
/**
|
|
206
|
-
* Run
|
|
207
|
-
*
|
|
208
|
-
*
|
|
219
|
+
* Run headless Claude Code (ideas borrowed from claude-code-telegram's runner):
|
|
220
|
+
* - prompt via STDIN (ARG_MAX-proof, quoting-proof, not in `ps`)
|
|
221
|
+
* - `--permission-mode bypassPermissions` by default — an autonomous runner must
|
|
222
|
+
* be able to edit files / run commands (override: CLAUDE_PERMISSION_MODE)
|
|
223
|
+
* - `--output-format json` → { result, session_id, is_error, permission_denials }
|
|
224
|
+
* - SIGTERM → SIGKILL escalation; salvage JSON from a timed-out process
|
|
209
225
|
*/
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
226
|
+
const PERMISSION_MODE = process.env.CLAUDE_PERMISSION_MODE ?? "bypassPermissions";
|
|
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 });
|
|
232
|
+
function runClaude(prompt, session, signal) {
|
|
233
|
+
return new Promise((resolve) => {
|
|
214
234
|
const env = { ...process.env };
|
|
215
235
|
delete env.CLAUDECODE;
|
|
216
236
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
});
|
|
237
|
+
const args = ["-p", "--output-format", "json", "--permission-mode", PERMISSION_MODE];
|
|
238
|
+
if (session.resume)
|
|
239
|
+
args.push("--resume", session.resume);
|
|
240
|
+
else if (session.newId)
|
|
241
|
+
args.push("--session-id", session.newId);
|
|
242
|
+
const child = spawn(claudeCmd, args, { env, cwd: WORK_DIR, stdio: ["pipe", "pipe", "pipe"] });
|
|
223
243
|
let stdout = "";
|
|
224
244
|
let stderr = "";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
245
|
+
let timedOut = false;
|
|
246
|
+
let killTimer;
|
|
247
|
+
const kill = () => {
|
|
248
|
+
try {
|
|
249
|
+
child.kill();
|
|
250
|
+
}
|
|
251
|
+
catch { /* already gone */ }
|
|
252
|
+
killTimer = setTimeout(() => {
|
|
253
|
+
try {
|
|
254
|
+
child.kill("SIGKILL");
|
|
255
|
+
}
|
|
256
|
+
catch { /* already gone */ }
|
|
257
|
+
}, 3_000);
|
|
258
|
+
};
|
|
259
|
+
const timeout = CLAUDE_TIMEOUT_MS > 0
|
|
260
|
+
? setTimeout(() => { timedOut = true; kill(); }, CLAUDE_TIMEOUT_MS)
|
|
261
|
+
: undefined;
|
|
262
|
+
const onAbort = () => kill();
|
|
263
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
264
|
+
child.stdout.on("data", (d) => { if (stdout.length < 10 * 1024 * 1024)
|
|
265
|
+
stdout += d.toString(); });
|
|
266
|
+
child.stderr.on("data", (d) => { if (stderr.length < 1024 * 1024)
|
|
267
|
+
stderr += d.toString(); });
|
|
268
|
+
child.on("error", (e) => finish({ kind: "error", message: `could not run "${claudeCmd}": ${e.message}` }));
|
|
234
269
|
child.on("close", (code, sig) => {
|
|
235
|
-
|
|
236
|
-
|
|
270
|
+
const parsed = parseClaudeOutput(stdout, stderr);
|
|
271
|
+
if (parsed)
|
|
272
|
+
return finish(parsed);
|
|
273
|
+
if (timedOut)
|
|
274
|
+
return finish({ kind: "error", message: `claude timed out after ${Math.round(CLAUDE_TIMEOUT_MS / 60000)} min` });
|
|
237
275
|
const tail = stderr.trim().slice(-600) || stdout.trim().slice(-600) || "(no output)";
|
|
238
|
-
|
|
276
|
+
finish({ kind: "error", message: `${claudeCmd} exited with ${sig ? `signal ${sig}` : `code ${code}`}: ${tail}` });
|
|
239
277
|
});
|
|
240
|
-
|
|
278
|
+
function finish(o) {
|
|
279
|
+
if (timeout)
|
|
280
|
+
clearTimeout(timeout);
|
|
281
|
+
if (killTimer)
|
|
282
|
+
clearTimeout(killTimer);
|
|
283
|
+
signal?.removeEventListener("abort", onAbort);
|
|
284
|
+
resolve(o);
|
|
285
|
+
}
|
|
286
|
+
child.stdin.on("error", () => undefined);
|
|
241
287
|
child.stdin.write(prompt);
|
|
242
288
|
child.stdin.end();
|
|
243
289
|
});
|
|
244
290
|
}
|
|
291
|
+
function parseClaudeOutput(stdout, stderr) {
|
|
292
|
+
if (/No conversation found with session ID/i.test(`${stdout}\n${stderr}`)) {
|
|
293
|
+
return { kind: "stale_session" };
|
|
294
|
+
}
|
|
295
|
+
let parsed;
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(stdout);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return null; // not JSON — let close-handler report exit code/stderr
|
|
301
|
+
}
|
|
302
|
+
if (parsed.is_error)
|
|
303
|
+
return { kind: "error", message: parsed.result || "claude reported an error" };
|
|
304
|
+
return { kind: "ok", result: (parsed.result ?? "").trim(), questions: extractQuestions(parsed.permission_denials) };
|
|
305
|
+
}
|
|
306
|
+
/** Headless mode auto-denies AskUserQuestion but preserves it in permission_denials —
|
|
307
|
+
* recover the questions so they become ask_requester on the ticket. */
|
|
308
|
+
function extractQuestions(denials) {
|
|
309
|
+
const out = [];
|
|
310
|
+
for (const d of denials ?? []) {
|
|
311
|
+
if (d?.tool_name !== "AskUserQuestion")
|
|
312
|
+
continue;
|
|
313
|
+
const qs = d.tool_input?.questions;
|
|
314
|
+
if (!Array.isArray(qs))
|
|
315
|
+
continue;
|
|
316
|
+
for (const q of qs) {
|
|
317
|
+
if (typeof q?.question !== "string")
|
|
318
|
+
continue;
|
|
319
|
+
let text = q.question;
|
|
320
|
+
const opts = (q.options ?? []).filter((o) => typeof o?.label === "string");
|
|
321
|
+
if (opts.length > 0) {
|
|
322
|
+
text += "\n" + opts.map((o) => `- **${o.label}**${o.description ? ` — ${o.description}` : ""}`).join("\n");
|
|
323
|
+
}
|
|
324
|
+
out.push(text);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
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
|
+
}
|
|
427
|
+
/** A prior run of this ticket announced its session in a progress note — find it. */
|
|
428
|
+
function findPreviousSession(messages) {
|
|
429
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
430
|
+
const m = messages[i];
|
|
431
|
+
if (m.kind !== "progress")
|
|
432
|
+
continue;
|
|
433
|
+
const match = /headless Claude Code session ([0-9a-f-]{36})/.exec(m.body);
|
|
434
|
+
if (match)
|
|
435
|
+
return match[1];
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
/** For resumed sessions: only what happened since the agent last spoke. */
|
|
440
|
+
function repliesSince(messages) {
|
|
441
|
+
let lastAgent = -1;
|
|
442
|
+
messages.forEach((m, i) => {
|
|
443
|
+
if (m.author.type === "agent")
|
|
444
|
+
lastAgent = i;
|
|
445
|
+
});
|
|
446
|
+
const fresh = messages.slice(lastAgent + 1).filter((m) => m.kind === "message" && m.author.type === "human");
|
|
447
|
+
return fresh.map((m) => m.body).join("\n\n");
|
|
448
|
+
}
|
|
449
|
+
const MAX_DELIVERABLES = 10; // server cap: attachments per message
|
|
450
|
+
const MAX_DELIVERABLE_BYTES = 25 * 1024 * 1024;
|
|
451
|
+
const MIME_BY_EXT = {
|
|
452
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
453
|
+
svg: "image/svg+xml", pdf: "application/pdf", txt: "text/plain", md: "text/markdown",
|
|
454
|
+
json: "application/json", csv: "text/csv", html: "text/html", zip: "application/zip",
|
|
455
|
+
};
|
|
245
456
|
const controller = new AbortController();
|
|
246
457
|
await mailbox.poll(async (ticket, ctx) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
throw new Error("claude produced no output");
|
|
255
|
-
await ctx.complete(result.slice(0, 60_000));
|
|
256
|
-
if (once)
|
|
257
|
-
controller.abort();
|
|
458
|
+
try {
|
|
459
|
+
await workTicket(ticket, ctx);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
if (once)
|
|
463
|
+
controller.abort(); // every outcome ends a --once run: complete, ask, or error
|
|
464
|
+
}
|
|
258
465
|
}, {
|
|
259
466
|
mode: interval ? "interval" : "continuous",
|
|
260
467
|
every: interval ?? "5m",
|
|
@@ -262,3 +469,130 @@ await mailbox.poll(async (ticket, ctx) => {
|
|
|
262
469
|
signal: controller.signal,
|
|
263
470
|
onEvent,
|
|
264
471
|
});
|
|
472
|
+
async function workTicket(ticket, ctx) {
|
|
473
|
+
mkdirSync(deliverablesDir(ticket.id), { recursive: true });
|
|
474
|
+
const attachmentPaths = await fetchAttachments(ticket, ctx.messages);
|
|
475
|
+
const previous = findPreviousSession(ctx.messages);
|
|
476
|
+
const sessionId = previous ?? crypto.randomUUID();
|
|
477
|
+
onEvent?.({
|
|
478
|
+
ts: new Date().toISOString(),
|
|
479
|
+
event: "claude_session",
|
|
480
|
+
instanceId: mailbox.instanceId,
|
|
481
|
+
ticketId: ticket.id,
|
|
482
|
+
detail: { message: sessionId + (previous ? " (resumed)" : "") },
|
|
483
|
+
});
|
|
484
|
+
await ctx.progress(`headless Claude Code session ${sessionId}` +
|
|
485
|
+
(previous ? " (resumed)" : "") +
|
|
486
|
+
(attachmentPaths.length > 0 ? ` (${attachmentPaths.length} attachment(s) downloaded)` : ""));
|
|
487
|
+
// Live peek: steps from the session JSONL → throttled ⚙ entries on the ticket.
|
|
488
|
+
let lastShipped = 0;
|
|
489
|
+
let lastLabel = "";
|
|
490
|
+
const stopWatch = watchClaudeSession(sessionId, (label) => {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
if (label === lastLabel || now - lastShipped < 7_000)
|
|
493
|
+
return;
|
|
494
|
+
lastShipped = now;
|
|
495
|
+
lastLabel = label;
|
|
496
|
+
ctx.progress(label).catch(() => undefined); // heartbeat + visible step; best-effort
|
|
497
|
+
});
|
|
498
|
+
const freshPrompt = promptFor(ticket, ctx.messages, attachmentPaths);
|
|
499
|
+
const resumePrompt = `The requester replied on the ticket you were working:\n\n${repliesSince(ctx.messages)}\n\n` +
|
|
500
|
+
`Continue the task. Reply with your final result (it is posted back as the ticket resolution), ` +
|
|
501
|
+
`or use the AskUserQuestion tool if you are blocked on the requester again.` +
|
|
502
|
+
deliverablesInstruction(ticket.id);
|
|
503
|
+
let outcome = previous
|
|
504
|
+
? await runClaude(resumePrompt, { resume: previous }, ctx.signal)
|
|
505
|
+
: await runClaude(freshPrompt, { newId: sessionId }, ctx.signal);
|
|
506
|
+
if (outcome.kind === "stale_session" && previous) {
|
|
507
|
+
// Session file vanished (cache cleaned / machine changed) — start over.
|
|
508
|
+
stopWatch();
|
|
509
|
+
const freshId = crypto.randomUUID();
|
|
510
|
+
await ctx.progress(`previous session gone — fresh headless Claude Code session ${freshId}`);
|
|
511
|
+
const stopWatch2 = watchClaudeSession(freshId, (label) => {
|
|
512
|
+
const now = Date.now();
|
|
513
|
+
if (label === lastLabel || now - lastShipped < 7_000)
|
|
514
|
+
return;
|
|
515
|
+
lastShipped = now;
|
|
516
|
+
lastLabel = label;
|
|
517
|
+
ctx.progress(label).catch(() => undefined);
|
|
518
|
+
});
|
|
519
|
+
try {
|
|
520
|
+
outcome = await runClaude(freshPrompt, { newId: freshId }, ctx.signal);
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
stopWatch2();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
stopWatch();
|
|
527
|
+
if (outcome.kind === "error" || outcome.kind === "stale_session") {
|
|
528
|
+
const message = outcome.kind === "error" ? outcome.message : "session lost and restart failed";
|
|
529
|
+
throw new Error(`${message} [claude session ${sessionId}]`);
|
|
530
|
+
}
|
|
531
|
+
if (outcome.questions.length > 0) {
|
|
532
|
+
// Claude asked the human — ticket goes pending; we resume this session
|
|
533
|
+
// after the reply (assignee survives claim expiry by design).
|
|
534
|
+
await ctx.askAsync(outcome.questions.join("\n\n"));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!outcome.result)
|
|
538
|
+
throw new Error(`claude produced no output [claude session ${sessionId}]`);
|
|
539
|
+
const { attachmentIds, note } = await uploadDeliverables(ticket.id, ctx);
|
|
540
|
+
const summary = outcome.result.slice(0, 60_000) + note;
|
|
541
|
+
if (attachmentIds.length > 0)
|
|
542
|
+
await ctx.complete(summary, { attachmentIds });
|
|
543
|
+
else
|
|
544
|
+
await ctx.complete(summary);
|
|
545
|
+
rmSync(deliverablesDir(ticket.id), { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
function listFilesRecursive(dir) {
|
|
548
|
+
const out = [];
|
|
549
|
+
let entries;
|
|
550
|
+
try {
|
|
551
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
for (const e of entries) {
|
|
557
|
+
const full = join(dir, e.name);
|
|
558
|
+
if (e.isDirectory())
|
|
559
|
+
out.push(...listFilesRecursive(full));
|
|
560
|
+
else if (e.isFile())
|
|
561
|
+
out.push(full);
|
|
562
|
+
}
|
|
563
|
+
return out.sort();
|
|
564
|
+
}
|
|
565
|
+
/** Upload everything Claude saved to the deliverables dir; report what was skipped. */
|
|
566
|
+
async function uploadDeliverables(ticketId, ctx) {
|
|
567
|
+
void ctx; // (claim is held by caller; uploads use the client directly)
|
|
568
|
+
const files = listFilesRecursive(deliverablesDir(ticketId));
|
|
569
|
+
if (files.length === 0)
|
|
570
|
+
return { attachmentIds: [], note: "" };
|
|
571
|
+
const attachmentIds = [];
|
|
572
|
+
const skipped = [];
|
|
573
|
+
for (const path of files) {
|
|
574
|
+
const name = basename(path);
|
|
575
|
+
if (attachmentIds.length >= MAX_DELIVERABLES) {
|
|
576
|
+
skipped.push(`${name} (over ${MAX_DELIVERABLES}-file limit)`);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
if (statSync(path).size > MAX_DELIVERABLE_BYTES) {
|
|
581
|
+
skipped.push(`${name} (larger than 25MB)`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
585
|
+
const id = await mailbox.uploadAttachment(ticketId, {
|
|
586
|
+
filename: name,
|
|
587
|
+
contentType: MIME_BY_EXT[ext] ?? "application/octet-stream",
|
|
588
|
+
data: new Uint8Array(readFileSync(path)),
|
|
589
|
+
});
|
|
590
|
+
attachmentIds.push(id);
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
skipped.push(`${name} (upload failed: ${e instanceof Error ? e.message : String(e)})`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const note = skipped.length > 0 ? `\n\n---\n⚠️ Deliverables not attached: ${skipped.join(", ")}` : "";
|
|
597
|
+
return { attachmentIds, note };
|
|
598
|
+
}
|
package/package.json
CHANGED