agent-ticketing 0.1.1 → 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 +22 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +21 -0
- package/dist/runner.js +322 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,14 +59,35 @@ NOT keep the Durable Object hot, so pick the mode by latency preference, not cos
|
|
|
59
59
|
the thinking, the ticket is completed with its output.
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
+
# persistent service (recommended): starts now, restarts on crash, survives reboots
|
|
62
63
|
MAILBOX_URL=https://… MAILBOX_API_KEY=mbx_live_… \
|
|
63
|
-
npx -y -p agent-ticketing mailbox-claude-runner
|
|
64
|
+
npx -y -p agent-ticketing mailbox-claude-runner --install-service
|
|
65
|
+
|
|
66
|
+
# one-off foreground run: drop --install-service (add --interval 5m for lazy polling)
|
|
67
|
+
# remove the service later: mailbox-claude-runner --uninstall-service
|
|
64
68
|
```
|
|
65
69
|
|
|
70
|
+
`--install-service` writes a launchd LaunchAgent (macOS) or a systemd user unit
|
|
71
|
+
(Linux) with your env baked in. Linux + reboot-without-login also needs
|
|
72
|
+
`loginctl enable-linger $USER` once.
|
|
73
|
+
|
|
66
74
|
Runners are agent-specific — `mailbox-claude-runner` launches headless **Claude Code**
|
|
67
75
|
sessions. Runners for Codex and daemon-style agents (Hermes, OpenClaw) are planned;
|
|
68
76
|
for a custom harness, embed the SDK instead (below).
|
|
69
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
|
+
|
|
70
91
|
## Low-level client
|
|
71
92
|
|
|
72
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. */
|
|
@@ -143,6 +148,12 @@ export declare class MailboxAgent {
|
|
|
143
148
|
}): Promise<Message>;
|
|
144
149
|
complete(ticketId: string, summary?: string): Promise<Ticket>;
|
|
145
150
|
release(ticketId: string, reason?: string): Promise<Ticket>;
|
|
151
|
+
/** Download an attachment's bytes (any attachment in your mailbox's tickets). */
|
|
152
|
+
downloadAttachment(attachmentId: string): Promise<{
|
|
153
|
+
filename: string;
|
|
154
|
+
contentType: string;
|
|
155
|
+
data: Uint8Array;
|
|
156
|
+
}>;
|
|
146
157
|
uploadAttachment(ticketId: string, file: {
|
|
147
158
|
filename: string;
|
|
148
159
|
contentType?: string;
|
package/dist/index.js
CHANGED
|
@@ -87,6 +87,21 @@ export class MailboxAgent {
|
|
|
87
87
|
});
|
|
88
88
|
return (await res.json()).ticket;
|
|
89
89
|
}
|
|
90
|
+
/** Download an attachment's bytes (any attachment in your mailbox's tickets). */
|
|
91
|
+
async downloadAttachment(attachmentId) {
|
|
92
|
+
const res = await fetch(`${this.baseUrl}/v1/attachments/${attachmentId}`, {
|
|
93
|
+
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
throw await toApiError(res);
|
|
97
|
+
const cd = res.headers.get("Content-Disposition") ?? "";
|
|
98
|
+
const filename = /filename="([^"]+)"/.exec(cd)?.[1] ?? attachmentId;
|
|
99
|
+
return {
|
|
100
|
+
filename,
|
|
101
|
+
contentType: res.headers.get("Content-Type") ?? "application/octet-stream",
|
|
102
|
+
data: new Uint8Array(await res.arrayBuffer()),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
90
105
|
async uploadAttachment(ticketId, file) {
|
|
91
106
|
const params = new URLSearchParams({
|
|
92
107
|
filename: file.filename,
|
|
@@ -178,6 +193,12 @@ export class MailboxAgent {
|
|
|
178
193
|
assertLive(claimLost.signal);
|
|
179
194
|
await self.postMessage(ticket.id, body, { attachmentIds: opts?.attachmentIds });
|
|
180
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
|
+
},
|
|
181
202
|
async ask(question, opts) {
|
|
182
203
|
assertLive(claimLost.signal);
|
|
183
204
|
const asked = await self.postMessage(ticket.id, question, { askRequester: true });
|
package/dist/runner.js
CHANGED
|
@@ -7,18 +7,141 @@
|
|
|
7
7
|
* MAILBOX_URL=https://…workers.dev MAILBOX_API_KEY=mbx_live_… \
|
|
8
8
|
* npx @mailbox/agent # or: mailbox-claude-runner [--interval 5m] [--once]
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
11
|
-
import { appendFileSync } from "node:fs";
|
|
10
|
+
import { execSync, spawn } from "node:child_process";
|
|
11
|
+
import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
12
14
|
import { MailboxAgent } from "./index.js";
|
|
13
15
|
const baseUrl = process.env.MAILBOX_URL;
|
|
14
16
|
const apiKey = process.env.MAILBOX_API_KEY;
|
|
15
|
-
if (!baseUrl || !apiKey) {
|
|
17
|
+
if ((!baseUrl || !apiKey) && !process.argv.includes("--uninstall-service")) {
|
|
16
18
|
console.error("Set MAILBOX_URL and MAILBOX_API_KEY (Settings → Agents in the mailbox UI)");
|
|
17
19
|
process.exit(1);
|
|
18
20
|
}
|
|
19
21
|
const args = process.argv.slice(2);
|
|
20
22
|
const interval = args.includes("--interval") ? args[args.indexOf("--interval") + 1] : null;
|
|
21
23
|
const once = args.includes("--once");
|
|
24
|
+
// ── --install-service / --uninstall-service: survive host reboots ────────────
|
|
25
|
+
// macOS → launchd LaunchAgent; Linux → systemd user unit. Env is baked in.
|
|
26
|
+
const SERVICE_LABEL = "com.agent-ticketing.runner";
|
|
27
|
+
const UNIT_NAME = "agent-ticketing-runner";
|
|
28
|
+
function serviceCommand() {
|
|
29
|
+
let npx = "npx";
|
|
30
|
+
try {
|
|
31
|
+
npx = execSync("command -v npx", { shell: "/bin/sh", encoding: "utf8" }).trim() || "npx";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// fall back to PATH lookup (we bake PATH into the service env anyway)
|
|
35
|
+
}
|
|
36
|
+
return `exec "${npx}" -y -p agent-ticketing mailbox-claude-runner${interval ? ` --interval ${interval}` : ""}`;
|
|
37
|
+
}
|
|
38
|
+
function xmlEscape(v) {
|
|
39
|
+
return v.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
40
|
+
}
|
|
41
|
+
function installService() {
|
|
42
|
+
const home = homedir();
|
|
43
|
+
const logDir = join(home, ".agent-ticketing");
|
|
44
|
+
mkdirSync(logDir, { recursive: true });
|
|
45
|
+
const envs = {
|
|
46
|
+
MAILBOX_URL: baseUrl ?? "",
|
|
47
|
+
MAILBOX_API_KEY: apiKey ?? "",
|
|
48
|
+
MAILBOX_LOG_FILE: process.env.MAILBOX_LOG_FILE ?? join(logDir, "events.ndjson"),
|
|
49
|
+
// Service shells get a bare PATH (no profile) — bake the installing shell's
|
|
50
|
+
// PATH so node/npx/claude resolve exactly as they did for the human.
|
|
51
|
+
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
52
|
+
};
|
|
53
|
+
if (process.env.CLAUDE_CMD)
|
|
54
|
+
envs.CLAUDE_CMD = process.env.CLAUDE_CMD;
|
|
55
|
+
if (process.platform === "darwin") {
|
|
56
|
+
const plist = join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
57
|
+
mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true });
|
|
58
|
+
const envXml = Object.entries(envs)
|
|
59
|
+
.map(([k, v]) => ` <key>${k}</key><string>${xmlEscape(v)}</string>`)
|
|
60
|
+
.join("\n");
|
|
61
|
+
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
62
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
63
|
+
<plist version="1.0">
|
|
64
|
+
<dict>
|
|
65
|
+
<key>Label</key><string>${SERVICE_LABEL}</string>
|
|
66
|
+
<key>ProgramArguments</key>
|
|
67
|
+
<array><string>/bin/sh</string><string>-lc</string><string>${xmlEscape(serviceCommand())}</string></array>
|
|
68
|
+
<key>EnvironmentVariables</key>
|
|
69
|
+
<dict>
|
|
70
|
+
${envXml}
|
|
71
|
+
</dict>
|
|
72
|
+
<key>RunAtLoad</key><true/>
|
|
73
|
+
<key>KeepAlive</key><true/>
|
|
74
|
+
<key>StandardOutPath</key><string>${join(logDir, "runner.log")}</string>
|
|
75
|
+
<key>StandardErrorPath</key><string>${join(logDir, "runner.log")}</string>
|
|
76
|
+
</dict>
|
|
77
|
+
</plist>
|
|
78
|
+
`);
|
|
79
|
+
execSync(`launchctl unload "${plist}" 2>/dev/null || true`, { shell: "/bin/sh" });
|
|
80
|
+
execSync(`launchctl load -w "${plist}"`, { shell: "/bin/sh" });
|
|
81
|
+
console.log(`installed launchd service ${SERVICE_LABEL}`);
|
|
82
|
+
console.log(` plist: ${plist}`);
|
|
83
|
+
console.log(` logs: ${join(logDir, "runner.log")}`);
|
|
84
|
+
console.log(`It starts now, restarts on crash, and survives reboots (starts at login).`);
|
|
85
|
+
}
|
|
86
|
+
else if (process.platform === "linux") {
|
|
87
|
+
const unitDir = join(home, ".config", "systemd", "user");
|
|
88
|
+
mkdirSync(unitDir, { recursive: true });
|
|
89
|
+
const unit = join(unitDir, `${UNIT_NAME}.service`);
|
|
90
|
+
const envLines = Object.entries(envs)
|
|
91
|
+
.map(([k, v]) => `Environment="${k}=${v}"`)
|
|
92
|
+
.join("\n");
|
|
93
|
+
writeFileSync(unit, `[Unit]
|
|
94
|
+
Description=Agent Ticketing runner (mailbox-claude-runner)
|
|
95
|
+
After=network-online.target
|
|
96
|
+
|
|
97
|
+
[Service]
|
|
98
|
+
${envLines}
|
|
99
|
+
ExecStart=/bin/sh -lc '${serviceCommand()}'
|
|
100
|
+
Restart=always
|
|
101
|
+
RestartSec=10
|
|
102
|
+
|
|
103
|
+
[Install]
|
|
104
|
+
WantedBy=default.target
|
|
105
|
+
`);
|
|
106
|
+
execSync(`systemctl --user daemon-reload && systemctl --user enable --now ${UNIT_NAME}`, { shell: "/bin/sh" });
|
|
107
|
+
console.log(`installed systemd user service ${UNIT_NAME}`);
|
|
108
|
+
console.log(` unit: ${unit}`);
|
|
109
|
+
console.log(` logs: journalctl --user -u ${UNIT_NAME} -f (plus ${envs.MAILBOX_LOG_FILE})`);
|
|
110
|
+
console.log(`To also start WITHOUT anyone logging in after reboot, run once:`);
|
|
111
|
+
console.log(` loginctl enable-linger $USER`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.error(`--install-service supports macOS (launchd) and Linux (systemd) — on ${process.platform}, run the runner under your own supervisor.`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function uninstallService() {
|
|
119
|
+
const home = homedir();
|
|
120
|
+
if (process.platform === "darwin") {
|
|
121
|
+
const plist = join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
122
|
+
execSync(`launchctl unload -w "${plist}" 2>/dev/null || true`, { shell: "/bin/sh" });
|
|
123
|
+
rmSync(plist, { force: true });
|
|
124
|
+
console.log(`removed launchd service ${SERVICE_LABEL}`);
|
|
125
|
+
}
|
|
126
|
+
else if (process.platform === "linux") {
|
|
127
|
+
execSync(`systemctl --user disable --now ${UNIT_NAME} 2>/dev/null || true`, { shell: "/bin/sh" });
|
|
128
|
+
rmSync(join(home, ".config", "systemd", "user", `${UNIT_NAME}.service`), { force: true });
|
|
129
|
+
execSync(`systemctl --user daemon-reload 2>/dev/null || true`, { shell: "/bin/sh" });
|
|
130
|
+
console.log(`removed systemd user service ${UNIT_NAME}`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.error(`--uninstall-service supports macOS and Linux only.`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (args.includes("--uninstall-service")) {
|
|
138
|
+
uninstallService();
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (args.includes("--install-service")) {
|
|
142
|
+
installService();
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
22
145
|
const claudeCmd = process.env.CLAUDE_CMD ?? "claude";
|
|
23
146
|
// MAILBOX_LOG_FILE=agent.ndjson → one JSON event per line (ts, event, ticketId…)
|
|
24
147
|
// for grep-able history that cross-references the server-side ticket threads.
|
|
@@ -33,12 +156,21 @@ const onEvent = logFile
|
|
|
33
156
|
}
|
|
34
157
|
}
|
|
35
158
|
: undefined;
|
|
36
|
-
|
|
37
|
-
|
|
159
|
+
// Non-null: every path reaching here passed the env guard (service handlers exit earlier).
|
|
160
|
+
const mailbox = new MailboxAgent({ baseUrl: baseUrl, apiKey: apiKey });
|
|
161
|
+
function promptFor(ticket, messages, attachmentPaths) {
|
|
38
162
|
const thread = messages
|
|
39
163
|
.filter((m) => m.kind === "message")
|
|
40
164
|
.map((m) => `${m.author.type === "human" ? "REQUESTER" : m.author.type.toUpperCase()}: ${m.body}`)
|
|
41
165
|
.join("\n\n");
|
|
166
|
+
const files = attachmentPaths.length > 0
|
|
167
|
+
? [
|
|
168
|
+
``,
|
|
169
|
+
`ATTACHMENTS — the requester attached these files; they are downloaded locally.`,
|
|
170
|
+
`Read them with your file tools (images can be viewed directly):`,
|
|
171
|
+
...attachmentPaths.map((p) => `- ${p}`),
|
|
172
|
+
]
|
|
173
|
+
: [];
|
|
42
174
|
return [
|
|
43
175
|
`You are working a ticket from a ticketing system. Do the task and reply with your final result.`,
|
|
44
176
|
`Your reply is posted back to the requester as the ticket resolution — be complete but concise.`,
|
|
@@ -46,58 +178,168 @@ function promptFor(ticket, messages) {
|
|
|
46
178
|
`TICKET: ${ticket.subject}`,
|
|
47
179
|
``,
|
|
48
180
|
thread,
|
|
181
|
+
...files,
|
|
49
182
|
].join("\n");
|
|
50
183
|
}
|
|
184
|
+
/** Download the ticket's attachments so the headless session can open them. */
|
|
185
|
+
async function fetchAttachments(ticket, messages) {
|
|
186
|
+
const attachments = messages.flatMap((m) => m.attachments);
|
|
187
|
+
if (attachments.length === 0)
|
|
188
|
+
return [];
|
|
189
|
+
const dir = join(tmpdir(), "agent-ticketing", ticket.id);
|
|
190
|
+
mkdirSync(dir, { recursive: true });
|
|
191
|
+
const paths = [];
|
|
192
|
+
for (const a of attachments) {
|
|
193
|
+
try {
|
|
194
|
+
const file = await mailbox.downloadAttachment(a.id);
|
|
195
|
+
const path = join(dir, basename(a.filename) || a.id);
|
|
196
|
+
writeFileSync(path, file.data);
|
|
197
|
+
paths.push(path);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
console.error(`[mailbox] could not download attachment ${a.id} (${a.filename}): ${String(e)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return paths;
|
|
204
|
+
}
|
|
51
205
|
/**
|
|
52
|
-
* Run
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
55
212
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) => {
|
|
60
217
|
const env = { ...process.env };
|
|
61
218
|
delete env.CLAUDECODE;
|
|
62
219
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
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"] });
|
|
69
226
|
let stdout = "";
|
|
70
227
|
let stderr = "";
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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}` }));
|
|
80
252
|
child.on("close", (code, sig) => {
|
|
81
|
-
|
|
82
|
-
|
|
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` });
|
|
83
258
|
const tail = stderr.trim().slice(-600) || stdout.trim().slice(-600) || "(no output)";
|
|
84
|
-
|
|
259
|
+
finish({ kind: "error", message: `${claudeCmd} exited with ${sig ? `signal ${sig}` : `code ${code}`}: ${tail}` });
|
|
85
260
|
});
|
|
86
|
-
|
|
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);
|
|
87
270
|
child.stdin.write(prompt);
|
|
88
271
|
child.stdin.end();
|
|
89
272
|
});
|
|
90
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
|
+
}
|
|
91
334
|
const controller = new AbortController();
|
|
92
335
|
await mailbox.poll(async (ticket, ctx) => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
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
|
+
}
|
|
101
343
|
}, {
|
|
102
344
|
mode: interval ? "interval" : "continuous",
|
|
103
345
|
every: interval ?? "5m",
|
|
@@ -105,3 +347,44 @@ await mailbox.poll(async (ticket, ctx) => {
|
|
|
105
347
|
signal: controller.signal,
|
|
106
348
|
onEvent,
|
|
107
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