syndai 0.1.0 → 0.3.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.
- package/CHANGELOG.md +31 -0
- package/README.md +76 -52
- package/bin/syndai.js +13 -29
- package/package.json +6 -11
- package/src/auth.js +179 -0
- package/src/changes.js +307 -0
- package/src/errors.js +128 -0
- package/src/http.js +153 -0
- package/src/main.js +212 -0
- package/src/mcp.js +93 -0
- package/src/projects.js +168 -0
- package/src/routes.js +56 -0
- package/src/settings.js +160 -0
- package/src/store.js +134 -0
- package/src/ui.js +133 -0
- package/src/watch.js +292 -0
- package/LICENSE +0 -9
- package/src/api.js +0 -70
- package/src/claude.js +0 -145
- package/src/codex.js +0 -71
- package/src/connect.js +0 -122
- package/src/proc.js +0 -36
- package/src/prompt.js +0 -43
- package/src/tokens.js +0 -45
package/src/store.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Local files, all outside any repository (`.syndai/**` in a repo is a protected path):
|
|
2
|
+
// $XDG_CONFIG_HOME/syndai/credentials.json keys, by workspace (customer), mode 0600
|
|
3
|
+
// $XDG_CONFIG_HOME/syndai/links.json directory -> project
|
|
4
|
+
// $XDG_STATE_HOME/syndai/pending/*.json a change's request id, written BEFORE its POST
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { CliError, EXIT } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export function storePaths(env) {
|
|
14
|
+
const home = env.HOME || homedir();
|
|
15
|
+
const config = path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "syndai");
|
|
16
|
+
const state = path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "syndai");
|
|
17
|
+
return {
|
|
18
|
+
home,
|
|
19
|
+
credentials: path.join(config, "credentials.json"),
|
|
20
|
+
links: path.join(config, "links.json"),
|
|
21
|
+
pending: path.join(state, "pending"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readJson(file, fallback) {
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = fs.readFileSync(file, "utf8");
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code === "ENOENT") return fallback;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new CliError(EXIT.FAILED, `${file} is not valid JSON; fix or delete it.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Atomic write (temp file + rename) with an exact mode, whatever the umask. */
|
|
41
|
+
export function writeFileAtomic(file, text, mode = 0o600) {
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
43
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
fs.writeFileSync(tmp, text, { mode });
|
|
45
|
+
fs.chmodSync(tmp, mode);
|
|
46
|
+
fs.renameSync(tmp, file);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeJson(file, data, mode = 0o600) {
|
|
50
|
+
writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`, mode);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- credentials -------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export function loadCredentials(paths) {
|
|
56
|
+
const data = readJson(paths.credentials, null);
|
|
57
|
+
return data && typeof data.customers === "object" ? data : { default: null, customers: {} };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function saveCredential(paths, { customer_id, api_key, key_id, expires_at }) {
|
|
61
|
+
const data = loadCredentials(paths);
|
|
62
|
+
data.customers[customer_id] = { api_key, key_id, expires_at };
|
|
63
|
+
data.default = customer_id;
|
|
64
|
+
writeJson(paths.credentials, data);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function removeCredential(paths, customerId) {
|
|
68
|
+
const data = loadCredentials(paths);
|
|
69
|
+
delete data.customers[customerId];
|
|
70
|
+
if (data.default === customerId) data.default = Object.keys(data.customers)[0] ?? null;
|
|
71
|
+
writeJson(paths.credentials, data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- links -------------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** The link for `cwd` or its nearest linked parent, or null. */
|
|
77
|
+
export function findLink(paths, cwd) {
|
|
78
|
+
const links = readJson(paths.links, {});
|
|
79
|
+
let dir = path.resolve(cwd);
|
|
80
|
+
for (;;) {
|
|
81
|
+
if (links[dir]) return { dir, ...links[dir] };
|
|
82
|
+
const parent = path.dirname(dir);
|
|
83
|
+
if (parent === dir) return null;
|
|
84
|
+
dir = parent;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function saveLink(paths, dir, link) {
|
|
89
|
+
const links = readJson(paths.links, {});
|
|
90
|
+
links[path.resolve(dir)] = link;
|
|
91
|
+
writeJson(paths.links, links);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- pending change requests -------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function pendingFile(paths, requestId) {
|
|
97
|
+
return path.join(paths.pending, `${requestId.replace(/[^\w-]/g, "_")}.json`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A not-yet-confirmed request for the same project and text (a crash or a retry), or null. */
|
|
101
|
+
export function findPending(paths, { app, text }, now = Date.now()) {
|
|
102
|
+
let names;
|
|
103
|
+
try {
|
|
104
|
+
names = fs.readdirSync(paths.pending);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.code === "ENOENT") return null;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
let match = null;
|
|
110
|
+
for (const name of names.filter((n) => n.endsWith(".json"))) {
|
|
111
|
+
const file = path.join(paths.pending, name);
|
|
112
|
+
let record;
|
|
113
|
+
try {
|
|
114
|
+
record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
115
|
+
} catch {
|
|
116
|
+
fs.rmSync(file, { force: true });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (now - Date.parse(record.created_at) > PENDING_TTL_MS) {
|
|
120
|
+
fs.rmSync(file, { force: true });
|
|
121
|
+
} else if (record.app === app && record.text === text) {
|
|
122
|
+
match = record;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return match;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function writePending(paths, record) {
|
|
129
|
+
writeJson(pendingFile(paths, record.request_id), record);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function clearPending(paths, requestId) {
|
|
133
|
+
fs.rmSync(pendingFile(paths, requestId), { force: true });
|
|
134
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Terminal I/O: progress on stderr, the result on stdout, prompts, the browser and the pager.
|
|
2
|
+
//
|
|
3
|
+
// The result is JSON when `--json` is passed or stdout is not a terminal (an agent or a
|
|
4
|
+
// pipe), human text otherwise. Colour only on a terminal, never with NO_COLOR.
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { getRandomValues } from "node:crypto";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { CliError, EXIT } from "./errors.js";
|
|
10
|
+
import { interrupted } from "./http.js";
|
|
11
|
+
|
|
12
|
+
export function createUi(io, { json = false, noInput = false } = {}) {
|
|
13
|
+
const colour = Boolean(io.stderr.isTTY) && !io.env.NO_COLOR && io.env.TERM !== "dumb";
|
|
14
|
+
const paint = (code) => (text) => (colour ? `\x1b[${code}m${text}\x1b[0m` : String(text));
|
|
15
|
+
return {
|
|
16
|
+
json: json || !io.stdout.isTTY,
|
|
17
|
+
interactive: !noInput && Boolean(io.stdin.isTTY) && Boolean(io.stderr.isTTY),
|
|
18
|
+
dim: paint("2"),
|
|
19
|
+
bold: paint("1"),
|
|
20
|
+
green: paint("32"),
|
|
21
|
+
yellow: paint("33"),
|
|
22
|
+
red: paint("31"),
|
|
23
|
+
progress(line) {
|
|
24
|
+
io.stderr.write(`${line}\n`);
|
|
25
|
+
},
|
|
26
|
+
/** Print the result: `data` as one JSON line, or `human` text. */
|
|
27
|
+
result(data, human) {
|
|
28
|
+
io.stdout.write(this.json ? `${JSON.stringify(data)}\n` : `${human}\n`);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One line reader per stdin for the whole run: its async iterator buffers lines, so answers
|
|
34
|
+
// typed (or piped) ahead of a prompt are never dropped between questions.
|
|
35
|
+
const readers = new WeakMap();
|
|
36
|
+
|
|
37
|
+
export async function ask(io, question) {
|
|
38
|
+
io.stderr.write(question);
|
|
39
|
+
let reader = readers.get(io.stdin);
|
|
40
|
+
if (!reader) {
|
|
41
|
+
const rl = createInterface({ input: io.stdin, terminal: false, crlfDelay: Number.POSITIVE_INFINITY });
|
|
42
|
+
reader = { rl, lines: rl[Symbol.asyncIterator]() };
|
|
43
|
+
readers.set(io.stdin, reader);
|
|
44
|
+
}
|
|
45
|
+
let onAbort;
|
|
46
|
+
const aborted = new Promise((_, reject) => {
|
|
47
|
+
onAbort = () => reject(interrupted());
|
|
48
|
+
if (io.signal.aborted) onAbort();
|
|
49
|
+
io.signal.addEventListener("abort", onAbort, { once: true });
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
const next = await Promise.race([reader.lines.next(), aborted]);
|
|
53
|
+
if (next.done) throw new CliError(EXIT.NEEDS_YOU, "No answer: input closed.");
|
|
54
|
+
return next.value.trim();
|
|
55
|
+
} finally {
|
|
56
|
+
io.signal.removeEventListener("abort", onAbort);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Release stdin so the process can exit (called once a command is done). */
|
|
61
|
+
export function closeInput(io) {
|
|
62
|
+
readers.get(io.stdin)?.rl.close();
|
|
63
|
+
readers.delete(io.stdin);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Open an http(s) URL in the default browser; the caller always prints it too. */
|
|
67
|
+
export function openBrowser(url) {
|
|
68
|
+
if (!/^https?:\/\//.test(url)) return;
|
|
69
|
+
const [cmd, args] =
|
|
70
|
+
process.platform === "darwin"
|
|
71
|
+
? ["open", [url]]
|
|
72
|
+
: process.platform === "win32"
|
|
73
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
74
|
+
: ["xdg-open", [url]];
|
|
75
|
+
try {
|
|
76
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
77
|
+
child.on("error", () => {});
|
|
78
|
+
child.unref();
|
|
79
|
+
} catch {
|
|
80
|
+
// No browser here (SSH, container): the printed URL is the fallback.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Show long text through $PAGER on a terminal; print it plainly otherwise. */
|
|
85
|
+
export function page(proc, text) {
|
|
86
|
+
if (!proc.stdout.isTTY) {
|
|
87
|
+
proc.stdout.write(text);
|
|
88
|
+
return Promise.resolve();
|
|
89
|
+
}
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const child = spawn(proc.env.PAGER || "less", {
|
|
92
|
+
shell: true,
|
|
93
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
94
|
+
env: { LESS: "FRX", ...proc.env },
|
|
95
|
+
});
|
|
96
|
+
child.on("error", () => {
|
|
97
|
+
proc.stdout.write(text);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
child.on("close", resolve);
|
|
101
|
+
child.stdin.on("error", () => {}); // EPIPE when the reader quits early
|
|
102
|
+
child.stdin.end(text);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
107
|
+
|
|
108
|
+
/** A ULID: 48-bit millisecond time + 80 random bits, Crockford base32 (26 chars). */
|
|
109
|
+
export function ulid(now = Date.now()) {
|
|
110
|
+
let time = "";
|
|
111
|
+
for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = CROCKFORD[t % 32] + time;
|
|
112
|
+
let rand = "";
|
|
113
|
+
for (const byte of getRandomValues(new Uint8Array(16))) rand += CROCKFORD[byte % 32];
|
|
114
|
+
return time + rand;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** "90", "90s", "10m", "2h" -> milliseconds. */
|
|
118
|
+
export function parseDuration(text) {
|
|
119
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(String(text).trim());
|
|
120
|
+
if (!match) {
|
|
121
|
+
throw new CliError(EXIT.USAGE, `--timeout takes a duration like 90s, 10m or 1h, not "${text}".`);
|
|
122
|
+
}
|
|
123
|
+
const unit = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2] ?? "s"];
|
|
124
|
+
return Math.round(Number(match[1]) * unit);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function hostOf(url) {
|
|
128
|
+
try {
|
|
129
|
+
return new URL(url).host;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/watch.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Following a change to its outcome.
|
|
2
|
+
//
|
|
3
|
+
// One loop long-polls `GET runs/{id}/change?wait=45&cursor=` (ChangeState). Long-polling
|
|
4
|
+
// survives proxies and deploys (the server returns early while it drains) and resumes by
|
|
5
|
+
// cursor. Ctrl-C only detaches: it never sends a cancel. `--logs` also tails the run's SSE
|
|
6
|
+
// events (resuming with Last-Event-ID) next to the loop.
|
|
7
|
+
|
|
8
|
+
import { ApiError, CliError, EXIT, explain } from "./errors.js";
|
|
9
|
+
import { backoffMs } from "./http.js";
|
|
10
|
+
import { ask, ulid } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
const POLL_WAIT_S = 45;
|
|
13
|
+
const MIN_POLL_GAP_MS = 1000;
|
|
14
|
+
const DEADLINE_GRACE_MS = 2000;
|
|
15
|
+
const TIMED_OUT = Symbol("timed out");
|
|
16
|
+
|
|
17
|
+
// Exit code per ChangeState state (docs: cli.mdx "Exit codes").
|
|
18
|
+
const STATE_EXIT = {
|
|
19
|
+
needs_input: EXIT.NEEDS_YOU,
|
|
20
|
+
held: EXIT.NEEDS_YOU,
|
|
21
|
+
ready: EXIT.OK,
|
|
22
|
+
live: EXIT.OK,
|
|
23
|
+
live_unverified: EXIT.UNVERIFIED,
|
|
24
|
+
failed: EXIT.FAILED,
|
|
25
|
+
reverted: EXIT.FAILED,
|
|
26
|
+
cancelled: EXIT.FAILED,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function exitFor(state) {
|
|
30
|
+
return STATE_EXIT[state.state] ?? (Number.isInteger(state.exit_code) ? state.exit_code : EXIT.FAILED);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The command (or URL) that moves this change on, or null. */
|
|
34
|
+
export function nextFor(state) {
|
|
35
|
+
const id = state.run_id;
|
|
36
|
+
if (state.state === "needs_input") return `syndai answer ${id}`;
|
|
37
|
+
if (state.state === "held") return `syndai review ${id}`;
|
|
38
|
+
if (state.state === "ready") return `syndai go-live ${id}`;
|
|
39
|
+
if (state.state === "live_unverified") return `syndai watch ${id}`;
|
|
40
|
+
return state.done ? null : `syndai watch ${id}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function describe(state) {
|
|
44
|
+
const headline = state.headline ? `: ${state.headline}` : "";
|
|
45
|
+
switch (state.state) {
|
|
46
|
+
case "queued":
|
|
47
|
+
return `Queued${state.queue_position ? ` (#${state.queue_position} in line)` : ""}`;
|
|
48
|
+
case "working":
|
|
49
|
+
return `Working${headline}`;
|
|
50
|
+
case "needs_input":
|
|
51
|
+
return `Needs you: ${state.needs_input?.[0]?.summary ?? "a question"}`;
|
|
52
|
+
case "landing":
|
|
53
|
+
return `Checking and going live${headline}`;
|
|
54
|
+
case "held":
|
|
55
|
+
return `Held: ${state.held?.message ?? state.held?.reason ?? "needs your review"}`;
|
|
56
|
+
case "ready":
|
|
57
|
+
return `Ready for review${headline}`;
|
|
58
|
+
case "live":
|
|
59
|
+
return `Live: ${state.live_url ?? state.project?.live_url ?? ""}`.trim();
|
|
60
|
+
case "live_unverified":
|
|
61
|
+
return `Live, but the live check could not confirm it: ${state.live_url ?? ""}`.trim();
|
|
62
|
+
default:
|
|
63
|
+
return `${state.state[0].toUpperCase()}${state.state.slice(1).replace(/_/g, " ")}${headline}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Print the final state (JSON ChangeState + `next`, or a line) and return its exit code. */
|
|
68
|
+
export function finish(ctx, state, { exitCode = exitFor(state), next = nextFor(state) } = {}) {
|
|
69
|
+
const { ui } = ctx;
|
|
70
|
+
const lines = [describe(state)];
|
|
71
|
+
if (state.run_url) lines.push(ui.dim(state.run_url));
|
|
72
|
+
ui.result({ ...state, ...(next ? { next } : {}) }, lines.join("\n"));
|
|
73
|
+
if (next && !ui.json) ui.progress(`Next: ${next}`);
|
|
74
|
+
return exitCode;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getChange(ctx, runId) {
|
|
78
|
+
return ctx.client.call("changeState", { params: { run_id: runId }, query: { wait: 0 } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Follow `runId` until it is done (or, with `untilNot`, until it leaves that state).
|
|
83
|
+
* Returns the exit code.
|
|
84
|
+
*/
|
|
85
|
+
export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = null, logs = false } = {}) {
|
|
86
|
+
const { client, ui, io } = ctx;
|
|
87
|
+
const deadline = timeoutMs === null ? null : io.now() + timeoutMs;
|
|
88
|
+
const deadlineCtl = new AbortController();
|
|
89
|
+
const timer =
|
|
90
|
+
deadline === null ? null : setTimeout(() => deadlineCtl.abort(TIMED_OUT), timeoutMs + DEADLINE_GRACE_MS);
|
|
91
|
+
timer?.unref?.();
|
|
92
|
+
const logsCtl = new AbortController();
|
|
93
|
+
const logsDone = logs ? tailEvents(ctx, runId, logsCtl.signal) : null;
|
|
94
|
+
let cursor;
|
|
95
|
+
let last = null;
|
|
96
|
+
let lastLine = null;
|
|
97
|
+
const answered = new Set(); // a state read before our answer landed must not ask again
|
|
98
|
+
try {
|
|
99
|
+
for (;;) {
|
|
100
|
+
const remaining = deadline === null ? Number.POSITIVE_INFINITY : deadline - io.now();
|
|
101
|
+
if (remaining <= 0) return timedOut(ctx, runId, last);
|
|
102
|
+
const wait = Math.max(0, Math.min(POLL_WAIT_S, Math.floor(remaining / 1000)));
|
|
103
|
+
const started = io.now();
|
|
104
|
+
let state;
|
|
105
|
+
try {
|
|
106
|
+
state = await client.call("changeState", {
|
|
107
|
+
params: { run_id: runId },
|
|
108
|
+
query: { wait, cursor },
|
|
109
|
+
timeoutMs: (wait + 20) * 1000,
|
|
110
|
+
signal: deadlineCtl.signal,
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err === TIMED_OUT) return timedOut(ctx, runId, last);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
const line = describe(state);
|
|
117
|
+
const changed = line !== lastLine || state.cursor !== cursor;
|
|
118
|
+
const final =
|
|
119
|
+
(state.done && state.state !== untilNot) || (state.state === "needs_input" && !ui.interactive);
|
|
120
|
+
if (final) return finish(ctx, state);
|
|
121
|
+
if (line !== lastLine) ui.progress(`${ui.dim("·")} ${line}`);
|
|
122
|
+
lastLine = line;
|
|
123
|
+
cursor = state.cursor ?? cursor;
|
|
124
|
+
last = state;
|
|
125
|
+
|
|
126
|
+
const open = (state.needs_input ?? []).filter((q) => !answered.has(q.hitl_request_id));
|
|
127
|
+
if (state.state === "needs_input" && open.length) {
|
|
128
|
+
await answerInteractively(ctx, runId, open);
|
|
129
|
+
for (const q of open) answered.add(q.hitl_request_id);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A server that answers at once without news (draining, a proxy) must not make us spin.
|
|
133
|
+
if (!changed && io.now() - started < MIN_POLL_GAP_MS) {
|
|
134
|
+
const left = deadline === null ? MIN_POLL_GAP_MS : Math.max(0, deadline - io.now());
|
|
135
|
+
await io.sleep(Math.min(MIN_POLL_GAP_MS, left), io.signal);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (io.signal.aborted || (err instanceof CliError && err.exitCode === EXIT.INTERRUPTED)) {
|
|
140
|
+
throw new CliError(EXIT.INTERRUPTED, "Stopped watching; the change keeps going.", {
|
|
141
|
+
next: `syndai watch ${runId}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
throw err;
|
|
145
|
+
} finally {
|
|
146
|
+
if (timer) clearTimeout(timer);
|
|
147
|
+
logsCtl.abort();
|
|
148
|
+
await logsDone;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function timedOut(ctx, runId, last) {
|
|
153
|
+
const state = last ?? { run_id: runId, state: "unknown", done: false };
|
|
154
|
+
const next = `syndai watch ${runId}`;
|
|
155
|
+
ctx.ui.result({ ...state, next }, `Still going (timeout reached): ${describe(state)}`);
|
|
156
|
+
if (!ctx.ui.json) ctx.ui.progress(`Next: ${next}`);
|
|
157
|
+
return EXIT.TIMEOUT;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- questions -----------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export async function answerInteractively(ctx, runId, questions) {
|
|
163
|
+
for (const question of questions) {
|
|
164
|
+
ctx.ui.progress(ctx.ui.bold(question.summary ?? "A question about your change"));
|
|
165
|
+
const entries = [];
|
|
166
|
+
for (const input of question.inputs ?? []) {
|
|
167
|
+
const entry = await askInput(ctx, input);
|
|
168
|
+
if (entry) entries.push(entry);
|
|
169
|
+
}
|
|
170
|
+
await respond(ctx, runId, question.hitl_request_id, entries);
|
|
171
|
+
ctx.ui.progress(ctx.ui.green("Answered."));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function respond(ctx, runId, hitlRequestId, entries) {
|
|
176
|
+
return ctx.client.call("respond", {
|
|
177
|
+
params: { run_id: runId },
|
|
178
|
+
body: { hitl_request_id: hitlRequestId, entries, idempotency_key: `cli:${ulid()}` },
|
|
179
|
+
retry: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function askInput(ctx, input) {
|
|
184
|
+
const { io, ui } = ctx;
|
|
185
|
+
if (input.context) ui.progress(ui.dim(input.context));
|
|
186
|
+
const optional = input.required ? "" : ui.dim(" (Enter to skip)");
|
|
187
|
+
if (input.kind === "approval") {
|
|
188
|
+
for (;;) {
|
|
189
|
+
const answer = (await ask(io, `${input.prompt} [y/n]${optional} `)).toLowerCase();
|
|
190
|
+
if (!answer && !input.required) return null;
|
|
191
|
+
if (["y", "yes", "n", "no"].includes(answer)) {
|
|
192
|
+
return { input_id: input.input_id, approved: answer.startsWith("y") };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const options = input.options ?? [];
|
|
197
|
+
const textAllowed = input.kind === "free_text" || Boolean(input.free_text?.enabled);
|
|
198
|
+
if (options.length === 0) {
|
|
199
|
+
for (;;) {
|
|
200
|
+
const text = await ask(io, `${input.prompt}${optional} `);
|
|
201
|
+
if (text || !input.required) return text ? { input_id: input.input_id, text } : null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
ui.progress(input.prompt);
|
|
205
|
+
for (const [i, option] of options.entries()) ui.progress(` ${i + 1}. ${option.label}`);
|
|
206
|
+
const multi = input.kind === "multi_choice";
|
|
207
|
+
const hint = multi ? "numbers, comma-separated" : "a number";
|
|
208
|
+
for (;;) {
|
|
209
|
+
const answer = await ask(io, `Pick ${hint}${textAllowed ? " or type an answer" : ""}${optional}: `);
|
|
210
|
+
if (!answer && !input.required) return null;
|
|
211
|
+
const picks = answer.split(",").map((part) => Number(part.trim()) - 1);
|
|
212
|
+
const valid = picks.every((i) => Number.isInteger(i) && options[i]) && (multi || picks.length === 1);
|
|
213
|
+
if (answer && valid) {
|
|
214
|
+
return { input_id: input.input_id, selected_option_ids: picks.map((i) => options[i].id) };
|
|
215
|
+
}
|
|
216
|
+
if (answer && textAllowed) return { input_id: input.input_id, text: answer };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- logs ----------------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
async function tailEvents(ctx, runId, signal) {
|
|
223
|
+
const { client, ui, io } = ctx;
|
|
224
|
+
let lastEventId = null;
|
|
225
|
+
for (let attempt = 0; !signal.aborted; ) {
|
|
226
|
+
try {
|
|
227
|
+
const res = await client.stream("runEvents", {
|
|
228
|
+
params: { run_id: runId },
|
|
229
|
+
headers: lastEventId ? { "last-event-id": lastEventId } : {},
|
|
230
|
+
signal,
|
|
231
|
+
});
|
|
232
|
+
attempt = 0;
|
|
233
|
+
for await (const event of parseSse(res.body)) {
|
|
234
|
+
if (event.id) lastEventId = event.id;
|
|
235
|
+
if (event.event === "stream_heartbeat") continue;
|
|
236
|
+
if (ui.json) {
|
|
237
|
+
io.stdout.write(`${JSON.stringify({ id: event.id, event: event.event, data: event.data })}\n`);
|
|
238
|
+
} else {
|
|
239
|
+
const payload = event.data?.payload ?? event.data ?? {};
|
|
240
|
+
const text = payload.summary ?? payload.message ?? payload.title ?? "";
|
|
241
|
+
ui.progress(ui.dim(` [${event.event}] ${String(text).slice(0, 200)}`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (signal.aborted || io.signal.aborted) return;
|
|
246
|
+
if (err instanceof ApiError && err.status < 500 && err.status !== 429) {
|
|
247
|
+
ui.progress(ui.yellow(`Logs stopped: ${explain(err).message}`));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
attempt = Math.min(attempt + 1, 5);
|
|
252
|
+
try {
|
|
253
|
+
await io.sleep(backoffMs(attempt), signal);
|
|
254
|
+
} catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Server-sent events from a byte stream: yields `{id, event, data}` (data JSON-parsed). */
|
|
261
|
+
export async function* parseSse(body) {
|
|
262
|
+
const decoder = new TextDecoder();
|
|
263
|
+
let buffer = "";
|
|
264
|
+
for await (const chunk of body) {
|
|
265
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
266
|
+
let split = buffer.search(/\r?\n\r?\n/);
|
|
267
|
+
while (split !== -1) {
|
|
268
|
+
const block = buffer.slice(0, split);
|
|
269
|
+
buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
|
|
270
|
+
const event = { id: null, event: "message", data: "" };
|
|
271
|
+
const data = [];
|
|
272
|
+
for (const line of block.split(/\r?\n/)) {
|
|
273
|
+
const colon = line.indexOf(":");
|
|
274
|
+
if (colon === 0) continue; // comment
|
|
275
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
276
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
277
|
+
if (field === "data") data.push(value);
|
|
278
|
+
else if (field === "id") event.id = value;
|
|
279
|
+
else if (field === "event") event.event = value;
|
|
280
|
+
}
|
|
281
|
+
if (data.length) {
|
|
282
|
+
try {
|
|
283
|
+
event.data = JSON.parse(data.join("\n"));
|
|
284
|
+
} catch {
|
|
285
|
+
event.data = data.join("\n");
|
|
286
|
+
}
|
|
287
|
+
yield event;
|
|
288
|
+
}
|
|
289
|
+
split = buffer.search(/\r?\n\r?\n/);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Solo Zero Labs
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
-
|
|
7
|
-
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
-
|
|
9
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/src/api.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// Minimal JSON-over-HTTPS client for the pairing routes, built on node:https so
|
|
2
|
-
// the package stays dependency-free. Every helper returns { status, body } where
|
|
3
|
-
// body is the parsed JSON (or the raw string when it isn't JSON) — callers decide
|
|
4
|
-
// what a non-2xx means, per route.
|
|
5
|
-
|
|
6
|
-
import https from "node:https";
|
|
7
|
-
import http from "node:http";
|
|
8
|
-
import { URL } from "node:url";
|
|
9
|
-
|
|
10
|
-
// Production API host. Overridable with SYNDAI_API_URL for staging/dev. Matches
|
|
11
|
-
// tools/syndai-pickup (_DEFAULT_API_BASE) and apps/portal (NEXT_PUBLIC_API_BASE).
|
|
12
|
-
export function apiBase() {
|
|
13
|
-
return (process.env.SYNDAI_API_URL || "https://api.syndai.ai").replace(/\/+$/, "");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function request(method, path, payload) {
|
|
17
|
-
const url = new URL(apiBase() + path);
|
|
18
|
-
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
|
19
|
-
const transport = url.protocol === "http:" ? http : https;
|
|
20
|
-
const options = {
|
|
21
|
-
method,
|
|
22
|
-
hostname: url.hostname,
|
|
23
|
-
port: url.port || undefined,
|
|
24
|
-
path: url.pathname + url.search,
|
|
25
|
-
headers: {
|
|
26
|
-
accept: "application/json",
|
|
27
|
-
"user-agent": "syndai-connect",
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
if (body !== undefined) {
|
|
31
|
-
options.headers["content-type"] = "application/json";
|
|
32
|
-
options.headers["content-length"] = Buffer.byteLength(body);
|
|
33
|
-
}
|
|
34
|
-
return new Promise((resolve, reject) => {
|
|
35
|
-
const req = transport.request(options, (res) => {
|
|
36
|
-
const chunks = [];
|
|
37
|
-
res.on("data", (c) => chunks.push(c));
|
|
38
|
-
res.on("end", () => {
|
|
39
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
40
|
-
let parsed = text;
|
|
41
|
-
try {
|
|
42
|
-
parsed = text ? JSON.parse(text) : {};
|
|
43
|
-
} catch {
|
|
44
|
-
/* leave as raw text */
|
|
45
|
-
}
|
|
46
|
-
resolve({ status: res.statusCode || 0, body: parsed });
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
req.on("error", reject);
|
|
50
|
-
if (body !== undefined) req.write(body);
|
|
51
|
-
req.end();
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function post(path, payload) {
|
|
56
|
-
return request("POST", path, payload);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** True for any 2xx status. */
|
|
60
|
-
export function ok(status) {
|
|
61
|
-
return status >= 200 && status < 300;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Best-effort human message out of a JSON error body. */
|
|
65
|
-
export function errorMessage(body) {
|
|
66
|
-
if (body && typeof body === "object") {
|
|
67
|
-
return body.detail || body.message || body.error || JSON.stringify(body);
|
|
68
|
-
}
|
|
69
|
-
return String(body || "unknown error");
|
|
70
|
-
}
|