@fulldivevr/workcord-cli 2.17.0 → 3.0.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,234 @@
1
+ import { ChunkAssembler, encodeFrame } from "./chunks.js";
2
+ const OPEN = 1;
3
+ const PING_INTERVAL_MS = 10_000;
4
+ const PONG_TIMEOUT_MS = 60_000;
5
+ const CONNECT_TIMEOUT_MS = 30_000;
6
+ const BACKOFF_INITIAL_MS = 5_000;
7
+ const BACKOFF_CAP_MS = 30_000;
8
+ const BACKOFF_RESET_AFTER_MS = 60_000;
9
+ const TICK_MS = 1_000;
10
+ export function defaultBackoffMs(attempt) {
11
+ const backoff = Math.min(BACKOFF_CAP_MS, BACKOFF_INITIAL_MS * 2 ** Math.max(0, attempt));
12
+ return backoff * (0.5 + Math.random() * 0.5);
13
+ }
14
+ /**
15
+ * The connection model of Codex remote control (app-server-transport
16
+ * remote_control/websocket.rs): auth on the handshake, a ping every 10s and a
17
+ * reconnect after 60s of silence, tasks deduped by the server's seq and resumed
18
+ * from a subscribe cursor, results kept until the server acks their seq.
19
+ */
20
+ export class MachineRelayClient {
21
+ options;
22
+ socket;
23
+ opened = false;
24
+ stopped = false;
25
+ status;
26
+ attempt = 0;
27
+ cursor;
28
+ resultSeq = 0;
29
+ connectStartedAt = 0;
30
+ connectedAt = 0;
31
+ lastFrameAt = 0;
32
+ lastPingAt = 0;
33
+ ticker;
34
+ reconnectTimer;
35
+ finish;
36
+ running = new Map();
37
+ unacked = new Map();
38
+ chunks = new ChunkAssembler();
39
+ key;
40
+ now;
41
+ log;
42
+ constructor(options) {
43
+ this.options = options;
44
+ this.now = options.now ?? Date.now;
45
+ this.log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
46
+ this.key = options.key;
47
+ }
48
+ /** A rotated key takes effect on the next handshake; the open socket was authenticated already. */
49
+ setKey(key) {
50
+ this.key = key;
51
+ }
52
+ start() {
53
+ const done = new Promise((resolve) => { this.finish = resolve; });
54
+ if (!this.options.now)
55
+ this.ticker = setInterval(() => this.tick(), TICK_MS);
56
+ this.connect();
57
+ return done;
58
+ }
59
+ stop() {
60
+ this.end({ ok: true });
61
+ }
62
+ tick() {
63
+ const socket = this.socket;
64
+ if (!socket || this.stopped)
65
+ return;
66
+ const now = this.now();
67
+ if (!this.opened) {
68
+ if (now - this.connectStartedAt > CONNECT_TIMEOUT_MS)
69
+ socket.close(4000, "connect timeout");
70
+ return;
71
+ }
72
+ if (now - this.lastFrameAt > PONG_TIMEOUT_MS) {
73
+ socket.close(4000, "server silent");
74
+ return;
75
+ }
76
+ if (now - this.lastPingAt >= PING_INTERVAL_MS) {
77
+ this.lastPingAt = now;
78
+ this.send({ t: "ping" });
79
+ }
80
+ }
81
+ end(outcome) {
82
+ if (this.stopped)
83
+ return;
84
+ this.stopped = true;
85
+ clearInterval(this.ticker);
86
+ clearTimeout(this.reconnectTimer);
87
+ for (const controller of this.running.values())
88
+ controller.abort();
89
+ this.socket?.close(1000, "stopped");
90
+ this.finish?.(outcome);
91
+ }
92
+ setStatus(status) {
93
+ if (this.status === status)
94
+ return;
95
+ this.status = status;
96
+ this.log(`machine: ${status}`);
97
+ }
98
+ connect() {
99
+ if (this.stopped)
100
+ return;
101
+ this.setStatus("connecting");
102
+ const headers = {
103
+ authorization: `Bearer ${this.key}`,
104
+ "x-workcord-installation-id": this.options.installationId,
105
+ ...(this.options.cliVersion ? { "x-workcord-cli-version": this.options.cliVersion } : {}),
106
+ ...(this.options.adapters ? { "x-workcord-adapters": this.options.adapters.join(",") } : {}),
107
+ ...(this.cursor !== undefined ? { "x-workcord-subscribe-cursor": String(this.cursor) } : {}),
108
+ };
109
+ const connect = this.options.connect
110
+ ?? ((url, init) => new WebSocket(url, { headers: init }));
111
+ const socket = connect(this.options.url, headers);
112
+ this.socket = socket;
113
+ this.opened = false;
114
+ this.chunks = new ChunkAssembler();
115
+ this.connectStartedAt = this.now();
116
+ socket.addEventListener("open", () => {
117
+ if (socket !== this.socket)
118
+ return;
119
+ this.opened = true;
120
+ this.connectedAt = this.lastFrameAt = this.lastPingAt = this.now();
121
+ this.setStatus("connected");
122
+ for (const seq of [...this.unacked.keys()].sort((a, b) => a - b))
123
+ this.send(this.unacked.get(seq));
124
+ });
125
+ socket.addEventListener("message", (event) => {
126
+ if (socket !== this.socket)
127
+ return;
128
+ this.lastFrameAt = this.now();
129
+ let frame;
130
+ try {
131
+ frame = JSON.parse(String(event.data));
132
+ if (frame?.t === "chunk") {
133
+ frame = this.chunks.accept(frame);
134
+ if (frame === undefined)
135
+ return;
136
+ }
137
+ }
138
+ catch {
139
+ return;
140
+ }
141
+ this.onFrame(frame);
142
+ });
143
+ socket.addEventListener("close", (event) => {
144
+ if (socket !== this.socket || this.stopped)
145
+ return;
146
+ void this.onClose(event?.code);
147
+ });
148
+ socket.addEventListener("error", () => { });
149
+ }
150
+ async onClose(code) {
151
+ if (this.opened) {
152
+ if (this.now() - this.connectedAt >= BACKOFF_RESET_AFTER_MS)
153
+ this.attempt = 0;
154
+ this.scheduleReconnect(`disconnected (${code ?? "?"})`);
155
+ return;
156
+ }
157
+ this.setStatus("errored");
158
+ const verdict = await this.options.probe().catch(() => "retry");
159
+ if (this.stopped)
160
+ return;
161
+ if (verdict === "unauthorized") {
162
+ const reason = "the server says this machine key is revoked or invalid. Pair it again with `workcord machine pair` (or `workcord machine enroll`).";
163
+ this.log(`machine: ${reason}`);
164
+ this.end({ ok: false, reason });
165
+ return;
166
+ }
167
+ this.scheduleReconnect(verdict === "in_use" ? "this key is connected from another computer" : "could not connect");
168
+ }
169
+ onFrame(frame) {
170
+ switch (frame?.t) {
171
+ case "ack":
172
+ for (const seq of this.unacked.keys())
173
+ if (seq <= Number(frame.seq))
174
+ this.unacked.delete(seq);
175
+ return;
176
+ case "cancel":
177
+ this.running.get(String(frame.taskId))?.abort();
178
+ return;
179
+ case "task": {
180
+ const seq = Number(frame.seq);
181
+ if (this.cursor !== undefined && seq <= this.cursor)
182
+ return;
183
+ this.cursor = seq;
184
+ void this.runTask(String(frame.taskId), frame.rpc);
185
+ return;
186
+ }
187
+ }
188
+ }
189
+ async runTask(taskId, rpc) {
190
+ if (this.running.has(taskId))
191
+ return;
192
+ const controller = new AbortController();
193
+ this.running.set(taskId, controller);
194
+ this.log(`machine: task ${taskId} started`);
195
+ let response;
196
+ try {
197
+ response = await this.options.handle(rpc, controller.signal, (event) => this.send({ t: "event", taskId, event }));
198
+ }
199
+ catch (error) {
200
+ response = { jsonrpc: "2.0", id: rpc?.id ?? null, error: { code: -32603, message: error.message || String(error) } };
201
+ }
202
+ finally {
203
+ this.running.delete(taskId);
204
+ }
205
+ const seq = ++this.resultSeq;
206
+ let frame = { t: "result", seq, taskId, response };
207
+ try {
208
+ encodeFrame(frame);
209
+ }
210
+ catch (error) {
211
+ frame = { t: "result", seq, taskId, response: { jsonrpc: "2.0", id: rpc?.id ?? null, error: { code: -32603, message: error.message } } };
212
+ }
213
+ this.unacked.set(seq, frame);
214
+ this.log(`machine: task ${taskId} finished`);
215
+ this.send(frame);
216
+ }
217
+ send(frame) {
218
+ if (this.socket?.readyState !== OPEN)
219
+ return;
220
+ for (const data of encodeFrame(frame))
221
+ this.socket.send(data);
222
+ }
223
+ scheduleReconnect(why) {
224
+ if (this.stopped)
225
+ return;
226
+ if (this.status !== "errored")
227
+ this.setStatus("connecting");
228
+ const delay = (this.options.backoffMs ?? defaultBackoffMs)(this.attempt);
229
+ this.attempt += 1;
230
+ this.log(`machine: ${why}, reconnecting in ${Math.round(delay / 1000)}s`);
231
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
232
+ }
233
+ }
234
+ //# sourceMappingURL=relay.js.map
@@ -0,0 +1,36 @@
1
+ import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { normalizeBaseUrl } from "../http.js";
4
+ export const ROTATE_EVERY_MS = 24 * 60 * 60_000;
5
+ /** Writes to a temp file and renames over the old one, so a crash never leaves half a key. */
6
+ export async function writeMachineFileAtomic(path, file) {
7
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
8
+ const temp = `${path}.${process.pid}.tmp`;
9
+ await writeFile(temp, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 });
10
+ await chmod(temp, 0o600);
11
+ await rename(temp, path);
12
+ }
13
+ export async function rotateMachineKey(input) {
14
+ const { file } = input;
15
+ const url = new URL(`/api/v1/machine-agents/${encodeURIComponent(file.machineId)}/rotate`, normalizeBaseUrl(file.baseUrl));
16
+ const response = await (input.fetchImpl ?? fetch)(url, {
17
+ method: "POST",
18
+ headers: { authorization: `Bearer ${file.key}` },
19
+ signal: AbortSignal.timeout(30_000),
20
+ });
21
+ if (!response.ok)
22
+ throw new Error(`key rotation refused with ${response.status}`);
23
+ const { machineKey } = (await response.json());
24
+ if (!machineKey)
25
+ throw new Error("key rotation returned no key");
26
+ const next = { ...file, key: machineKey, rotatedAt: new Date().toISOString() };
27
+ await writeMachineFileAtomic(input.path, next);
28
+ return next;
29
+ }
30
+ export function nextRotationDelay(file, now = Date.now()) {
31
+ const last = file.rotatedAt ? Date.parse(file.rotatedAt) : NaN;
32
+ if (!Number.isFinite(last))
33
+ return ROTATE_EVERY_MS;
34
+ return Math.max(60_000, last + ROTATE_EVERY_MS - now);
35
+ }
36
+ //# sourceMappingURL=rotate.js.map
@@ -0,0 +1,112 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const exec = promisify(execFile);
7
+ export const LAUNCHD_LABEL = "com.fulldive.workcord.machine";
8
+ const SYSTEMD_UNIT = "workcord-machine.service";
9
+ export function serviceArgs(input) {
10
+ return ["machine", "start", ...input.roots.flatMap((root) => ["--root", root]), "--adapter", input.adapter];
11
+ }
12
+ export function launchdPlist(spec) {
13
+ const args = [spec.node, spec.cli, ...spec.args].map((arg) => ` <string>${xml(arg)}</string>`).join("\n");
14
+ return `<?xml version="1.0" encoding="UTF-8"?>
15
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
16
+ <plist version="1.0">
17
+ <dict>
18
+ <key>Label</key>
19
+ <string>${LAUNCHD_LABEL}</string>
20
+ <key>ProgramArguments</key>
21
+ <array>
22
+ ${args}
23
+ </array>
24
+ <key>EnvironmentVariables</key>
25
+ <dict>
26
+ <key>PATH</key>
27
+ <string>${xml(spec.pathDirs.join(":"))}</string>
28
+ <key>HOME</key>
29
+ <string>${xml(spec.home)}</string>
30
+ </dict>
31
+ <key>WorkingDirectory</key>
32
+ <string>${xml(spec.home)}</string>
33
+ <key>RunAtLoad</key>
34
+ <true/>
35
+ <key>KeepAlive</key>
36
+ <true/>
37
+ <key>StandardOutPath</key>
38
+ <string>${xml(spec.logPath)}</string>
39
+ <key>StandardErrorPath</key>
40
+ <string>${xml(spec.logPath)}</string>
41
+ </dict>
42
+ </plist>
43
+ `;
44
+ }
45
+ export function systemdUnit(spec) {
46
+ const command = [spec.node, spec.cli, ...spec.args].map(shellWord).join(" ");
47
+ return `[Unit]
48
+ Description=WorkCord machine agent
49
+ After=network-online.target
50
+
51
+ [Service]
52
+ ExecStart=${command}
53
+ Environment=PATH=${spec.pathDirs.join(":")}
54
+ WorkingDirectory=${spec.home}
55
+ Restart=always
56
+ RestartSec=5
57
+ StandardOutput=append:${spec.logPath}
58
+ StandardError=append:${spec.logPath}
59
+
60
+ [Install]
61
+ WantedBy=default.target
62
+ `;
63
+ }
64
+ export function servicePaths(home = homedir()) {
65
+ return {
66
+ launchd: join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
67
+ systemd: join(home, ".config", "systemd", "user", SYSTEMD_UNIT),
68
+ log: join(home, ".workcord", "machine.log"),
69
+ };
70
+ }
71
+ export async function installService(spec) {
72
+ const paths = servicePaths(spec.home);
73
+ await mkdir(dirname(spec.logPath), { recursive: true, mode: 0o700 });
74
+ if (process.platform === "darwin") {
75
+ await mkdir(dirname(paths.launchd), { recursive: true });
76
+ const domain = `gui/${process.getuid?.() ?? 501}`;
77
+ await exec("launchctl", ["bootout", `${domain}/${LAUNCHD_LABEL}`]).catch(() => undefined);
78
+ await writeFile(paths.launchd, launchdPlist(spec), { mode: 0o644 });
79
+ await exec("launchctl", ["bootstrap", domain, paths.launchd]);
80
+ return { path: paths.launchd, manager: "launchd" };
81
+ }
82
+ if (process.platform === "linux") {
83
+ await mkdir(dirname(paths.systemd), { recursive: true });
84
+ await writeFile(paths.systemd, systemdUnit(spec), { mode: 0o644 });
85
+ await exec("systemctl", ["--user", "daemon-reload"]);
86
+ await exec("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
87
+ return { path: paths.systemd, manager: "systemd" };
88
+ }
89
+ throw new Error(`install-service supports macOS (launchd) and Linux (systemd), not ${process.platform}.`);
90
+ }
91
+ export async function uninstallService(home = homedir()) {
92
+ const paths = servicePaths(home);
93
+ if (process.platform === "darwin") {
94
+ await exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${LAUNCHD_LABEL}`]).catch(() => undefined);
95
+ await rm(paths.launchd, { force: true });
96
+ return { removed: paths.launchd };
97
+ }
98
+ if (process.platform === "linux") {
99
+ await exec("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]).catch(() => undefined);
100
+ await rm(paths.systemd, { force: true });
101
+ await exec("systemctl", ["--user", "daemon-reload"]).catch(() => undefined);
102
+ return { removed: paths.systemd };
103
+ }
104
+ return { removed: null };
105
+ }
106
+ function xml(value) {
107
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
108
+ }
109
+ function shellWord(value) {
110
+ return /^[\w@%+=:,./-]+$/.test(value) ? value : `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
111
+ }
112
+ //# sourceMappingURL=service.js.map
@@ -0,0 +1,143 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ const MAX_FILES = 400;
5
+ const MAX_OUTPUT_BYTES = 32 * 1024;
6
+ const SNIPPET_RADIUS = 160;
7
+ const SECRETS = [
8
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
9
+ /\bwize_[a-z]+_[A-Za-z0-9]{16,}/g,
10
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
11
+ /\bAKIA[0-9A-Z]{16}\b/g,
12
+ /\bxox[abpr]-[A-Za-z0-9-]{10,}/g,
13
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
14
+ /(bearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi,
15
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
16
+ ];
17
+ export async function searchSessions(options) {
18
+ const terms = options.query.toLowerCase().split(/\s+/).filter(Boolean);
19
+ if (!terms.length)
20
+ return [];
21
+ const home = options.homeDir ?? homedir();
22
+ const since = options.since?.getTime() ?? 0;
23
+ const limit = Math.max(1, Math.min(options.limit ?? 10, 50));
24
+ const candidates = (await Promise.all([
25
+ claudeFiles(join(home, ".claude", "projects")),
26
+ codexFiles(join(home, ".codex", "sessions")),
27
+ ])).flat().filter((file) => file.mtime >= since).sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
28
+ const hits = [];
29
+ let bytes = 0;
30
+ for (const file of candidates) {
31
+ let sessionId = basename(file.path, ".jsonl");
32
+ let project = file.project;
33
+ const lines = (await readFile(file.path, "utf8").catch(() => "")).split("\n");
34
+ for (const line of lines) {
35
+ const entry = file.tool === "claude" ? claudeEntry(line) : codexEntry(line);
36
+ if (!entry)
37
+ continue;
38
+ if (entry.sessionId)
39
+ sessionId = entry.sessionId;
40
+ if (entry.cwd)
41
+ project = entry.cwd;
42
+ if (!entry.text)
43
+ continue;
44
+ const lower = entry.text.toLowerCase();
45
+ if (!terms.every((term) => lower.includes(term)))
46
+ continue;
47
+ const hit = {
48
+ tool: file.tool, project, sessionId, ...(entry.ts ? { ts: entry.ts } : {}),
49
+ role: entry.role, snippet: redact(snippet(entry.text, lower.indexOf(terms[0]))),
50
+ };
51
+ bytes += Buffer.byteLength(JSON.stringify(hit));
52
+ if (bytes > MAX_OUTPUT_BYTES)
53
+ return hits;
54
+ hits.push(hit);
55
+ if (hits.length >= limit)
56
+ return hits;
57
+ }
58
+ }
59
+ return hits;
60
+ }
61
+ export function redact(text) {
62
+ return SECRETS.reduce((out, pattern) => out.replace(pattern, (_match, prefix) => typeof prefix === "string" && /bearer/i.test(prefix) ? `${prefix}[redacted]` : "[redacted]"), text);
63
+ }
64
+ function snippet(text, at) {
65
+ const start = Math.max(0, at - SNIPPET_RADIUS);
66
+ const end = Math.min(text.length, at + SNIPPET_RADIUS);
67
+ return `${start > 0 ? "…" : ""}${text.slice(start, end).replace(/\s+/g, " ").trim()}${end < text.length ? "…" : ""}`;
68
+ }
69
+ function claudeEntry(line) {
70
+ const row = parse(line);
71
+ if (!row || (row.type !== "user" && row.type !== "assistant"))
72
+ return null;
73
+ const content = row.message?.content;
74
+ const text = typeof content === "string"
75
+ ? content
76
+ : Array.isArray(content)
77
+ ? content.filter((part) => part?.type === "text").map((part) => String(part.text || "")).join("\n")
78
+ : "";
79
+ return { role: row.type, text, ts: row.timestamp, sessionId: row.sessionId, cwd: row.cwd };
80
+ }
81
+ function codexEntry(line) {
82
+ const row = parse(line);
83
+ if (!row)
84
+ return null;
85
+ if (row.type === "session_meta")
86
+ return { role: "meta", text: "", sessionId: row.payload?.id, cwd: row.payload?.cwd };
87
+ const payload = row.payload;
88
+ if (row.type !== "response_item" || payload?.type !== "message")
89
+ return null;
90
+ if (payload.role !== "user" && payload.role !== "assistant")
91
+ return null;
92
+ const text = Array.isArray(payload.content)
93
+ ? payload.content.map((part) => String(part?.text || "")).join("\n")
94
+ : "";
95
+ if (/^\s*(# AGENTS\.md|<environment_context>|<user_instructions>)/.test(text))
96
+ return null;
97
+ return { role: payload.role, text, ts: row.timestamp };
98
+ }
99
+ function parse(line) {
100
+ if (!line.trim())
101
+ return null;
102
+ try {
103
+ return JSON.parse(line);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ async function claudeFiles(dir) {
110
+ const projects = await readdir(dir).catch(() => []);
111
+ const out = [];
112
+ for (const project of projects) {
113
+ const files = await readdir(join(dir, project)).catch(() => []);
114
+ for (const name of files) {
115
+ if (!name.endsWith(".jsonl"))
116
+ continue;
117
+ const path = join(dir, project, name);
118
+ const info = await stat(path).catch(() => null);
119
+ if (info?.isFile())
120
+ out.push({ tool: "claude", path, mtime: info.mtimeMs, project: project.replace(/-/g, "/") });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ async function codexFiles(dir) {
126
+ const out = [];
127
+ const walk = async (current, depth) => {
128
+ const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
129
+ for (const entry of entries) {
130
+ const path = join(current, entry.name);
131
+ if (entry.isDirectory() && depth < 4)
132
+ await walk(path, depth + 1);
133
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
134
+ const info = await stat(path).catch(() => null);
135
+ if (info)
136
+ out.push({ tool: "codex", path, mtime: info.mtimeMs, project: "" });
137
+ }
138
+ }
139
+ };
140
+ await walk(dir, 0);
141
+ return out;
142
+ }
143
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1,68 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ const MAX_ENTRIES = 1_000;
4
+ /**
5
+ * The agent thread (Codex) or session (Claude Code) that carries each A2A
6
+ * contextId, as a hub tenant keys its runtime session on `a2a:<contextId>`.
7
+ * Kept on disk so that a CLI restart does not cut a conversation.
8
+ */
9
+ export class ConversationThreads {
10
+ path;
11
+ entries;
12
+ saving = Promise.resolve();
13
+ constructor(path) {
14
+ this.path = path;
15
+ }
16
+ async get(adapter, contextId) {
17
+ return (await this.load())[key(adapter, contextId)]?.id;
18
+ }
19
+ async set(adapter, contextId, id) {
20
+ const entries = await this.load();
21
+ entries[key(adapter, contextId)] = { id, usedAt: Date.now() };
22
+ const kept = Object.entries(entries).sort(([, a], [, b]) => b.usedAt - a.usedAt).slice(0, MAX_ENTRIES);
23
+ this.entries = Object.fromEntries(kept);
24
+ await this.save();
25
+ }
26
+ async delete(adapter, contextId) {
27
+ const entries = await this.load();
28
+ if (!entries[key(adapter, contextId)])
29
+ return;
30
+ delete entries[key(adapter, contextId)];
31
+ await this.save();
32
+ }
33
+ async load() {
34
+ if (this.entries)
35
+ return this.entries;
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(await readFile(this.path, "utf8"));
39
+ }
40
+ catch {
41
+ parsed = {};
42
+ }
43
+ const entries = {};
44
+ if (parsed && typeof parsed === "object") {
45
+ for (const [name, value] of Object.entries(parsed)) {
46
+ if (typeof value?.id === "string" && value.id)
47
+ entries[name] = { id: value.id, usedAt: Number(value.usedAt) || 0 };
48
+ }
49
+ }
50
+ this.entries ??= entries;
51
+ return this.entries;
52
+ }
53
+ save() {
54
+ const snapshot = JSON.stringify(this.entries ?? {}, null, 2) + "\n";
55
+ this.saving = this.saving.catch(() => undefined).then(async () => {
56
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
57
+ const temp = `${this.path}.${process.pid}.tmp`;
58
+ await writeFile(temp, snapshot, { mode: 0o600 });
59
+ await chmod(temp, 0o600);
60
+ await rename(temp, this.path);
61
+ });
62
+ return this.saving;
63
+ }
64
+ }
65
+ function key(adapter, contextId) {
66
+ return `${adapter}:${contextId}`;
67
+ }
68
+ //# sourceMappingURL=threads.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulldivevr/workcord-cli",
3
- "version": "2.17.0",
3
+ "version": "3.0.0",
4
4
  "description": "Agent-first CLI using persistent Hermes tenant chat, with advanced API diagnostics.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",
@@ -47,7 +47,8 @@
47
47
  ],
48
48
  "dependencies": {
49
49
  "commander": "^12.1.0",
50
- "jsonwebtoken": "^9.0.2"
50
+ "jsonwebtoken": "^9.0.2",
51
+ "qrcode-terminal": "^0.12.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/jsonwebtoken": "^9.0.7",
@@ -56,6 +57,6 @@
56
57
  "typescript": "^5.6.0"
57
58
  },
58
59
  "engines": {
59
- "node": ">=20"
60
+ "node": ">=22"
60
61
  }
61
62
  }