talentos 0.3.0 → 1.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.
- package/README.md +145 -103
- package/dist/agent/adapters.js +260 -0
- package/dist/agent/gateway.js +70 -0
- package/dist/agent/mcp-entry.js +10 -0
- package/dist/agent/serve.js +269 -0
- package/dist/agent/sessions.js +36 -0
- package/dist/agent-config.js +105 -0
- package/dist/agent-http.js +176 -0
- package/dist/agent-link.js +49 -0
- package/dist/agent-service.js +145 -0
- package/dist/args.js +52 -0
- package/dist/client.js +87 -0
- package/dist/commands/agent-serve.js +42 -0
- package/dist/commands/agent.js +234 -0
- package/dist/commands/ask.js +74 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/login.js +125 -0
- package/dist/commands/mcp.js +48 -0
- package/dist/commands/memory.js +61 -0
- package/dist/commands/routines.js +44 -0
- package/dist/commands/skills.js +183 -0
- package/dist/commands/whoami.js +29 -0
- package/dist/config.js +70 -0
- package/dist/format.js +54 -0
- package/dist/index.js +139 -91
- package/dist/install.js +253 -0
- package/dist/login.js +86 -0
- package/dist/mcp-proxy.js +17 -0
- package/dist/skill-files.js +109 -0
- package/package.json +12 -4
- package/dist/api.js +0 -210
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, open, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { childEnvironment, runProvider } from "./adapters.js";
|
|
7
|
+
import { AgentGateway, GatewayError } from "./gateway.js";
|
|
8
|
+
import { SessionMap } from "./sessions.js";
|
|
9
|
+
const HEARTBEAT_MS = 20_000;
|
|
10
|
+
const LEASE_SAFETY_MS = 55_000;
|
|
11
|
+
const IDLE_MS = 30 * 60_000;
|
|
12
|
+
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
13
|
+
export function needsUpdate(current, minimum) {
|
|
14
|
+
const a = VERSION.exec(current), b = VERSION.exec(minimum);
|
|
15
|
+
if (!b) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (!a) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
for (let i = 1; i <= 3; i++) {
|
|
22
|
+
if (Number(a[i]) !== Number(b[i])) {
|
|
23
|
+
return Number(a[i]) < Number(b[i]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
export function claimPollInterval(lastJobAt, now = Date.now()) {
|
|
29
|
+
return now - lastJobAt >= IDLE_MS ? 10_000 : 2000;
|
|
30
|
+
}
|
|
31
|
+
async function acquireLock(directory) {
|
|
32
|
+
const path = join(directory, "serve.lock");
|
|
33
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
34
|
+
try {
|
|
35
|
+
const handle = await open(path, "wx", 0o600);
|
|
36
|
+
await handle.writeFile(String(process.pid));
|
|
37
|
+
await handle.close();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== "EEXIST") {
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const pid = Number(await readFile(path, "utf8"));
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0);
|
|
46
|
+
}
|
|
47
|
+
catch (failure) {
|
|
48
|
+
if (failure.code === "ESRCH") {
|
|
49
|
+
await rm(path, { force: true });
|
|
50
|
+
return await acquireLock(directory);
|
|
51
|
+
}
|
|
52
|
+
throw failure;
|
|
53
|
+
}
|
|
54
|
+
throw new Error("This agent is already serving on this machine.");
|
|
55
|
+
}
|
|
56
|
+
return async () => {
|
|
57
|
+
await rm(path, { force: true });
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function installGatewayUpdate(directory, minimum, signal) {
|
|
61
|
+
if (!VERSION.test(minimum)) {
|
|
62
|
+
throw new Error("Unsupported gateway minimum version.");
|
|
63
|
+
}
|
|
64
|
+
const destination = join(directory, "gateway-cli");
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawn("npm", [
|
|
67
|
+
"install",
|
|
68
|
+
"--prefix",
|
|
69
|
+
destination,
|
|
70
|
+
"--omit=dev",
|
|
71
|
+
"--ignore-scripts",
|
|
72
|
+
"--no-audit",
|
|
73
|
+
"--no-fund",
|
|
74
|
+
`talentos@${minimum}`,
|
|
75
|
+
], { env: childEnvironment(), stdio: "ignore", signal });
|
|
76
|
+
child.once("error", () => reject(new Error("Gateway update could not start.")));
|
|
77
|
+
child.once("close", (code) => code === 0
|
|
78
|
+
? resolve()
|
|
79
|
+
: reject(new Error("Gateway update failed. Retry when the network is available.")));
|
|
80
|
+
});
|
|
81
|
+
return join(destination, "node_modules", "talentos", "dist", "index.js");
|
|
82
|
+
}
|
|
83
|
+
export async function serveAgent(config, directory, version, stop) {
|
|
84
|
+
const release = await acquireLock(directory);
|
|
85
|
+
try {
|
|
86
|
+
const workDir = join(directory, "work"), sessions = new SessionMap(directory), gateway = new AgentGateway(config);
|
|
87
|
+
await mkdir(workDir, { recursive: true, mode: 0o700 });
|
|
88
|
+
const deviceId = randomUUID();
|
|
89
|
+
const service = new AbortController();
|
|
90
|
+
const signal = AbortSignal.any([stop, service.signal]);
|
|
91
|
+
let wakeResolve, wakePending = true;
|
|
92
|
+
const wake = () => {
|
|
93
|
+
wakePending = true;
|
|
94
|
+
wakeResolve?.();
|
|
95
|
+
};
|
|
96
|
+
let active;
|
|
97
|
+
let minimum = version, lastJobAt = Date.now(), push = false, dispose;
|
|
98
|
+
let terminalError;
|
|
99
|
+
const failService = (error) => {
|
|
100
|
+
if (error instanceof GatewayError && error.status === 401) {
|
|
101
|
+
terminalError = error;
|
|
102
|
+
service.abort();
|
|
103
|
+
active?.cancel.abort();
|
|
104
|
+
wake();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const heartbeat = async () => {
|
|
108
|
+
const response = await gateway.request("/agent-gateway/heartbeat", { cliVersion: version }, signal);
|
|
109
|
+
if (response) {
|
|
110
|
+
minimum = response.cliMinVersion;
|
|
111
|
+
if (response.pendingJobs > 0 || minimum !== version) {
|
|
112
|
+
wake();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const heartbeatLoop = async () => {
|
|
117
|
+
while (!signal.aborted) {
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
const leased = active;
|
|
120
|
+
await Promise.all([
|
|
121
|
+
heartbeat().catch(failService),
|
|
122
|
+
leased
|
|
123
|
+
? gateway
|
|
124
|
+
.request(`/agent-gateway/jobs/${leased.job.jobId}/renew`, { leaseToken: leased.job.leaseToken }, signal)
|
|
125
|
+
.then(() => {
|
|
126
|
+
if (active === leased) {
|
|
127
|
+
leased.deadline = started + LEASE_SAFETY_MS;
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
.catch((error) => {
|
|
131
|
+
if (active !== leased) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (error instanceof GatewayError && error.status === 409) {
|
|
135
|
+
leased.cancel.abort();
|
|
136
|
+
}
|
|
137
|
+
failService(error);
|
|
138
|
+
})
|
|
139
|
+
: Promise.resolve(),
|
|
140
|
+
]);
|
|
141
|
+
await delay(Math.max(1, HEARTBEAT_MS - (Date.now() - started)), undefined, { signal }).catch(() => {
|
|
142
|
+
/* Shutdown interrupts the timer. */
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
let heartbeats;
|
|
147
|
+
const watchdog = setInterval(() => {
|
|
148
|
+
if (active && Date.now() >= active.deadline) {
|
|
149
|
+
active.cancel.abort();
|
|
150
|
+
}
|
|
151
|
+
}, 1000);
|
|
152
|
+
try {
|
|
153
|
+
await heartbeat();
|
|
154
|
+
try {
|
|
155
|
+
push = true;
|
|
156
|
+
dispose = await gateway.subscribe(wake, () => {
|
|
157
|
+
push = false;
|
|
158
|
+
wake();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
push = false;
|
|
163
|
+
failService(error);
|
|
164
|
+
}
|
|
165
|
+
heartbeats = heartbeatLoop();
|
|
166
|
+
while (!signal.aborted) {
|
|
167
|
+
if (needsUpdate(version, minimum)) {
|
|
168
|
+
return await installGatewayUpdate(directory, minimum, signal);
|
|
169
|
+
}
|
|
170
|
+
wakePending = false;
|
|
171
|
+
let job;
|
|
172
|
+
const claimStartedAt = Date.now();
|
|
173
|
+
try {
|
|
174
|
+
job = await gateway.request("/agent-gateway/claim", { deviceId }, signal);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
failService(error);
|
|
178
|
+
job = null;
|
|
179
|
+
}
|
|
180
|
+
if (job) {
|
|
181
|
+
lastJobAt = Date.now();
|
|
182
|
+
const cancel = new AbortController();
|
|
183
|
+
active = { job, cancel, deadline: claimStartedAt + LEASE_SAFETY_MS };
|
|
184
|
+
let lastStatusAt = 0;
|
|
185
|
+
try {
|
|
186
|
+
const result = await runProvider({
|
|
187
|
+
config,
|
|
188
|
+
job,
|
|
189
|
+
workDir,
|
|
190
|
+
sessionId: await sessions.get(job.packet.thread.id),
|
|
191
|
+
signal: AbortSignal.any([signal, cancel.signal]),
|
|
192
|
+
remember: async (sessionId) => {
|
|
193
|
+
await sessions.remember(job.packet.thread.id, sessionId);
|
|
194
|
+
},
|
|
195
|
+
status: (line) => {
|
|
196
|
+
if (Date.now() - lastStatusAt < 1000) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
lastStatusAt = Date.now();
|
|
200
|
+
gateway
|
|
201
|
+
.request(`/agent-gateway/jobs/${job.jobId}/status`, { leaseToken: job.leaseToken, statusLine: line }, signal)
|
|
202
|
+
.catch(failService);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
// A transient delivery error retries this same completion. It must
|
|
206
|
+
// never become a new provider run or a duplicate final reply.
|
|
207
|
+
while (!(signal.aborted || cancel.signal.aborted)) {
|
|
208
|
+
try {
|
|
209
|
+
await gateway.request(`/agent-gateway/jobs/${job.jobId}/complete`, { leaseToken: job.leaseToken, text: result.text }, signal);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error instanceof GatewayError && error.status === 409) {
|
|
214
|
+
cancel.abort();
|
|
215
|
+
}
|
|
216
|
+
failService(error);
|
|
217
|
+
await delay(2000, undefined, { signal }).catch(() => {
|
|
218
|
+
/* Shutdown interrupts the timer. */
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
if (!(signal.aborted || cancel.signal.aborted)) {
|
|
225
|
+
await gateway
|
|
226
|
+
.request(`/agent-gateway/jobs/${job.jobId}/fail`, { leaseToken: job.leaseToken }, signal)
|
|
227
|
+
.catch(failService);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
active = undefined;
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (wakePending) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
await new Promise((resolve) => {
|
|
239
|
+
const timer = setTimeout(finish, push ? HEARTBEAT_MS : claimPollInterval(lastJobAt));
|
|
240
|
+
function finish() {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
signal.removeEventListener("abort", finish);
|
|
243
|
+
wakeResolve = undefined;
|
|
244
|
+
resolve();
|
|
245
|
+
}
|
|
246
|
+
wakeResolve = finish;
|
|
247
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
248
|
+
if (signal.aborted || wakePending) {
|
|
249
|
+
finish();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (terminalError) {
|
|
254
|
+
throw terminalError;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
service.abort();
|
|
260
|
+
active?.cancel.abort();
|
|
261
|
+
clearInterval(watchdog);
|
|
262
|
+
await heartbeats;
|
|
263
|
+
await dispose?.();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await release();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isMissing, writePrivateFile } from "../agent-config.js";
|
|
4
|
+
// One file per agent already supplies the agentId half of the session key.
|
|
5
|
+
export class SessionMap {
|
|
6
|
+
path;
|
|
7
|
+
constructor(directory) {
|
|
8
|
+
this.path = join(directory, "sessions.json");
|
|
9
|
+
}
|
|
10
|
+
async get(threadId) {
|
|
11
|
+
return (await this.read())[threadId];
|
|
12
|
+
}
|
|
13
|
+
async remember(threadId, sessionId) {
|
|
14
|
+
const sessions = await this.read();
|
|
15
|
+
sessions[threadId] = sessionId;
|
|
16
|
+
await writePrivateFile(this.path, `${JSON.stringify(sessions)}\n`);
|
|
17
|
+
}
|
|
18
|
+
async read() {
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(await readFile(this.path, "utf8"));
|
|
21
|
+
if (!value ||
|
|
22
|
+
typeof value !== "object" ||
|
|
23
|
+
Array.isArray(value) ||
|
|
24
|
+
Object.values(value).some((session) => typeof session !== "string")) {
|
|
25
|
+
throw new Error("Agent sessions are invalid. Restore sessions.json before serving.");
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (isMissing(error)) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { redactAgentOutput } from "./agent-link.js";
|
|
5
|
+
import { warn } from "./format.js";
|
|
6
|
+
import { homeDir } from "./install.js";
|
|
7
|
+
const AGENT_ID = /^[a-z\d_-]+$/iu;
|
|
8
|
+
export function agentsDir(home = homeDir()) {
|
|
9
|
+
return join(home, ".talentos", "agents");
|
|
10
|
+
}
|
|
11
|
+
export function agentConfigPath(agentId, home = homeDir()) {
|
|
12
|
+
if (!AGENT_ID.test(agentId)) {
|
|
13
|
+
throw new Error("Invalid agent ID.");
|
|
14
|
+
}
|
|
15
|
+
return join(agentsDir(home), `${agentId}.json`);
|
|
16
|
+
}
|
|
17
|
+
export function agentWorkDir(agentId, home = homeDir()) {
|
|
18
|
+
agentConfigPath(agentId, home);
|
|
19
|
+
return join(agentsDir(home), agentId, "work");
|
|
20
|
+
}
|
|
21
|
+
export function agentLogPath(agentId, home = homeDir()) {
|
|
22
|
+
agentConfigPath(agentId, home);
|
|
23
|
+
return join(agentsDir(home), agentId, "gateway.log");
|
|
24
|
+
}
|
|
25
|
+
export async function writePrivateFile(path, content) {
|
|
26
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
27
|
+
try {
|
|
28
|
+
if ((await readFile(path, "utf8")) === content) {
|
|
29
|
+
await chmod(path, 0o600);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!isMissing(error)) {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
await writeFile(temporary, content, { mode: 0o600, flag: "wx" });
|
|
41
|
+
await rename(temporary, path);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await rm(temporary, { force: true });
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
export async function storeAgent(config, home = homeDir()) {
|
|
49
|
+
await mkdir(agentsDir(home), { recursive: true, mode: 0o700 });
|
|
50
|
+
await chmod(agentsDir(home), 0o700);
|
|
51
|
+
await writePrivateFile(agentConfigPath(config.agentId, home), `${JSON.stringify(config, null, 2)}\n`);
|
|
52
|
+
await mkdir(agentWorkDir(config.agentId, home), {
|
|
53
|
+
recursive: true,
|
|
54
|
+
mode: 0o700,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function localAgents(home = homeDir()) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(agentsDir(home));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (isMissing(error)) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
const agents = [];
|
|
69
|
+
for (const entry of entries.sort()) {
|
|
70
|
+
if (!entry.startsWith(".") && entry.endsWith(".json")) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(await readFile(join(agentsDir(home), entry), "utf8"));
|
|
73
|
+
if (!isLinkedAgent(parsed) || entry !== `${parsed.agentId}.json`) {
|
|
74
|
+
throw new Error("Invalid agent configuration.");
|
|
75
|
+
}
|
|
76
|
+
agents.push(parsed);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
warn(`Skipping unreadable or invalid agent configuration ${redactAgentOutput(JSON.stringify(entry))}. Restore it to manage that agent.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return agents;
|
|
84
|
+
}
|
|
85
|
+
function isLinkedAgent(value) {
|
|
86
|
+
if (!value || typeof value !== "object") {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const config = value;
|
|
90
|
+
return (typeof config.agentId === "string" &&
|
|
91
|
+
AGENT_ID.test(config.agentId) &&
|
|
92
|
+
typeof config.apiKey === "string" &&
|
|
93
|
+
config.apiKey.startsWith("tos_") &&
|
|
94
|
+
typeof config.apiUrl === "string" &&
|
|
95
|
+
typeof config.name === "string" &&
|
|
96
|
+
typeof config.linkHash === "string" &&
|
|
97
|
+
typeof config.linkedAt === "number" &&
|
|
98
|
+
(config.provider === "claude_code" || config.provider === "codex"));
|
|
99
|
+
}
|
|
100
|
+
export function isMissing(error) {
|
|
101
|
+
return (error !== null &&
|
|
102
|
+
typeof error === "object" &&
|
|
103
|
+
"code" in error &&
|
|
104
|
+
error.code === "ENOENT");
|
|
105
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { isSecureUrl, } from "./agent-link.js";
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
4
|
+
const HEARTBEAT_WINDOW_MS = 60_000;
|
|
5
|
+
export class AgentHttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
retryAfterMs;
|
|
8
|
+
constructor(operation, response) {
|
|
9
|
+
super(`${operation} (HTTP ${response.status}). Ask for a fresh setup prompt if this link expired.`);
|
|
10
|
+
this.status = response.status;
|
|
11
|
+
const seconds = Number(response.headers.get("Retry-After"));
|
|
12
|
+
this.retryAfterMs = Number.isFinite(seconds)
|
|
13
|
+
? Math.max(0, Math.min(60_000, seconds * 1000))
|
|
14
|
+
: 0;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function apiOrigin(value) {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(value);
|
|
20
|
+
if (!(isSecureUrl(url) && url.pathname === "/" && !url.search && !url.hash)) {
|
|
21
|
+
throw new Error("Invalid origin");
|
|
22
|
+
}
|
|
23
|
+
return url.origin;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error("Use a secure TalentOS API origin with --api-url.");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function setupApiOrigin(link, override) {
|
|
30
|
+
if (override) {
|
|
31
|
+
return apiOrigin(override);
|
|
32
|
+
}
|
|
33
|
+
const response = await safeFetch(link.setupUrl);
|
|
34
|
+
const apiUrl = response.headers.get("X-TalentOS-Api-Url");
|
|
35
|
+
if (!(response.ok && apiUrl)) {
|
|
36
|
+
throw new Error("The setup page is unavailable. Ask for a fresh prompt or supply its API origin with --api-url.");
|
|
37
|
+
}
|
|
38
|
+
return apiOrigin(apiUrl);
|
|
39
|
+
}
|
|
40
|
+
export function inspectProvider(provider) {
|
|
41
|
+
const binary = provider === "claude_code" ? "claude" : "codex";
|
|
42
|
+
const version = spawnSync(binary, ["--version"], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
45
|
+
});
|
|
46
|
+
if (version.status !== 0 || !version.stdout?.trim()) {
|
|
47
|
+
throw new Error(`Install the official ${binary} CLI and sign in before linking.`);
|
|
48
|
+
}
|
|
49
|
+
const args = provider === "claude_code"
|
|
50
|
+
? ["auth", "status", "--json"]
|
|
51
|
+
: ["login", "status"];
|
|
52
|
+
const login = spawnSync(binary, args, {
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
55
|
+
});
|
|
56
|
+
if (login.status !== 0) {
|
|
57
|
+
throw new Error(`Sign in through the official ${binary} CLI before linking.`);
|
|
58
|
+
}
|
|
59
|
+
if (provider === "claude_code") {
|
|
60
|
+
let loggedIn = false;
|
|
61
|
+
try {
|
|
62
|
+
const status = JSON.parse(login.stdout);
|
|
63
|
+
loggedIn =
|
|
64
|
+
!!status &&
|
|
65
|
+
typeof status === "object" &&
|
|
66
|
+
"loggedIn" in status &&
|
|
67
|
+
status.loggedIn === true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
loggedIn = false;
|
|
71
|
+
}
|
|
72
|
+
if (!loggedIn) {
|
|
73
|
+
throw new Error("Sign in through the official claude CLI before linking.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return version.stdout.trim().split("\n")[0]?.slice(0, 200) ?? "unknown";
|
|
77
|
+
}
|
|
78
|
+
export async function redeemAgent(apiUrl, body) {
|
|
79
|
+
const response = await safeFetch(`${apiOrigin(apiUrl)}/agent-link/redeem`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new AgentHttpError("Could not redeem this link", response);
|
|
86
|
+
}
|
|
87
|
+
const value = await readJson(response);
|
|
88
|
+
if (!value ||
|
|
89
|
+
typeof value !== "object" ||
|
|
90
|
+
!("agentId" in value && "name" in value) ||
|
|
91
|
+
typeof value.agentId !== "string" ||
|
|
92
|
+
typeof value.name !== "string") {
|
|
93
|
+
throw new Error("TalentOS returned an invalid agent link. Ask for a fresh prompt.");
|
|
94
|
+
}
|
|
95
|
+
return { agentId: value.agentId, name: value.name };
|
|
96
|
+
}
|
|
97
|
+
export async function fetchAgentStatus(agent) {
|
|
98
|
+
const response = await safeFetch(`${apiOrigin(agent.apiUrl)}/agent-link/status`, { headers: { Authorization: `Bearer ${agent.apiKey}` } });
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new AgentHttpError("Could not check the agent", response);
|
|
101
|
+
}
|
|
102
|
+
const value = await readJson(response);
|
|
103
|
+
if (!value || typeof value !== "object") {
|
|
104
|
+
throw new Error("TalentOS returned an invalid agent status.");
|
|
105
|
+
}
|
|
106
|
+
const status = value;
|
|
107
|
+
if (typeof status.agentId !== "string" ||
|
|
108
|
+
(agent.agentId !== undefined && status.agentId !== agent.agentId) ||
|
|
109
|
+
status.provider !== agent.provider ||
|
|
110
|
+
typeof status.name !== "string" ||
|
|
111
|
+
!Number.isFinite(status.serverNow) ||
|
|
112
|
+
!(status.lastSeenAt === null || typeof status.lastSeenAt === "number") ||
|
|
113
|
+
!["online", "busy", "offline"].includes(String(status.presence))) {
|
|
114
|
+
throw new Error("TalentOS returned an invalid agent status.");
|
|
115
|
+
}
|
|
116
|
+
return status;
|
|
117
|
+
}
|
|
118
|
+
export async function unlinkRemoteAgent(agent) {
|
|
119
|
+
const response = await safeFetch(`${apiOrigin(agent.apiUrl)}/agent-link/unlink`, { method: "POST", headers: { Authorization: `Bearer ${agent.apiKey}` } });
|
|
120
|
+
if (response.status === 401) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new AgentHttpError("Could not disconnect the agent", response);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
export async function waitForHeartbeat(agent, options = {}) {
|
|
129
|
+
const deadline = Date.now() + (options.timeoutMs ?? 10 * 60_000);
|
|
130
|
+
let serverStartedAt;
|
|
131
|
+
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
132
|
+
while (Date.now() < deadline) {
|
|
133
|
+
let delay = 10_000;
|
|
134
|
+
try {
|
|
135
|
+
const status = await fetchAgentStatus(agent);
|
|
136
|
+
serverStartedAt ??= status.serverNow;
|
|
137
|
+
if (status.lastSeenAt !== null &&
|
|
138
|
+
status.lastSeenAt > serverStartedAt &&
|
|
139
|
+
status.lastSeenAt <= status.serverNow &&
|
|
140
|
+
status.serverNow - status.lastSeenAt < HEARTBEAT_WINDOW_MS &&
|
|
141
|
+
status.presence !== "offline") {
|
|
142
|
+
return status;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error instanceof AgentHttpError && error.status === 401) {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
if (error instanceof AgentHttpError) {
|
|
150
|
+
delay = Math.max(delay, error.retryAfterMs);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await wait(Math.max(0, Math.min(delay, deadline - Date.now())));
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
async function safeFetch(url, init = {}) {
|
|
158
|
+
try {
|
|
159
|
+
return await fetch(url, {
|
|
160
|
+
...init,
|
|
161
|
+
redirect: "error",
|
|
162
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Error("Could not reach TalentOS. Check the network and try again.");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function readJson(response) {
|
|
170
|
+
try {
|
|
171
|
+
return await response.json();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error("TalentOS returned an invalid response.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const INVALID_LINK = "Get a new setup prompt from TalentOS and pass its complete URL with --url.";
|
|
2
|
+
const SETUP_PATH = /^\/agent-setup\/(claude_code|codex)\/?$/;
|
|
3
|
+
const FRAGMENT_TOKEN = /((?:#|%23)token(?:=|%3d))[^\s"'<>]+/giu;
|
|
4
|
+
const RAW_KEY = /\btos_[a-z\d_-]+/giu;
|
|
5
|
+
const BEARER = /\bBearer\s+\S+/giu;
|
|
6
|
+
const TOKEN_WHITESPACE = /\s/u;
|
|
7
|
+
export function parseAgentLink(value) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
const provider = SETUP_PATH.exec(url.pathname)?.[1];
|
|
11
|
+
const fragment = new URLSearchParams(url.hash.slice(1));
|
|
12
|
+
const tokens = fragment.getAll("token");
|
|
13
|
+
const token = tokens[0];
|
|
14
|
+
if (!(isSecureUrl(url) &&
|
|
15
|
+
(provider === "claude_code" || provider === "codex") &&
|
|
16
|
+
!url.search &&
|
|
17
|
+
fragment.size === 1 &&
|
|
18
|
+
tokens.length === 1 &&
|
|
19
|
+
token &&
|
|
20
|
+
token.length <= 200 &&
|
|
21
|
+
!TOKEN_WHITESPACE.test(token))) {
|
|
22
|
+
throw new Error(INVALID_LINK);
|
|
23
|
+
}
|
|
24
|
+
url.hash = "";
|
|
25
|
+
return { provider, setupUrl: url.href, token };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(INVALID_LINK);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function isSecureUrl(url) {
|
|
32
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
33
|
+
return (!(url.username || url.password) &&
|
|
34
|
+
(url.protocol === "https:" || (url.protocol === "http:" && loopback)));
|
|
35
|
+
}
|
|
36
|
+
export function redactAgentOutput(value, secrets = []) {
|
|
37
|
+
let safe = value;
|
|
38
|
+
for (const secret of secrets) {
|
|
39
|
+
if (secret) {
|
|
40
|
+
safe = safe
|
|
41
|
+
.replaceAll(secret, "[redacted]")
|
|
42
|
+
.replaceAll(encodeURIComponent(secret), "[redacted]");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return safe
|
|
46
|
+
.replace(FRAGMENT_TOKEN, "$1[redacted]")
|
|
47
|
+
.replace(RAW_KEY, "[redacted key]")
|
|
48
|
+
.replace(BEARER, "Bearer [redacted]");
|
|
49
|
+
}
|