agent-ticketing 0.1.0 → 0.1.2
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 +16 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.js +15 -0
- package/dist/runner.js +204 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# agent-ticketing
|
|
2
2
|
|
|
3
|
-
Agent SDK for **Agent
|
|
3
|
+
Agent SDK for **Agent Ticketing** — claim tickets, heartbeat, ask the requester, complete. Zero dependencies, Node 18+.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install agent-ticketing # (or use it from this repo via workspaces)
|
|
@@ -12,7 +12,7 @@ npm install agent-ticketing # (or use it from this repo via workspaces)
|
|
|
12
12
|
import { MailboxAgent } from "agent-ticketing";
|
|
13
13
|
|
|
14
14
|
const mailbox = new MailboxAgent({
|
|
15
|
-
baseUrl: "https://agent-
|
|
15
|
+
baseUrl: "https://agent-ticketing.<you>.workers.dev",
|
|
16
16
|
apiKey: process.env.MAILBOX_API_KEY, // mbx_live_… from Settings → Agents
|
|
17
17
|
});
|
|
18
18
|
|
|
@@ -59,9 +59,22 @@ 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
|
-
|
|
62
|
+
# persistent service (recommended): starts now, restarts on crash, survives reboots
|
|
63
|
+
MAILBOX_URL=https://… MAILBOX_API_KEY=mbx_live_… \
|
|
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
|
|
63
68
|
```
|
|
64
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
|
+
|
|
74
|
+
Runners are agent-specific — `mailbox-claude-runner` launches headless **Claude Code**
|
|
75
|
+
sessions. Runners for Codex and daemon-style agents (Hermes, OpenClaw) are planned;
|
|
76
|
+
for a custom harness, embed the SDK instead (below).
|
|
77
|
+
|
|
65
78
|
## Low-level client
|
|
66
79
|
|
|
67
80
|
Every REST endpoint is also exposed directly if you don't want the loop:
|
package/dist/index.d.ts
CHANGED
|
@@ -143,6 +143,12 @@ export declare class MailboxAgent {
|
|
|
143
143
|
}): Promise<Message>;
|
|
144
144
|
complete(ticketId: string, summary?: string): Promise<Ticket>;
|
|
145
145
|
release(ticketId: string, reason?: string): Promise<Ticket>;
|
|
146
|
+
/** Download an attachment's bytes (any attachment in your mailbox's tickets). */
|
|
147
|
+
downloadAttachment(attachmentId: string): Promise<{
|
|
148
|
+
filename: string;
|
|
149
|
+
contentType: string;
|
|
150
|
+
data: Uint8Array;
|
|
151
|
+
}>;
|
|
146
152
|
uploadAttachment(ticketId: string, file: {
|
|
147
153
|
filename: string;
|
|
148
154
|
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,
|
package/dist/runner.js
CHANGED
|
@@ -7,20 +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 {
|
|
11
|
-
import { appendFileSync } from "node:fs";
|
|
12
|
-
import {
|
|
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";
|
|
13
14
|
import { MailboxAgent } from "./index.js";
|
|
14
|
-
const exec = promisify(execFile);
|
|
15
15
|
const baseUrl = process.env.MAILBOX_URL;
|
|
16
16
|
const apiKey = process.env.MAILBOX_API_KEY;
|
|
17
|
-
if (!baseUrl || !apiKey) {
|
|
17
|
+
if ((!baseUrl || !apiKey) && !process.argv.includes("--uninstall-service")) {
|
|
18
18
|
console.error("Set MAILBOX_URL and MAILBOX_API_KEY (Settings → Agents in the mailbox UI)");
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
21
|
const args = process.argv.slice(2);
|
|
22
22
|
const interval = args.includes("--interval") ? args[args.indexOf("--interval") + 1] : null;
|
|
23
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
|
+
}
|
|
24
145
|
const claudeCmd = process.env.CLAUDE_CMD ?? "claude";
|
|
25
146
|
// MAILBOX_LOG_FILE=agent.ndjson → one JSON event per line (ts, event, ticketId…)
|
|
26
147
|
// for grep-able history that cross-references the server-side ticket threads.
|
|
@@ -35,12 +156,21 @@ const onEvent = logFile
|
|
|
35
156
|
}
|
|
36
157
|
}
|
|
37
158
|
: undefined;
|
|
38
|
-
|
|
39
|
-
|
|
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) {
|
|
40
162
|
const thread = messages
|
|
41
163
|
.filter((m) => m.kind === "message")
|
|
42
164
|
.map((m) => `${m.author.type === "human" ? "REQUESTER" : m.author.type.toUpperCase()}: ${m.body}`)
|
|
43
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
|
+
: [];
|
|
44
174
|
return [
|
|
45
175
|
`You are working a ticket from a ticketing system. Do the task and reply with your final result.`,
|
|
46
176
|
`Your reply is posted back to the requester as the ticket resolution — be complete but concise.`,
|
|
@@ -48,16 +178,77 @@ function promptFor(ticket, messages) {
|
|
|
48
178
|
`TICKET: ${ticket.subject}`,
|
|
49
179
|
``,
|
|
50
180
|
thread,
|
|
181
|
+
...files,
|
|
51
182
|
].join("\n");
|
|
52
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
|
+
}
|
|
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 tail — so the release reason / ndjson log show the real cause.
|
|
209
|
+
*/
|
|
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.
|
|
214
|
+
const env = { ...process.env };
|
|
215
|
+
delete env.CLAUDECODE;
|
|
216
|
+
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
|
+
});
|
|
223
|
+
let stdout = "";
|
|
224
|
+
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}`)));
|
|
234
|
+
child.on("close", (code, sig) => {
|
|
235
|
+
if (code === 0)
|
|
236
|
+
return resolve(stdout);
|
|
237
|
+
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}`));
|
|
239
|
+
});
|
|
240
|
+
child.stdin.on("error", () => undefined); // child died before reading stdin; close() reports it
|
|
241
|
+
child.stdin.write(prompt);
|
|
242
|
+
child.stdin.end();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
53
245
|
const controller = new AbortController();
|
|
54
246
|
await mailbox.poll(async (ticket, ctx) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
});
|
|
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);
|
|
61
252
|
const result = stdout.trim();
|
|
62
253
|
if (!result)
|
|
63
254
|
throw new Error("claude produced no output");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-ticketing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
34
|
-
"url": "https://github.com/terryds/agent-
|
|
34
|
+
"url": "https://github.com/terryds/agent-ticketing"
|
|
35
35
|
},
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"keywords": [
|