talentos 1.1.0 → 1.1.1

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.
@@ -1,4 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { rm } from "node:fs/promises";
3
4
  import { dirname, join } from "node:path";
4
5
  import { createInterface } from "node:readline";
@@ -6,6 +7,7 @@ import { fileURLToPath } from "node:url";
6
7
  import { promisify } from "node:util";
7
8
  import { writePrivateFile } from "../agent-config.js";
8
9
  import { redactAgentOutput, } from "../agent-link.js";
10
+ import { gatewayLauncherPath } from "../gateway-install.js";
9
11
  const CHILD_ENV = [
10
12
  "HOME",
11
13
  "PATH",
@@ -23,8 +25,12 @@ const CHILD_ENV = [
23
25
  "CLAUDE_CONFIG_DIR",
24
26
  ];
25
27
  const CLAUDE_VERSION = /^\d+\.\d+\.\d+/u;
28
+ const CODEX_VERSION = /\b\d+\.\d+\.\d+\b/u;
26
29
  const DONT_ASK = /\bdontAsk\b/u;
27
- const CLAUDE_TOOLS = [
30
+ // Kept package-local for the published CLI. The launch tests compare both
31
+ // adapters against the server's exported ROOM_AGENT_TOOLS contract.
32
+ const ROOM_TOOLS = [
33
+ "list_rooms",
28
34
  "read_room",
29
35
  "list_room_files",
30
36
  "read_file",
@@ -33,9 +39,9 @@ const CLAUDE_TOOLS = [
33
39
  "create_deck",
34
40
  "post_message",
35
41
  "share_link",
36
- ]
37
- .map((tool) => `mcp__talentos__${tool}`)
38
- .join(",");
42
+ "whoami",
43
+ ];
44
+ const CLAUDE_TOOLS = ROOM_TOOLS.map((tool) => `mcp__talentos__${tool}`).join(",");
39
45
  export function childEnvironment(source = process.env) {
40
46
  return Object.fromEntries(CHILD_ENV.filter((key) => source[key] !== undefined).map((key) => [
41
47
  key,
@@ -63,6 +69,77 @@ export async function verifyClaudeCode() {
63
69
  }
64
70
  throw new Error(`Update Claude Code (${version}) to a version that supports --permission-mode dontAsk.`);
65
71
  }
72
+ export async function verifyCodex() {
73
+ const execute = promisify(execFile);
74
+ const options = {
75
+ env: childEnvironment(),
76
+ timeout: 5000,
77
+ maxBuffer: 256_000,
78
+ };
79
+ let version = "unknown";
80
+ try {
81
+ const detected = await execute("codex", ["--version"], options);
82
+ version = CODEX_VERSION.exec(detected.stdout)?.[0] ?? "unknown";
83
+ for (const args of [
84
+ ["exec", "--help"],
85
+ ["exec", "resume", "--help"],
86
+ ]) {
87
+ const help = await execute("codex", args, options);
88
+ if (![
89
+ "--json",
90
+ "--skip-git-repo-check",
91
+ "--ignore-user-config",
92
+ "--ignore-rules",
93
+ ].every((flag) => help.stdout.includes(flag))) {
94
+ throw new Error("Unsupported Codex flags");
95
+ }
96
+ }
97
+ if (version !== "unknown") {
98
+ await codexRoomCatalog();
99
+ return;
100
+ }
101
+ }
102
+ catch {
103
+ // Provider diagnostics can contain login details.
104
+ }
105
+ throw new Error(`Update Codex (${version}) to a version supporting isolated room turns, exec/resume flags and a local model catalog.`);
106
+ }
107
+ async function codexRoomCatalog() {
108
+ try {
109
+ const { stdout } = await promisify(execFile)("codex", ["debug", "models", "--bundled"], {
110
+ env: childEnvironment(),
111
+ timeout: 5000,
112
+ maxBuffer: 4_000_000,
113
+ });
114
+ const catalog = JSON.parse(stdout);
115
+ if (!Array.isArray(catalog.models) || catalog.models.length === 0) {
116
+ throw new Error("Missing model catalog");
117
+ }
118
+ const models = catalog.models.map((model) => {
119
+ if (!model ||
120
+ typeof model !== "object" ||
121
+ !("slug" in model) ||
122
+ !("apply_patch_tool_type" in model)) {
123
+ throw new Error("Unsupported model catalog");
124
+ }
125
+ return {
126
+ ...model,
127
+ // apply_patch has no feature toggle: its exposure comes from model
128
+ // metadata. A local catalog removes it before tool registration.
129
+ apply_patch_tool_type: null,
130
+ shell_type: "disabled",
131
+ experimental_supported_tools: [],
132
+ // Code-mode-only models route MCP through native execution. Expose
133
+ // allowed MCP functions directly while native execution stays off.
134
+ tool_mode: "direct",
135
+ };
136
+ });
137
+ return JSON.stringify({ ...catalog, models });
138
+ }
139
+ catch {
140
+ throw new Error("Update Codex to a version supporting a local room-only model catalog.");
141
+ }
142
+ }
66
143
  export function readProviderEvent(provider, value) {
67
144
  if (!value || typeof value !== "object") {
68
145
  return {};
@@ -110,6 +187,7 @@ export function readProviderEvent(provider, value) {
110
187
  export async function runProvider(input) {
111
188
  const { config, job, workDir, sessionId, signal } = input;
112
189
  const prompt = JSON.stringify(job.packet), mcpPath = join(dirname(workDir), ".talentos-mcp.json");
190
+ const catalogPath = join(dirname(workDir), ".talentos-codex-models.json");
113
191
  let command, args;
114
192
  if (config.provider === "claude_code") {
115
193
  await writePrivateFile(mcpPath, JSON.stringify({
@@ -148,12 +226,14 @@ export async function runProvider(input) {
148
226
  }
149
227
  else {
150
228
  command = "codex";
151
- const entry = fileURLToPath(new URL("./mcp-entry.js", import.meta.url));
229
+ const launcher = gatewayLauncherPath();
230
+ const useLauncher = process.platform !== "win32" && existsSync(launcher);
231
+ await writePrivateFile(catalogPath, await codexRoomCatalog());
152
232
  // Resume has a different flag surface. Root overrides are inherited by
153
233
  // both exec paths; shell_tool=false enforces ADR 0093's default no-Bash.
154
234
  args = [
155
235
  "-c",
156
- 'sandbox_mode="workspace-write"',
236
+ 'sandbox_mode="read-only"',
157
237
  "-c",
158
238
  'approval_policy="never"',
159
239
  "-c",
@@ -161,9 +241,31 @@ export async function runProvider(input) {
161
241
  "-c",
162
242
  "features.unified_exec=false",
163
243
  "-c",
164
- `mcp_servers.talentos.command=${JSON.stringify(process.execPath)}`,
244
+ 'web_search="disabled"',
245
+ "-c",
246
+ "features.view_image=false",
247
+ "-c",
248
+ "features.image_generation=false",
249
+ "-c",
250
+ "features.apps=false",
251
+ "-c",
252
+ "features.plugins=false",
253
+ "-c",
254
+ "features.multi_agent=false",
255
+ "-c",
256
+ "features.skip_host_skill_discovery=true",
257
+ "-c",
258
+ "memories.use_memories=false",
259
+ "-c",
260
+ "memories.generate_memories=false",
261
+ "-c",
262
+ `model_catalog_json=${JSON.stringify(catalogPath)}`,
263
+ "-c",
264
+ `mcp_servers.talentos.enabled_tools=${JSON.stringify(ROOM_TOOLS)}`,
265
+ "-c",
266
+ `mcp_servers.talentos.command=${JSON.stringify(useLauncher ? launcher : process.execPath)}`,
165
267
  "-c",
166
- `mcp_servers.talentos.args=${JSON.stringify([entry, config.agentId])}`,
268
+ `mcp_servers.talentos.args=${JSON.stringify([...(useLauncher ? [] : [fileURLToPath(new URL("../index.js", import.meta.url))]), "agent", "mcp", "--agent", config.agentId])}`,
167
269
  "exec",
168
270
  ];
169
271
  if (sessionId) {
@@ -256,5 +358,6 @@ export async function runProvider(input) {
256
358
  clearTimeout(killTimer);
257
359
  }
258
360
  await rm(mcpPath, { force: true });
361
+ await rm(catalogPath, { force: true });
259
362
  }
260
363
  }
@@ -1,9 +1,10 @@
1
1
  import { localAgents } from "../agent-config.js";
2
+ import { parseArgv } from "../args.js";
2
3
  import { AgentClient } from "../client.js";
3
4
  import { startMcpProxy } from "../mcp-proxy.js";
4
5
  // A local stdio entry avoids putting the bearer key in Codex's argv or child
5
6
  // environment. Only the MCP proxy reads the linked-agent credential file.
6
- const config = (await localAgents()).find((agent) => agent.agentId === process.argv[2]);
7
+ const config = (await localAgents()).find((agent) => agent.agentId === parseArgv(process.argv.slice(2)).options.agent);
7
8
  if (!config) {
8
9
  throw new Error("Linked agent configuration is unavailable.");
9
10
  }
@@ -1,29 +1,20 @@
1
- import { spawn } from "node:child_process";
2
1
  import { randomUUID } from "node:crypto";
3
2
  import { mkdir, open, readFile, rm } from "node:fs/promises";
4
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
5
4
  import { setTimeout as delay } from "node:timers/promises";
6
- import { childEnvironment, runProvider } from "./adapters.js";
5
+ import { installSharedGateway } from "../gateway-install.js";
6
+ import { compareGatewayVersions } from "../gateway-version.js";
7
+ import { runProvider } from "./adapters.js";
7
8
  import { AgentGateway, GatewayError } from "./gateway.js";
8
9
  import { SessionMap } from "./sessions.js";
9
10
  const HEARTBEAT_MS = 20_000;
10
11
  const LEASE_SAFETY_MS = 55_000;
11
12
  const IDLE_MS = 30 * 60_000;
12
- const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
13
13
  export function needsUpdate(current, minimum) {
14
- const a = VERSION.exec(current), b = VERSION.exec(minimum);
15
- if (!b) {
14
+ if (compareGatewayVersions(minimum, minimum) === undefined) {
16
15
  return false;
17
16
  }
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;
17
+ return (compareGatewayVersions(current, minimum) ?? -1) < 0;
27
18
  }
28
19
  export function claimPollInterval(lastJobAt, now = Date.now()) {
29
20
  return now - lastJobAt >= IDLE_MS ? 10_000 : 2000;
@@ -58,27 +49,11 @@ async function acquireLock(directory) {
58
49
  };
59
50
  }
60
51
  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.")));
52
+ return await installSharedGateway({
53
+ home: dirname(dirname(dirname(directory))),
54
+ version: minimum,
55
+ signal,
80
56
  });
81
- return join(destination, "node_modules", "talentos", "dist", "index.js");
82
57
  }
83
58
  export async function serveAgent(config, directory, version, stop) {
84
59
  const release = await acquireLock(directory);
@@ -144,6 +119,7 @@ export async function serveAgent(config, directory, version, stop) {
144
119
  }
145
120
  };
146
121
  let heartbeats;
122
+ let updateAfter = 0;
147
123
  const watchdog = setInterval(() => {
148
124
  if (active && Date.now() >= active.deadline) {
149
125
  active.cancel.abort();
@@ -164,8 +140,17 @@ export async function serveAgent(config, directory, version, stop) {
164
140
  }
165
141
  heartbeats = heartbeatLoop();
166
142
  while (!signal.aborted) {
167
- if (needsUpdate(version, minimum)) {
168
- return await installGatewayUpdate(directory, minimum, signal);
143
+ if (needsUpdate(version, minimum) && Date.now() >= updateAfter) {
144
+ if (process.platform === "win32") {
145
+ throw new Error(`Gateway update required; re-run npx talentos@${minimum} agent serve --agent ${config.agentId}. Windows gateways update in the foreground.`);
146
+ }
147
+ try {
148
+ return await installGatewayUpdate(directory, minimum, signal);
149
+ }
150
+ catch {
151
+ updateAfter = Date.now() + 60_000;
152
+ console.error("Gateway update unavailable. Serving the current version; retrying in one minute.");
153
+ }
169
154
  }
170
155
  wakePending = false;
171
156
  let job;
@@ -22,11 +22,11 @@ export function agentLogPath(agentId, home = homeDir()) {
22
22
  agentConfigPath(agentId, home);
23
23
  return join(agentsDir(home), agentId, "gateway.log");
24
24
  }
25
- export async function writePrivateFile(path, content) {
25
+ export async function writePrivateFile(path, content, mode = 0o600) {
26
26
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
27
27
  try {
28
28
  if ((await readFile(path, "utf8")) === content) {
29
- await chmod(path, 0o600);
29
+ await chmod(path, mode);
30
30
  return false;
31
31
  }
32
32
  }
@@ -37,7 +37,7 @@ export async function writePrivateFile(path, content) {
37
37
  }
38
38
  const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
39
39
  try {
40
- await writeFile(temporary, content, { mode: 0o600, flag: "wx" });
40
+ await writeFile(temporary, content, { mode, flag: "wx" });
41
41
  await rename(temporary, path);
42
42
  }
43
43
  finally {
@@ -92,7 +92,13 @@ export async function redeemAgent(apiUrl, body) {
92
92
  typeof value.name !== "string") {
93
93
  throw new Error("TalentOS returned an invalid agent link. Ask for a fresh prompt.");
94
94
  }
95
- return { agentId: value.agentId, name: value.name };
95
+ const retiredAgentIds = "retiredAgentIds" in value ? value.retiredAgentIds : undefined;
96
+ if (retiredAgentIds !== undefined &&
97
+ (!Array.isArray(retiredAgentIds) ||
98
+ retiredAgentIds.some((id) => typeof id !== "string"))) {
99
+ throw new Error("TalentOS returned an invalid agent link. Ask for a fresh prompt.");
100
+ }
101
+ return { agentId: value.agentId, name: value.name, retiredAgentIds };
96
102
  }
97
103
  export async function fetchAgentStatus(agent) {
98
104
  const response = await safeFetch(`${apiOrigin(agent.apiUrl)}/agent-link/status`, { headers: { Authorization: `Bearer ${agent.apiKey}` } });
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { chmod, mkdir, open, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
  import { agentLogPath, agentWorkDir, writePrivateFile, } from "./agent-config.js";
5
+ import { gatewayLauncherPath, servicePathEnv } from "./gateway-install.js";
6
6
  import { homeDir } from "./install.js";
7
7
  export function runCommand(command, args) {
8
8
  return (spawnSync(command, args, { stdio: "ignore", timeout: 15_000 }).status ?? 1);
@@ -25,8 +25,7 @@ export async function installAgentService(agent, options = {}) {
25
25
  return { path: "", started: false };
26
26
  }
27
27
  const args = [
28
- options.nodePath ?? process.execPath,
29
- options.cliPath ?? fileURLToPath(new URL("./index.js", import.meta.url)),
28
+ gatewayLauncherPath(home),
30
29
  "agent",
31
30
  "serve",
32
31
  "--agent",
@@ -34,9 +33,15 @@ export async function installAgentService(agent, options = {}) {
34
33
  ];
35
34
  const workDir = agentWorkDir(agent.agentId, home);
36
35
  const content = platform === "darwin"
37
- ? launchdUnit(name, args, workDir, logPath)
38
- : systemdUnit(args, workDir, logPath);
36
+ ? launchdUnit(name, args, workDir, logPath, servicePathEnv(home))
37
+ : systemdUnit(args, workDir, logPath, servicePathEnv(home));
39
38
  const changed = await writePrivateFile(path, content);
39
+ if (options.reload === false) {
40
+ if (platform === "linux" && changed) {
41
+ run("systemctl", ["--user", "daemon-reload"]);
42
+ }
43
+ return { path, started: true };
44
+ }
40
45
  if (platform === "darwin") {
41
46
  const domain = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
42
47
  const loaded = run("launchctl", ["print", `${domain}/${name}`]) === 0;
@@ -103,7 +108,7 @@ function servicePath(agentId, platform, home) {
103
108
  ? join(home, ".config", "systemd", "user", `talentos-agent@${agentId}.service`)
104
109
  : "";
105
110
  }
106
- function launchdUnit(name, args, workDir, logPath) {
111
+ function launchdUnit(name, args, workDir, logPath, pathEnv) {
107
112
  const xml = (value) => value
108
113
  .replaceAll("&", "&amp;")
109
114
  .replaceAll("<", "&lt;")
@@ -116,14 +121,15 @@ function launchdUnit(name, args, workDir, logPath) {
116
121
  <key>Label</key><string>${xml(name)}</string>
117
122
  <key>ProgramArguments</key><array>${args.map((arg) => `<string>${xml(arg)}</string>`).join("")}</array>
118
123
  <key>WorkingDirectory</key><string>${xml(workDir)}</string>
119
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin")}</string></dict>
124
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(pathEnv)}</string></dict>
125
+ <key>ThrottleInterval</key><integer>60</integer>
120
126
  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
121
127
  <key>StandardOutPath</key><string>${xml(logPath)}</string>
122
128
  <key>StandardErrorPath</key><string>${xml(logPath)}</string>
123
129
  </dict></plist>
124
130
  `;
125
131
  }
126
- function systemdUnit(args, workDir, logPath) {
132
+ function systemdUnit(args, workDir, logPath, pathEnv) {
127
133
  const quote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("\n", "\\n").replaceAll("\r", "\\r")}"`;
128
134
  return `[Unit]
129
135
  Description=TalentOS agent gateway
@@ -133,9 +139,10 @@ After=network-online.target
133
139
  Type=simple
134
140
  ExecStart=${args.map(quote).join(" ")}
135
141
  WorkingDirectory=${quote(workDir)}
136
- Environment=${quote(`PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}`)}
142
+ Environment=${quote(`PATH=${pathEnv}`)}
137
143
  Restart=always
138
- RestartSec=5
144
+ RestartSec=60
145
+ RestartPreventExitStatus=78
139
146
  StandardOutput=${quote(`append:${logPath}`)}
140
147
  StandardError=${quote(`append:${logPath}`)}
141
148
 
@@ -1,9 +1,10 @@
1
- import { spawn } from "node:child_process";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { dirname } from "node:path";
4
- import { childEnvironment, verifyClaudeCode } from "../agent/adapters.js";
3
+ import { verifyClaudeCode, verifyCodex } from "../agent/adapters.js";
5
4
  import { serveAgent } from "../agent/serve.js";
6
5
  import { agentWorkDir, localAgents } from "../agent-config.js";
6
+ import { installAgentService } from "../agent-service.js";
7
+ import { installSharedGateway } from "../gateway-install.js";
7
8
  export async function runAgentServe(parsed) {
8
9
  const id = parsed.options.agent;
9
10
  const agents = await localAgents();
@@ -14,26 +15,28 @@ export async function runAgentServe(parsed) {
14
15
  if (!config) {
15
16
  throw new Error("Choose a linked agent with `talentos agent serve --agent <id>`.");
16
17
  }
18
+ try {
19
+ if (process.platform !== "win32") {
20
+ await installSharedGateway();
21
+ }
22
+ await installAgentService(config, { reload: false });
23
+ }
24
+ catch {
25
+ console.error("Gateway installation unavailable. Serving the loaded version; retry talentos agent repair when the installation is available.");
26
+ }
17
27
  if (config.provider === "claude_code") {
18
28
  await verifyClaudeCode();
19
29
  }
30
+ else {
31
+ await verifyCodex();
32
+ }
20
33
  const stop = new AbortController();
21
34
  const abort = () => stop.abort();
22
35
  process.once("SIGTERM", abort);
23
36
  process.once("SIGINT", abort);
24
37
  const version = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
25
38
  try {
26
- const updated = await serveAgent(config, dirname(agentWorkDir(config.agentId)), version, stop.signal);
27
- if (updated && !stop.signal.aborted) {
28
- await new Promise((resolve, reject) => {
29
- const child = spawn(process.execPath, [updated, "agent", "serve", "--agent", config.agentId], { env: childEnvironment(), stdio: "inherit", signal: stop.signal });
30
- child.once("error", () => reject(new Error("Updated gateway could not start.")));
31
- child.once("close", (code) => {
32
- process.exitCode = code ?? 1;
33
- resolve();
34
- });
35
- });
36
- }
39
+ await serveAgent(config, dirname(agentWorkDir(config.agentId)), version, stop.signal);
37
40
  }
38
41
  finally {
39
42
  process.removeListener("SIGTERM", abort);
@@ -7,9 +7,11 @@ import { AgentHttpError, fetchAgentStatus, inspectProvider, redeemAgent, setupAp
7
7
  import { parseAgentLink, redactAgentOutput } from "../agent-link.js";
8
8
  import { installAgentService, uninstallAgentService, } from "../agent-service.js";
9
9
  import { emit, emitJson, renderTable, warn } from "../format.js";
10
+ import { installSharedGateway } from "../gateway-install.js";
10
11
  import { homeDir } from "../install.js";
11
12
  import { defaultDeviceName, hashKey, mintKey } from "../login.js";
12
- export async function runAgent(parsed) {
13
+ const MAX_PREVIOUS_AGENT_IDS = 100;
14
+ export async function runAgent(parsed, dependencies = {}) {
13
15
  switch (parsed.action) {
14
16
  case "link": {
15
17
  if (!(parsed.options.yes || (await approveInstall()))) {
@@ -38,6 +40,29 @@ export async function runAgent(parsed) {
38
40
  }
39
41
  return await command.runAgentServe(parsed);
40
42
  }
43
+ case "repair": {
44
+ if (process.platform === "win32") {
45
+ throw new Error("Windows agents run in the foreground. Use talentos agent serve.");
46
+ }
47
+ const agents = await selectAgents(parsed, false, dependencies.home);
48
+ if (!agents.length) {
49
+ throw new Error("No linked agent matches. Run talentos agent list.");
50
+ }
51
+ await installSharedGateway({
52
+ home: dependencies.home,
53
+ source: dependencies.gatewaySource,
54
+ });
55
+ for (const agent of agents) {
56
+ const service = await (dependencies.install ??
57
+ ((config) => installAgentService(config, { home: dependencies.home })))(agent);
58
+ emit(`Repaired ${redactAgentOutput(agent.name)} (${agent.agentId}).${service.started ? "" : " Start talentos agent serve in the foreground."}`);
59
+ }
60
+ return;
61
+ }
62
+ case "mcp": {
63
+ await import("../agent/mcp-entry.js");
64
+ return;
65
+ }
41
66
  case "status":
42
67
  case "list":
43
68
  return await showAgents(parsed);
@@ -46,7 +71,7 @@ export async function runAgent(parsed) {
46
71
  case "logs":
47
72
  return await showLogs(parsed);
48
73
  default:
49
- throw new Error("Use talentos agent link | status | list | unlink | logs.");
74
+ throw new Error("Use talentos agent link | repair | status | list | unlink | logs.");
50
75
  }
51
76
  }
52
77
  export async function installLinkedAgent(parsed, dependencies = {}) {
@@ -56,12 +81,17 @@ export async function installLinkedAgent(parsed, dependencies = {}) {
56
81
  const link = parseAgentLink(parsed.options.url ?? "");
57
82
  const home = dependencies.home ?? homeDir();
58
83
  const linkHash = hashKey(link.token);
59
- let agent = (await localAgents(home)).find((item) => item.linkHash === linkHash && item.provider === link.provider);
84
+ const savedAgents = await localAgents(home);
85
+ let agent = savedAgents.find((item) => item.linkHash === linkHash && item.provider === link.provider);
60
86
  if (agent) {
61
87
  await storeAgent(agent, home);
62
88
  }
63
89
  else {
64
90
  const apiUrl = await setupApiOrigin(link, parsed.options["api-url"] ?? process.env.TALENTOS_API_URL);
91
+ const candidates = savedAgents
92
+ .filter((item) => item.provider === link.provider && item.apiUrl === apiUrl)
93
+ .sort((a, b) => b.linkedAt - a.linkedAt)
94
+ .slice(0, MAX_PREVIOUS_AGENT_IDS);
65
95
  const pendingPath = join(agentsDir(home), `.pending-${linkHash}.json`);
66
96
  let apiKey;
67
97
  let recovered;
@@ -104,6 +134,7 @@ export async function installLinkedAgent(parsed, dependencies = {}) {
104
134
  const identity = recovered ??
105
135
  (await redeemAgent(apiUrl, {
106
136
  token: link.token,
137
+ previousAgentIds: candidates.map((item) => item.agentId),
107
138
  keyHash: hashKey(apiKey),
108
139
  provider: link.provider,
109
140
  providerVersion: (dependencies.probe ?? inspectProvider)(link.provider),
@@ -121,9 +152,22 @@ export async function installLinkedAgent(parsed, dependencies = {}) {
121
152
  linkedAt: Date.now(),
122
153
  };
123
154
  await storeAgent(agent, home);
155
+ for (const old of candidates) {
156
+ if (old.agentId !== agent.agentId &&
157
+ identity.retiredAgentIds?.includes(old.agentId)) {
158
+ await uninstallAgentService(old, { home });
159
+ await rm(agentConfigPath(old.agentId, home), { force: true });
160
+ }
161
+ }
124
162
  await rm(pendingPath, { force: true });
125
163
  }
126
- const service = await (dependencies.install ?? ((config) => installAgentService(config, { home })))(agent);
164
+ const service = await (dependencies.install ??
165
+ (async (config) => {
166
+ if (process.platform !== "win32") {
167
+ await installSharedGateway({ home });
168
+ }
169
+ return await installAgentService(config, { home });
170
+ }))(agent);
127
171
  return { agent, service };
128
172
  }
129
173
  async function showAgents(parsed) {
@@ -204,9 +248,9 @@ async function showLogs(parsed) {
204
248
  await log.close();
205
249
  }
206
250
  }
207
- async function selectAgents(parsed, single = false) {
251
+ async function selectAgents(parsed, single = false, home) {
208
252
  const selector = parsed.options.agent ?? parsed.args[0];
209
- const agents = (await localAgents()).filter((agent) => !selector || agent.agentId === selector || agent.name === selector);
253
+ const agents = (await localAgents(home)).filter((agent) => !selector || agent.agentId === selector || agent.name === selector);
210
254
  if (single && agents.length > 1) {
211
255
  throw new Error("Several agents are linked. Select one by name or --agent <id>.");
212
256
  }
@@ -0,0 +1,343 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { cp, link, mkdir, readdir, readFile, readlink, realpath, rename, rm, stat, symlink, } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { promisify } from "node:util";
8
+ import { writePrivateFile } from "./agent-config.js";
9
+ import { compareGatewayVersions } from "./gateway-version.js";
10
+ import { homeDir } from "./install.js";
11
+ const exec = promisify(execFile);
12
+ const TRANSIENT_BIN = /(?:\/_npx\/|node_modules\/\.bin|node-gyp-bin|\/Cellar\/|\/\.nvm\/versions\/)/;
13
+ const NPM_ENV = new Set([
14
+ "HOME",
15
+ "USER",
16
+ "LOGNAME",
17
+ "LANG",
18
+ "LC_ALL",
19
+ "TMPDIR",
20
+ "TEMP",
21
+ "TMP",
22
+ "SystemRoot",
23
+ "HTTP_PROXY",
24
+ "HTTPS_PROXY",
25
+ "NO_PROXY",
26
+ "NODE_EXTRA_CA_CERTS",
27
+ ]);
28
+ export function gatewayLauncherPath(home = homeDir()) {
29
+ return join(home, ".talentos", "bin", "talentos-gateway");
30
+ }
31
+ export function servicePathEnv(home = homeDir()) {
32
+ return [
33
+ ...new Set([
34
+ ...(process.env.PATH ?? "")
35
+ .split(":")
36
+ .filter((entry) => entry.startsWith("/") && !TRANSIENT_BIN.test(entry)),
37
+ join(home, ".local/bin"),
38
+ join(home, ".npm-global/bin"),
39
+ "/opt/homebrew/bin",
40
+ "/usr/local/bin",
41
+ "/usr/bin",
42
+ "/bin",
43
+ ]),
44
+ ].join(":");
45
+ }
46
+ // No user paths are interpolated into executable shell text. The Node hint is data.
47
+ const LAUNCHER = `#!/bin/sh
48
+ set -f
49
+ root="\${0%/*}/.."
50
+ agent=
51
+ next_agent=
52
+ for argument in "$@"; do
53
+ if [ "$next_agent" = yes ]; then agent=$argument; break; fi
54
+ if [ "$argument" = --agent ]; then next_agent=yes; fi
55
+ done
56
+ node_missing=
57
+ case "$agent" in
58
+ ''|*[!a-zA-Z0-9_-]*) ;;
59
+ *) node_missing="$root/agents/$agent/.node-missing" ;;
60
+ esac
61
+ qualifies() {
62
+ [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' >/dev/null 2>&1
63
+ }
64
+ start() {
65
+ if [ -n "$node_missing" ]; then /bin/rm -f "$node_missing"; fi
66
+ node=$1
67
+ shift
68
+ PATH="\${node%/*}:\${PATH:-/usr/bin:/bin}"
69
+ export PATH
70
+ exec "$node" "$root/cli/current/node_modules/talentos/dist/index.js" "$@"
71
+ }
72
+ node_hint=
73
+ IFS= read -r node_hint < "$root/cli/node-path" || :
74
+ if qualifies "\${TALENTOS_NODE:-}"; then
75
+ start "$TALENTOS_NODE" "$@"
76
+ fi
77
+ if qualifies "$node_hint"; then
78
+ start "$node_hint" "$@"
79
+ fi
80
+ old_ifs=$IFS
81
+ IFS=:
82
+ for dir in \${PATH:-}; do
83
+ IFS=$old_ifs
84
+ if qualifies "$dir/node"; then
85
+ start "$dir/node" "$@"
86
+ fi
87
+ IFS=:
88
+ done
89
+ IFS=$old_ifs
90
+ for candidate in /opt/homebrew/bin/node /usr/local/bin/node; do
91
+ if qualifies "$candidate"; then
92
+ start "$candidate" "$@"
93
+ fi
94
+ done
95
+ # nvm has no stable system bin; discover surviving installations at startup.
96
+ set +f
97
+ for candidate in "$root"/../.nvm/versions/node/*/bin/node; do
98
+ if qualifies "$candidate"; then start "$candidate" "$@"; fi
99
+ done
100
+ message='TalentOS gateway requires Node.js 20 or newer. Install Node, then run talentos agent repair.'
101
+ if [ -z "$node_missing" ]; then
102
+ echo "$message" >&2
103
+ elif (set -C; : > "$node_missing") 2>/dev/null; then
104
+ echo "$message" >> "$root/agents/$agent/gateway.log"
105
+ fi
106
+ exit 78
107
+ `;
108
+ async function manifestVersion(root) {
109
+ return JSON.parse(await readFile(join(root, "package.json"), "utf8")).version;
110
+ }
111
+ async function copyPackage(source, destination) {
112
+ await mkdir(destination, { recursive: true, mode: 0o700 });
113
+ for (const name of ["package.json", "dist", "node_modules"]) {
114
+ try {
115
+ await cp(join(source, name), join(destination, name), {
116
+ recursive: true,
117
+ dereference: true,
118
+ force: true,
119
+ });
120
+ }
121
+ catch (error) {
122
+ if (name !== "node_modules" ||
123
+ error.code !== "ENOENT") {
124
+ throw error;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ async function installed(root, version) {
130
+ try {
131
+ if ((await manifestVersion(root)) !== version ||
132
+ !(await readFile(join(root, "dist/index.js"))).length) {
133
+ return false;
134
+ }
135
+ const { dependencies = {} } = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
136
+ // The SDK has no root entrypoint. Load the same exports the CLI uses;
137
+ // imports also catch missing transitive dependencies in partial installs.
138
+ const imports = Object.keys(dependencies).flatMap((name) => name === "@modelcontextprotocol/sdk"
139
+ ? [
140
+ `${name}/client/index.js`,
141
+ `${name}/server/index.js`,
142
+ `${name}/types.js`,
143
+ ]
144
+ : [name]);
145
+ if (imports.length) {
146
+ await exec(process.execPath, [
147
+ "--input-type=module",
148
+ "--eval",
149
+ `await Promise.all(${JSON.stringify(imports)}.map(name => import(name)))`,
150
+ ], { cwd: root, timeout: 15_000 });
151
+ }
152
+ return true;
153
+ }
154
+ catch (error) {
155
+ if (error.killed) {
156
+ throw new Error("Gateway dependency check timed out. Keeping the current version; retry agent repair shortly.");
157
+ }
158
+ return false;
159
+ }
160
+ }
161
+ async function installLock(root, signal) {
162
+ const path = join(root, ".install-lock");
163
+ const pending = join(root, `.lock-${randomUUID()}`);
164
+ const contents = `${process.pid}\n${randomUUID()}`;
165
+ // Publish a populated lock atomically so a second installer never sees an
166
+ // empty new lock and mistakes it for one left behind by a crash.
167
+ await writePrivateFile(pending, contents);
168
+ try {
169
+ for (let attempt = 0; attempt < 600; attempt++) {
170
+ signal?.throwIfAborted();
171
+ try {
172
+ await link(pending, path);
173
+ return async () => {
174
+ if ((await readFile(path, "utf8").catch(() => "")) === contents) {
175
+ await rm(path, { force: true });
176
+ }
177
+ };
178
+ }
179
+ catch (error) {
180
+ if (error.code !== "EEXIST") {
181
+ throw error;
182
+ }
183
+ try {
184
+ const lock = await readFile(path, "utf8");
185
+ const metadata = await stat(path);
186
+ const age = Date.now() - metadata.mtimeMs;
187
+ const pid = Number(lock.split("\n")[0]);
188
+ let stale = !lock.trim() || age > 5 * 60_000;
189
+ if (!stale && pid > 0) {
190
+ try {
191
+ process.kill(pid, 0);
192
+ }
193
+ catch (failure) {
194
+ stale = failure.code === "ESRCH";
195
+ }
196
+ }
197
+ if (stale && (await readFile(path, "utf8")) === lock) {
198
+ const latest = await stat(path);
199
+ if (latest.dev === metadata.dev && latest.ino === metadata.ino) {
200
+ await rm(path, { force: true });
201
+ continue;
202
+ }
203
+ }
204
+ }
205
+ catch (error) {
206
+ if (error.code !== "ENOENT") {
207
+ throw error;
208
+ }
209
+ continue;
210
+ }
211
+ await delay(200, undefined, { signal });
212
+ }
213
+ }
214
+ throw new Error("Another gateway installation is still running. Retry agent repair shortly.");
215
+ }
216
+ finally {
217
+ await rm(pending, { force: true });
218
+ }
219
+ }
220
+ export async function installSharedGateway(options = {}) {
221
+ const home = options.home ?? homeDir();
222
+ const source = options.source ?? fileURLToPath(new URL("../", import.meta.url));
223
+ const ownVersion = await manifestVersion(source);
224
+ let version = options.version ?? ownVersion;
225
+ if (compareGatewayVersions(version, version) === undefined) {
226
+ throw new Error("Unsupported gateway version.");
227
+ }
228
+ const root = join(home, ".talentos/cli");
229
+ await mkdir(root, { recursive: true, mode: 0o700 });
230
+ const release = await installLock(root, options.signal);
231
+ const temporary = join(root, `.install-${randomUUID()}`);
232
+ const pointer = join(root, `.current-${randomUUID()}`);
233
+ let stagedVersion;
234
+ try {
235
+ const active = await readlink(join(root, "current")).catch(() => "");
236
+ if ((compareGatewayVersions(active, version) ?? 0) > 0 &&
237
+ (await installed(join(root, active, "node_modules/talentos"), active))) {
238
+ version = active;
239
+ }
240
+ const destination = join(root, version);
241
+ const pkg = join(destination, "node_modules/talentos");
242
+ if (!(await installed(pkg, version))) {
243
+ await mkdir(temporary, { mode: 0o700 });
244
+ // Local builds have not been published. Copy resolved dependencies too,
245
+ // so pnpm's symlinks cannot strand the installed gateway in a worktree.
246
+ if (version === ownVersion &&
247
+ !source.includes("/node_modules/talentos")) {
248
+ await copyPackage(source, join(temporary, "node_modules/talentos"));
249
+ }
250
+ else {
251
+ const npmEnv = {
252
+ PATH: `${process.env.PATH ?? servicePathEnv()}:${dirname(process.execPath)}`,
253
+ };
254
+ for (const [key, value] of Object.entries(process.env)) {
255
+ if ((NPM_ENV.has(key) || key.toLowerCase().startsWith("npm_config_")) &&
256
+ value) {
257
+ npmEnv[key] = value;
258
+ }
259
+ }
260
+ try {
261
+ await exec("npm", [
262
+ "install",
263
+ "--prefix",
264
+ temporary,
265
+ "--omit=dev",
266
+ "--ignore-scripts",
267
+ "--no-audit",
268
+ "--no-fund",
269
+ `talentos@${version}`,
270
+ ], { env: npmEnv, signal: options.signal, timeout: 90_000 });
271
+ }
272
+ catch {
273
+ if (version === ownVersion) {
274
+ await rm(temporary, { recursive: true, force: true });
275
+ await copyPackage(source, join(temporary, "node_modules/talentos"));
276
+ }
277
+ else {
278
+ throw new Error("Gateway update failed. Serving the current version; retrying when the network is available.");
279
+ }
280
+ }
281
+ }
282
+ if (!(await installed(join(temporary, "node_modules/talentos"), version))) {
283
+ throw new Error("Gateway package is incomplete. Restore its runtime dependencies or retry agent repair online.");
284
+ }
285
+ await rm(destination, { recursive: true, force: true });
286
+ await rename(temporary, destination);
287
+ if (version !== active) {
288
+ stagedVersion = destination;
289
+ }
290
+ }
291
+ let previous;
292
+ try {
293
+ previous = await readlink(join(root, "current"));
294
+ }
295
+ catch {
296
+ /* First install. */
297
+ }
298
+ await symlink(version, pointer);
299
+ await rename(pointer, join(root, "current"));
300
+ stagedVersion = undefined;
301
+ const launcher = gatewayLauncherPath(home);
302
+ await writePrivateFile(launcher, LAUNCHER, 0o700);
303
+ let nodePath = options.nodePath ?? process.execPath;
304
+ for (const stable of [
305
+ "/opt/homebrew/bin/node",
306
+ "/usr/local/bin/node",
307
+ "/usr/bin/node",
308
+ ]) {
309
+ if ((await realpath(stable).catch(() => "")) === nodePath) {
310
+ nodePath = stable;
311
+ break;
312
+ }
313
+ }
314
+ // Preserve the link-time hint during updates and repairs.
315
+ const hint = join(root, "node-path");
316
+ try {
317
+ await readFile(hint);
318
+ }
319
+ catch {
320
+ await writePrivateFile(hint, `${nodePath}\n`);
321
+ }
322
+ const keep = new Set([version, previous]);
323
+ const oldVersions = (await readdir(root))
324
+ .filter((entry) => compareGatewayVersions(entry, entry) !== undefined && !keep.has(entry))
325
+ .sort((a, b) => compareGatewayVersions(b, a) ?? 0);
326
+ // A repeat repair retains the other installed version.
327
+ if (previous === version) {
328
+ oldVersions.shift();
329
+ }
330
+ for (const old of oldVersions) {
331
+ await rm(join(root, old), { recursive: true, force: true });
332
+ }
333
+ return launcher;
334
+ }
335
+ finally {
336
+ await rm(temporary, { recursive: true, force: true });
337
+ await rm(pointer, { force: true });
338
+ if (stagedVersion) {
339
+ await rm(stagedVersion, { recursive: true, force: true });
340
+ }
341
+ await release();
342
+ }
343
+ }
@@ -0,0 +1,45 @@
1
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+[\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*)?$/;
2
+ const NUMERIC_IDENTIFIER = /^\d+$/;
3
+ export function compareGatewayVersions(a, b) {
4
+ const left = VERSION.exec(a), right = VERSION.exec(b);
5
+ if (!(left && right)) {
6
+ return;
7
+ }
8
+ for (let i = 1; i <= 3; i++) {
9
+ const difference = Number(left[i]) - Number(right[i]);
10
+ if (difference) {
11
+ return difference;
12
+ }
13
+ }
14
+ if (left[4] === right[4]) {
15
+ return 0;
16
+ }
17
+ if (!left[4]) {
18
+ return 1;
19
+ }
20
+ if (!right[4]) {
21
+ return -1;
22
+ }
23
+ const l = left[4].split("."), r = right[4].split(".");
24
+ for (let i = 0; i < Math.max(l.length, r.length); i++) {
25
+ const x = l[i], y = r[i];
26
+ if (x === undefined) {
27
+ return -1;
28
+ }
29
+ if (y === undefined) {
30
+ return 1;
31
+ }
32
+ if (x === y) {
33
+ continue;
34
+ }
35
+ const xn = NUMERIC_IDENTIFIER.test(x), yn = NUMERIC_IDENTIFIER.test(y);
36
+ if (xn && yn) {
37
+ return Number(x) - Number(y);
38
+ }
39
+ if (xn !== yn) {
40
+ return xn ? -1 : 1;
41
+ }
42
+ return x < y ? -1 : 1;
43
+ }
44
+ return 0;
45
+ }
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ Usage: talentos <command> [options]
9
9
 
10
10
  Commands:
11
11
  init Connect this machine's agents to TalentOS
12
- agent <link|status|list|unlink|logs> Bring your own agent into Rooms
12
+ agent <link|repair|status|list|unlink|logs> Bring your own agent into Rooms
13
13
  login Authorize this machine
14
14
  logout Forget the key stored on this machine
15
15
  whoami Show the member and org this key acts as
@@ -32,6 +32,7 @@ const GROUP_USAGE = {
32
32
  talentos agent status | list [--json] [--agent <id>]
33
33
  talentos agent unlink [name] [--agent <id>]
34
34
  talentos agent logs [--agent <id>]
35
+ talentos agent repair [--agent <id>]
35
36
  talentos agent serve --agent <id>
36
37
 
37
38
  The link command checks the provider's CLI login, mints the key locally, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talentos",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "TalentOS CLI: push and pull skills, read and write team memory, and delegate work to your Talent from any coding agent.",
6
6
  "repository": {