agent-companion 0.1.0

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.
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ const projectRoot = path.resolve(__dirname, "..");
11
+
12
+ const DEFAULT_RESUME_ID = "9f359518-cae4-4da8-9e55-0ac7e261e85a";
13
+ const JOBS_DIR = path.resolve(projectRoot, ".agent", "ui-jobs");
14
+
15
+ const argv = process.argv.slice(2);
16
+ const args = parseArgs(argv);
17
+ const prompt = resolvePrompt(args);
18
+ const resumeId = trim(args.resume) || DEFAULT_RESUME_ID;
19
+ const cwd = path.resolve(trim(args.cwd) || projectRoot);
20
+ const wait = toBool(args.wait);
21
+ const dryRun = toBool(args["dry-run"]);
22
+
23
+ if (!prompt) {
24
+ printUsageAndExit(1, "prompt is required");
25
+ }
26
+
27
+ if (!isUuid(resumeId)) {
28
+ printUsageAndExit(1, `invalid resume id: ${resumeId}`);
29
+ }
30
+
31
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
32
+ printUsageAndExit(1, `cwd must be an existing directory: ${cwd}`);
33
+ }
34
+
35
+ fs.mkdirSync(JOBS_DIR, { recursive: true });
36
+
37
+ const jobId = buildJobId();
38
+ const logPath = path.resolve(JOBS_DIR, `${jobId}.log`);
39
+ const metaPath = path.resolve(JOBS_DIR, `${jobId}.json`);
40
+
41
+ const commandArgs = [
42
+ "--resume",
43
+ resumeId,
44
+ "--dangerously-skip-permissions",
45
+ "--verbose",
46
+ "--output-format",
47
+ "stream-json",
48
+ "-p",
49
+ prompt
50
+ ];
51
+
52
+ const commandPreview = `claude ${commandArgs.map(shellEscape).join(" ")}`;
53
+ const metadata = {
54
+ id: jobId,
55
+ createdAt: Date.now(),
56
+ cwd,
57
+ resumeId,
58
+ prompt,
59
+ command: ["claude", ...commandArgs],
60
+ status: "STARTING",
61
+ pid: null,
62
+ logPath
63
+ };
64
+
65
+ fs.writeFileSync(metaPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
66
+
67
+ if (dryRun) {
68
+ metadata.status = "DRY_RUN";
69
+ fs.writeFileSync(metaPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
70
+ console.log(`[ui-delegate] dry-run`);
71
+ console.log(`[ui-delegate] command: ${commandPreview}`);
72
+ console.log(`[ui-delegate] meta: ${metaPath}`);
73
+ process.exit(0);
74
+ }
75
+
76
+ if (wait) {
77
+ const outFd = fs.openSync(logPath, "a");
78
+ fs.writeSync(outFd, `# ${new Date().toISOString()} ${commandPreview}\n`);
79
+
80
+ const child = spawn("claude", commandArgs, {
81
+ cwd,
82
+ env: process.env,
83
+ stdio: ["ignore", outFd, outFd]
84
+ });
85
+
86
+ metadata.pid = child.pid || null;
87
+ metadata.status = "RUNNING";
88
+ fs.writeFileSync(metaPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
89
+
90
+ child.on("close", (code, signal) => {
91
+ metadata.status = Number.isInteger(code) && code === 0 ? "COMPLETED" : "FAILED";
92
+ metadata.exitCode = Number.isInteger(code) ? code : null;
93
+ metadata.signal = signal || null;
94
+ metadata.endedAt = Date.now();
95
+ fs.writeFileSync(metaPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
96
+ fs.closeSync(outFd);
97
+ process.exit(code ?? 1);
98
+ });
99
+
100
+ child.on("error", (error) => {
101
+ metadata.status = "FAILED";
102
+ metadata.error = String(error?.message || error);
103
+ metadata.endedAt = Date.now();
104
+ fs.writeFileSync(metaPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
105
+ fs.closeSync(outFd);
106
+ process.exit(1);
107
+ });
108
+
109
+ console.log(`[ui-delegate] running (wait mode): ${jobId}`);
110
+ console.log(`[ui-delegate] log: ${logPath}`);
111
+ console.log(`[ui-delegate] meta: ${metaPath}`);
112
+ process.exit(0);
113
+ }
114
+
115
+ const outFd = fs.openSync(logPath, "a");
116
+ fs.writeSync(outFd, `# ${new Date().toISOString()} ${commandPreview}\n`);
117
+
118
+ const child = spawn("claude", commandArgs, {
119
+ cwd,
120
+ env: process.env,
121
+ detached: true,
122
+ stdio: ["ignore", outFd, outFd]
123
+ });
124
+
125
+ metadata.pid = child.pid || null;
126
+ metadata.status = "RUNNING";
127
+ fs.writeFileSync(metaPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
128
+
129
+ child.unref();
130
+ fs.closeSync(outFd);
131
+
132
+ console.log(`[ui-delegate] started: ${jobId}`);
133
+ console.log(`[ui-delegate] pid: ${metadata.pid ?? "unknown"}`);
134
+ console.log(`[ui-delegate] log: ${logPath}`);
135
+ console.log(`[ui-delegate] meta: ${metaPath}`);
136
+ console.log(`[ui-delegate] tail: tail -f ${shellEscape(logPath)}`);
137
+
138
+ function parseArgs(tokens) {
139
+ const out = { _: [] };
140
+ const valueFlags = new Set(["resume", "cwd", "prompt"]);
141
+ for (let i = 0; i < tokens.length; i += 1) {
142
+ const token = tokens[i];
143
+ if (!token.startsWith("--")) {
144
+ out._.push(token);
145
+ continue;
146
+ }
147
+ const key = token.slice(2);
148
+ if (!valueFlags.has(key)) {
149
+ out[key] = "true";
150
+ continue;
151
+ }
152
+ const next = tokens[i + 1];
153
+ if (!next || next.startsWith("--")) {
154
+ printUsageAndExit(1, `missing value for --${key}`);
155
+ }
156
+ out[key] = next;
157
+ i += 1;
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function resolvePrompt(argsInput) {
163
+ const explicit = trim(argsInput.prompt);
164
+ if (explicit) return explicit;
165
+ const fromPositional = Array.isArray(argsInput._) ? argsInput._ : [];
166
+ return trim(fromPositional.join(" "));
167
+ }
168
+
169
+ function toBool(value) {
170
+ const normalized = trim(value).toLowerCase();
171
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
172
+ }
173
+
174
+ function trim(value) {
175
+ return String(value || "").trim();
176
+ }
177
+
178
+ function isUuid(value) {
179
+ const candidate = trim(value).toLowerCase();
180
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(candidate);
181
+ }
182
+
183
+ function buildJobId() {
184
+ const now = new Date();
185
+ const parts = [
186
+ now.getUTCFullYear(),
187
+ String(now.getUTCMonth() + 1).padStart(2, "0"),
188
+ String(now.getUTCDate()).padStart(2, "0"),
189
+ String(now.getUTCHours()).padStart(2, "0"),
190
+ String(now.getUTCMinutes()).padStart(2, "0"),
191
+ String(now.getUTCSeconds()).padStart(2, "0")
192
+ ];
193
+ const rand = Math.floor(Math.random() * 10000)
194
+ .toString()
195
+ .padStart(4, "0");
196
+ return `ui_${parts.join("")}_${rand}`;
197
+ }
198
+
199
+ function shellEscape(value) {
200
+ const text = String(value || "");
201
+ if (/^[a-zA-Z0-9_./:@-]+$/.test(text)) return text;
202
+ return `'${text.replace(/'/g, `'\\''`)}'`;
203
+ }
204
+
205
+ function printUsageAndExit(code, error = "") {
206
+ if (error) {
207
+ console.error(`[ui-delegate] ${error}`);
208
+ }
209
+ console.log(`Usage:
210
+ node scripts/ui-claude-delegate.mjs --prompt "<text>" [options]
211
+ node scripts/ui-claude-delegate.mjs "<text>" [options]
212
+
213
+ Options:
214
+ --resume <uuid> Claude resume thread id (default project UI thread)
215
+ --cwd <path> Working directory for Claude (default: repo root)
216
+ --wait Run in foreground and wait for completion
217
+ --dry-run Print command and metadata without running Claude
218
+ `);
219
+ process.exit(code);
220
+ }
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import cors from "cors";
3
+ import dgram from "node:dgram";
4
+ import express from "express";
5
+
6
+ const app = express();
7
+
8
+ const WAKE_PROXY_PORT = clamp(toInt(process.env.PORT || process.env.WAKE_PROXY_PORT, 9898), 1, 65535);
9
+ const WAKE_PROXY_TOKEN = safeText(process.env.WAKE_PROXY_TOKEN, 500);
10
+ const WAKE_BROADCAST_ADDRESS = safeText(process.env.WAKE_BROADCAST_ADDRESS, 120) || "255.255.255.255";
11
+ const WAKE_UDP_PORT = clamp(toInt(process.env.WAKE_UDP_PORT, 9), 1, 65535);
12
+ const WAKE_PACKET_REPEAT = clamp(toInt(process.env.WAKE_PACKET_REPEAT, 3), 1, 12);
13
+ const WAKE_PACKET_DELAY_MS = clamp(toInt(process.env.WAKE_PACKET_DELAY_MS, 120), 0, 5000);
14
+
15
+ app.use(cors());
16
+ app.use(express.json({ limit: "64kb" }));
17
+
18
+ app.get("/health", (_req, res) => {
19
+ return res.json({
20
+ ok: true,
21
+ service: "wake-proxy",
22
+ udpPort: WAKE_UDP_PORT,
23
+ broadcastAddress: WAKE_BROADCAST_ADDRESS,
24
+ tokenRequired: Boolean(WAKE_PROXY_TOKEN)
25
+ });
26
+ });
27
+
28
+ app.post("/api/wake", async (req, res) => {
29
+ if (WAKE_PROXY_TOKEN) {
30
+ const authHeader = String(req.header("authorization") || "").trim();
31
+ const bearer = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
32
+ const fromHeader = safeText(req.header("x-wake-proxy-token"), 500);
33
+ const token = bearer || fromHeader;
34
+ if (!token || token !== WAKE_PROXY_TOKEN) {
35
+ return res.status(401).json({
36
+ ok: false,
37
+ error: "wake proxy token missing or invalid"
38
+ });
39
+ }
40
+ }
41
+
42
+ const macAddress = normalizeMacAddress(
43
+ req.body?.macAddress || req.body?.wakeMac || req.body?.mac || req.body?.address
44
+ );
45
+ if (!macAddress) {
46
+ return res.status(400).json({
47
+ ok: false,
48
+ error: "macAddress is required"
49
+ });
50
+ }
51
+
52
+ try {
53
+ await sendWakePacket(macAddress, {
54
+ udpPort: WAKE_UDP_PORT,
55
+ broadcastAddress: WAKE_BROADCAST_ADDRESS,
56
+ repeat: WAKE_PACKET_REPEAT,
57
+ delayMs: WAKE_PACKET_DELAY_MS
58
+ });
59
+
60
+ return res.json({
61
+ ok: true,
62
+ macAddress,
63
+ sentAt: Date.now(),
64
+ repeats: WAKE_PACKET_REPEAT
65
+ });
66
+ } catch (error) {
67
+ return res.status(502).json({
68
+ ok: false,
69
+ error: String(error?.message || error)
70
+ });
71
+ }
72
+ });
73
+
74
+ app.listen(WAKE_PROXY_PORT, () => {
75
+ console.log(`[wake-proxy] listening on http://localhost:${WAKE_PROXY_PORT}`);
76
+ console.log(`[wake-proxy] udp target: ${WAKE_BROADCAST_ADDRESS}:${WAKE_UDP_PORT}`);
77
+ if (WAKE_PROXY_TOKEN) {
78
+ console.log("[wake-proxy] auth token: enabled");
79
+ }
80
+ });
81
+
82
+ async function sendWakePacket(macAddress, options) {
83
+ const macBytes = macAddress.split(":").map((part) => Number.parseInt(part, 16));
84
+ if (macBytes.length !== 6 || macBytes.some((item) => !Number.isFinite(item))) {
85
+ throw new Error("invalid MAC address");
86
+ }
87
+
88
+ const packet = Buffer.alloc(6 + 16 * 6, 0xff);
89
+ for (let i = 0; i < 16; i += 1) {
90
+ for (let j = 0; j < 6; j += 1) {
91
+ packet[6 + i * 6 + j] = macBytes[j];
92
+ }
93
+ }
94
+
95
+ const socket = dgram.createSocket("udp4");
96
+
97
+ try {
98
+ await new Promise((resolve, reject) => {
99
+ socket.once("error", reject);
100
+ socket.bind(0, () => {
101
+ try {
102
+ socket.setBroadcast(true);
103
+ resolve();
104
+ } catch (error) {
105
+ reject(error);
106
+ }
107
+ });
108
+ });
109
+
110
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
111
+ await new Promise((resolve, reject) => {
112
+ socket.send(packet, 0, packet.length, options.udpPort, options.broadcastAddress, (error) => {
113
+ if (error) {
114
+ reject(error);
115
+ return;
116
+ }
117
+ resolve();
118
+ });
119
+ });
120
+
121
+ if (attempt < options.repeat - 1 && options.delayMs > 0) {
122
+ await sleep(options.delayMs);
123
+ }
124
+ }
125
+ } finally {
126
+ socket.close();
127
+ }
128
+ }
129
+
130
+ function normalizeMacAddress(value) {
131
+ const raw = String(value || "")
132
+ .trim()
133
+ .toUpperCase()
134
+ .replace(/[^0-9A-F]/g, "");
135
+ if (raw.length !== 12) return "";
136
+ if (raw === "000000000000" || raw === "FFFFFFFFFFFF") return "";
137
+ const chunks = raw.match(/.{1,2}/g);
138
+ return chunks ? chunks.join(":") : "";
139
+ }
140
+
141
+ function toInt(value, fallback = 0) {
142
+ const parsed = Number.parseInt(String(value), 10);
143
+ return Number.isFinite(parsed) ? parsed : fallback;
144
+ }
145
+
146
+ function clamp(value, min, max) {
147
+ return Math.max(min, Math.min(max, value));
148
+ }
149
+
150
+ function safeText(value, maxLength) {
151
+ if (typeof value !== "string") return "";
152
+ const trimmed = value.trim();
153
+ if (!trimmed) return "";
154
+ if (trimmed.length <= maxLength) return trimmed;
155
+ return trimmed.slice(0, maxLength);
156
+ }
157
+
158
+ function sleep(ms) {
159
+ return new Promise((resolve) => {
160
+ setTimeout(resolve, ms);
161
+ });
162
+ }