talentos 1.1.6 → 1.2.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,87 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, realpathSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { rootProblem } from "./config.js";
7
+ import { canonical, isInside } from "./paths.js";
8
+ const exec = promisify(execFile);
9
+ const safeId = (value, length) => value
10
+ .replace(/[^a-z\d]/giu, "")
11
+ .slice(0, length)
12
+ .toLowerCase() || "thread";
13
+ /**
14
+ * The folder one thread's owner jobs run in. In a repo it is a git worktree the
15
+ * CLI creates (never the model), so the owner's checkout stays untouched; with
16
+ * no repo, or one with no commits yet, a plain per-thread folder under the
17
+ * managed root. Throws when the configured root fails its checks.
18
+ */
19
+ export async function prepareWorkspace(input) {
20
+ const { config, home } = input;
21
+ const problem = rootProblem(config, home);
22
+ if (problem) {
23
+ throw new Error(problem);
24
+ }
25
+ const thread = safeId(input.threadId, 12);
26
+ const managed = join(home, "TalentOS", "threads");
27
+ let cwd;
28
+ const useWorktree = config.kind === "repo" && (await hasCommit(config.root));
29
+ if (useWorktree) {
30
+ const repo = `${safeId(config.root.split("/").pop() ?? "repo", 20)}-${createHash("sha256").update(config.root).digest("hex").slice(0, 8)}`;
31
+ cwd = join(home, "TalentOS", ".worktrees", repo, thread);
32
+ }
33
+ else {
34
+ cwd = join(config.kind === "managed" ? config.root : managed, thread);
35
+ }
36
+ // The folder must resolve inside the TalentOS area or the managed root, and
37
+ // that is checked BEFORE anything is created, so a planted symlink cannot
38
+ // make the CLI build a worktree somewhere else.
39
+ const bases = [join(home, "TalentOS"), config.root].map((base) => canonical(base, home, home));
40
+ const inside = (path) => bases.some((base) => isInside(canonical(path, home, home), base));
41
+ if (!inside(cwd)) {
42
+ throw new Error("The workspace left its folder.");
43
+ }
44
+ if (useWorktree) {
45
+ await ensureWorktree(config.root, cwd, thread);
46
+ }
47
+ else {
48
+ mkdirSync(cwd, { recursive: true, mode: 0o700 });
49
+ }
50
+ const real = realpathSync.native(cwd);
51
+ if (!(statSync(real).isDirectory() && inside(real))) {
52
+ throw new Error("The workspace left its folder.");
53
+ }
54
+ return { cwd: real, writeRoots: [real] };
55
+ }
56
+ async function hasCommit(root) {
57
+ try {
58
+ await exec("git", ["rev-parse", "--verify", "HEAD"], {
59
+ cwd: root,
60
+ timeout: 5000,
61
+ });
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ async function ensureWorktree(root, path, thread) {
69
+ try {
70
+ if (statSync(path).isDirectory()) {
71
+ return;
72
+ }
73
+ }
74
+ catch {
75
+ // Missing: create it below.
76
+ }
77
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
78
+ const branch = `talentos/${thread}`;
79
+ const options = { cwd: root, timeout: 60_000 };
80
+ try {
81
+ await exec("git", ["worktree", "add", "-b", branch, path, "HEAD"], options);
82
+ }
83
+ catch {
84
+ // The branch survives a deleted folder: reattach it.
85
+ await exec("git", ["worktree", "add", path, branch], options);
86
+ }
87
+ }
@@ -4,11 +4,17 @@ import { dirname, join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import { installSharedGateway } from "../gateway-install.js";
6
6
  import { compareGatewayVersions } from "../gateway-version.js";
7
+ import { homeDir } from "../install.js";
7
8
  import { runProvider } from "./adapters.js";
8
9
  import { AgentGateway, GatewayError } from "./gateway.js";
10
+ import { ApprovalBridge } from "./native/approvals.js";
11
+ import { NativeGate } from "./native/gate.js";
9
12
  import { SessionMap } from "./sessions.js";
10
13
  const HEARTBEAT_MS = 20_000;
11
14
  const LEASE_SAFETY_MS = 55_000;
15
+ // A native job holds a 30 s server lease and is re-derived on every renewal.
16
+ const NATIVE_RENEW_MS = 10_000;
17
+ const NATIVE_SAFETY_MS = 25_000;
12
18
  const IDLE_MS = 30 * 60_000;
13
19
  export function needsUpdate(current, minimum) {
14
20
  if (compareGatewayVersions(minimum, minimum) === undefined) {
@@ -55,12 +61,14 @@ export async function installGatewayUpdate(directory, minimum, signal) {
55
61
  signal,
56
62
  });
57
63
  }
58
- export async function serveAgent(config, directory, version, stop, verify) {
64
+ export async function serveAgent(config, directory, version, stop, verify, options = {}) {
59
65
  const release = await acquireLock(directory);
60
66
  try {
61
67
  const workDir = join(directory, "work"), sessions = new SessionMap(directory), gateway = new AgentGateway(config);
62
68
  await mkdir(workDir, { recursive: true, mode: 0o700 });
63
69
  const deviceId = randomUUID();
70
+ const gate = options.gate ??
71
+ new NativeGate({ agent: config, directory, home: homeDir() });
64
72
  const service = new AbortController();
65
73
  const signal = AbortSignal.any([stop, service.signal]);
66
74
  let wakeResolve, wakePending = true;
@@ -81,7 +89,8 @@ export async function serveAgent(config, directory, version, stop, verify) {
81
89
  }
82
90
  };
83
91
  const heartbeat = async () => {
84
- const response = await gateway.request("/agent-gateway/heartbeat", { cliVersion: version }, signal);
92
+ gate.refresh();
93
+ const response = await gateway.request("/agent-gateway/heartbeat", { cliVersion: version, nativeOptIn: gate.reportable() }, signal);
85
94
  if (response) {
86
95
  minimum = response.cliMinVersion;
87
96
  if (response.pendingJobs > 0 || needsUpdate(version, minimum)) {
@@ -100,7 +109,9 @@ export async function serveAgent(config, directory, version, stop, verify) {
100
109
  .request(`/agent-gateway/jobs/${leased.job.jobId}/renew`, { leaseToken: leased.job.leaseToken }, signal)
101
110
  .then(() => {
102
111
  if (active === leased) {
103
- leased.deadline = started + LEASE_SAFETY_MS;
112
+ leased.deadline =
113
+ started +
114
+ (leased.job.native ? NATIVE_SAFETY_MS : LEASE_SAFETY_MS);
104
115
  }
105
116
  })
106
117
  .catch((error) => {
@@ -114,7 +125,12 @@ export async function serveAgent(config, directory, version, stop, verify) {
114
125
  })
115
126
  : Promise.resolve(),
116
127
  ]);
117
- await delay(Math.max(1, HEARTBEAT_MS - (Date.now() - started)), undefined, { signal }).catch(() => {
128
+ // The owner's local `native off` ends a running owner job at once.
129
+ if (active?.job.native && active.owner && !gate.optedIn()) {
130
+ active.cancel.abort();
131
+ }
132
+ await delay(Math.max(1, (active?.job.native ? NATIVE_RENEW_MS : HEARTBEAT_MS) -
133
+ (Date.now() - started)), undefined, { signal }).catch(() => {
118
134
  /* Shutdown interrupts the timer. */
119
135
  });
120
136
  }
@@ -170,17 +186,67 @@ export async function serveAgent(config, directory, version, stop, verify) {
170
186
  if (job) {
171
187
  lastJobAt = Date.now();
172
188
  const cancel = new AbortController();
173
- active = { job, cancel, deadline: claimStartedAt + LEASE_SAFETY_MS };
189
+ const current = {
190
+ job,
191
+ cancel,
192
+ deadline: claimStartedAt +
193
+ (job.native ? NATIVE_SAFETY_MS : LEASE_SAFETY_MS),
194
+ owner: false,
195
+ };
196
+ active = current;
174
197
  let lastStatusAt = 0;
198
+ // Monotonic, so a wall-clock change cannot corrupt the marks.
199
+ const receivedAt = performance.now();
200
+ const timings = {};
175
201
  try {
202
+ // An owner job runs the owner's real setup only when the plane
203
+ // confirmed owner authority just now AND this computer is ready;
204
+ // anything less is the room-only profile, as before.
205
+ let owner;
206
+ if (job.native) {
207
+ const post = (statusLine) => gateway
208
+ .request(`/agent-gateway/jobs/${job.jobId}/status`, { leaseToken: job.leaseToken, statusLine }, signal)
209
+ .catch(failService);
210
+ post("Starting on your computer…");
211
+ lastStatusAt = Date.now();
212
+ const [, prepared] = await Promise.all([
213
+ gateway.request(`/agent-gateway/jobs/${job.jobId}/native-confirm`, { leaseToken: job.leaseToken }, signal),
214
+ gate.prepare(job.packet.thread.id).catch(() => undefined),
215
+ ]);
216
+ owner = prepared;
217
+ current.owner = Boolean(owner);
218
+ }
219
+ const sessionKey = owner
220
+ ? `${job.packet.thread.id}:owner`
221
+ : job.packet.thread.id;
222
+ const bridge = () => new ApprovalBridge({
223
+ gateway: {
224
+ request: async (ask) => (await gateway.request(`/agent-gateway/jobs/${job.jobId}/approval-request`, { leaseToken: job.leaseToken, ...ask }, signal)) ?? { status: "expired" },
225
+ status: async (actionKeys) => (await gateway.request(`/agent-gateway/jobs/${job.jobId}/approval-status`, { actionKeys, leaseToken: job.leaseToken }, signal)) ?? [],
226
+ },
227
+ onWaiting: (waiting) => {
228
+ gateway
229
+ .request(`/agent-gateway/jobs/${job.jobId}/status`, {
230
+ leaseToken: job.leaseToken,
231
+ statusLine: waiting
232
+ ? "Waiting for your approval…"
233
+ : "Working on your computer…",
234
+ }, signal)
235
+ .catch(failService);
236
+ },
237
+ }).start();
176
238
  const result = await runProvider({
177
239
  config,
178
240
  job,
179
241
  workDir,
180
- sessionId: await sessions.get(job.packet.thread.id),
242
+ owner: owner && { ...owner, watch: bridge },
243
+ sessionId: await sessions.get(sessionKey),
181
244
  signal: AbortSignal.any([signal, cancel.signal]),
182
245
  remember: async (sessionId) => {
183
- await sessions.remember(job.packet.thread.id, sessionId);
246
+ await sessions.remember(sessionKey, sessionId);
247
+ },
248
+ mark: (name) => {
249
+ timings[`${name}Ms`] = Math.round(performance.now() - receivedAt);
184
250
  },
185
251
  status: (line) => {
186
252
  if (Date.now() - lastStatusAt < 1000) {
@@ -196,7 +262,7 @@ export async function serveAgent(config, directory, version, stop, verify) {
196
262
  // never become a new provider run or a duplicate final reply.
197
263
  while (!(signal.aborted || cancel.signal.aborted)) {
198
264
  try {
199
- await gateway.request(`/agent-gateway/jobs/${job.jobId}/complete`, { leaseToken: job.leaseToken, text: result.text }, signal);
265
+ await gateway.request(`/agent-gateway/jobs/${job.jobId}/complete`, { leaseToken: job.leaseToken, text: result.text, timings }, signal);
200
266
  break;
201
267
  }
202
268
  catch (error) {
package/dist/args.js CHANGED
@@ -22,6 +22,7 @@ const CLI_OPTIONS = {
22
22
  url: { type: "string" },
23
23
  yes: { type: "boolean" },
24
24
  agent: { type: "string" },
25
+ "upload-root": { type: "string" },
25
26
  };
26
27
  /** Groups whose first positional is a sub-command rather than an argument. */
27
28
  const ACTION_GROUPS = new Set(["agent", "mcp", "skills", "memory", "routines"]);
@@ -0,0 +1,64 @@
1
+ import { dirname } from "node:path";
2
+ import { disableNative, enableNative, readNativeConfig, } from "../agent/native/config.js";
3
+ import { NativeGate } from "../agent/native/gate.js";
4
+ import { agentWorkDir, localAgents, } from "../agent-config.js";
5
+ import { emit } from "../format.js";
6
+ import { homeDir } from "../install.js";
7
+ // `talentos agent native enable | off | status` (#10987): the owner's local yes
8
+ // to "let this agent work on this computer when you ask it in a room". The
9
+ // server still needs the owner's own confirmation in TalentOS; this only
10
+ // records consent on this machine and proves the provider can be guarded.
11
+ async function pickAgent(parsed, home) {
12
+ const agents = await localAgents(home);
13
+ const selector = parsed.options.agent;
14
+ let chosen;
15
+ if (selector) {
16
+ chosen = agents.find((agent) => agent.agentId === selector || agent.name === selector);
17
+ }
18
+ else if (agents.length === 1) {
19
+ chosen = agents[0];
20
+ }
21
+ if (!chosen) {
22
+ throw new Error("Choose a linked agent with `talentos agent native enable --agent <id>`.");
23
+ }
24
+ return chosen;
25
+ }
26
+ const shown = (path, home) => path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
27
+ export async function runAgentNative(parsed, home = homeDir()) {
28
+ const action = parsed.args[0];
29
+ if (action !== "enable" && action !== "off" && action !== "status") {
30
+ throw new Error("Use talentos agent native enable | off | status.");
31
+ }
32
+ const agent = await pickAgent(parsed, home);
33
+ const directory = dirname(agentWorkDir(agent.agentId, home));
34
+ if (action === "off") {
35
+ await disableNative(directory);
36
+ emit(`Turned off. ${agent.name} answers with room tools only, and any run in progress stops within seconds.`);
37
+ return;
38
+ }
39
+ if (action === "status") {
40
+ const config = await readNativeConfig(directory);
41
+ emit(config
42
+ ? `On. ${agent.name} works in ${shown(config.root, home)} when you ask it in a room where you are the only person.`
43
+ : `Off. Run talentos agent native enable to let ${agent.name} use your full setup in rooms only you are in.`);
44
+ return;
45
+ }
46
+ if (agent.provider !== "claude_code") {
47
+ throw new Error("Full-setup rooms support Claude Code first. Codex is not available yet.");
48
+ }
49
+ const config = await enableNative({
50
+ agentId: agent.agentId,
51
+ cwd: process.cwd(),
52
+ directory,
53
+ home,
54
+ name: agent.name,
55
+ });
56
+ try {
57
+ await new NativeGate({ agent, directory, home }).runProbe();
58
+ }
59
+ catch (error) {
60
+ await disableNative(directory);
61
+ throw error;
62
+ }
63
+ emit(`I'll work in ${shown(config.root, home)}${config.kind === "repo" ? " (each room thread gets its own copy of the branch)" : ""}. TalentOS will ask you to confirm once.`);
64
+ }
@@ -92,6 +92,10 @@ export async function runAgent(parsed, dependencies = {}) {
92
92
  await import("../agent/mcp-entry.js");
93
93
  return;
94
94
  }
95
+ case "native": {
96
+ const { runAgentNative } = await import("./agent-native.js");
97
+ return await runAgentNative(parsed, dependencies.home);
98
+ }
95
99
  case "status":
96
100
  case "list":
97
101
  return await showAgents(parsed);
@@ -100,7 +104,7 @@ export async function runAgent(parsed, dependencies = {}) {
100
104
  case "logs":
101
105
  return await showLogs(parsed);
102
106
  default:
103
- throw new Error("Use talentos agent link | repair | status | list | unlink | logs.");
107
+ throw new Error("Use talentos agent link | repair | status | list | unlink | logs | native.");
104
108
  }
105
109
  }
106
110
  export async function installLinkedAgent(parsed, dependencies = {}) {
package/dist/mcp-proxy.js CHANGED
@@ -1,17 +1,58 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { UploadRefusal, uploadFile } from "./upload-file.js";
5
+ // The two server steps behind upload_file. The model sees only upload_file.
6
+ const UPLOAD_STEP_TOOLS = new Set(["prepare_upload", "commit_upload"]);
4
7
  /**
5
8
  * stdio front end for clients that cannot speak streamable HTTP (Codex, older
6
9
  * desktop builds). It forwards tool listing and calls verbatim so the tool set
7
10
  * an agent sees over stdio is the one the remote server owns.
8
11
  */
9
- export async function startMcpProxy(client) {
12
+ export async function startMcpProxy(client,
13
+ // The only folder upload_file reads from: the job's workspace, named by the
14
+ // launcher. Never a tool argument, never this process's own folder; without
15
+ // it upload_file refuses.
16
+ root) {
10
17
  const server = new Server({ name: "talentos", version: "1.0.0" }, { capabilities: { tools: {} } });
11
- server.setRequestHandler(ListToolsRequestSchema, async () => await client.listTools());
12
- server.setRequestHandler(CallToolRequestSchema, async (request) => await client.callTool({
13
- name: request.params.name,
14
- arguments: request.params.arguments ?? {},
15
- }));
18
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
19
+ const listed = await client.listTools();
20
+ return {
21
+ ...listed,
22
+ tools: listed.tools.filter((tool) => !UPLOAD_STEP_TOOLS.has(tool.name)),
23
+ };
24
+ });
25
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
26
+ const { name, arguments: args = {} } = request.params;
27
+ if (name === "upload_file") {
28
+ return await runUpload(client, args, root);
29
+ }
30
+ if (UPLOAD_STEP_TOOLS.has(name)) {
31
+ return {
32
+ content: [{ text: "Use upload_file instead.", type: "text" }],
33
+ isError: true,
34
+ };
35
+ }
36
+ return await client.callTool({ name, arguments: args });
37
+ });
16
38
  await server.connect(new StdioServerTransport());
17
39
  }
40
+ async function runUpload(client, args, root) {
41
+ try {
42
+ const text = await uploadFile(client, {
43
+ path: String(args.path ?? ""),
44
+ roomId: String(args.roomId ?? ""),
45
+ title: typeof args.title === "string" ? args.title : undefined,
46
+ }, root);
47
+ return { content: [{ text, type: "text" }] };
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof UploadRefusal || error instanceof Error
51
+ ? error.message
52
+ : "The upload failed.";
53
+ return {
54
+ content: [{ text: message, type: "text" }],
55
+ isError: true,
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,172 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
6
+ import { Readable } from "node:stream";
7
+ import { canonical, protectedPaths, protectedReason, } from "./agent/native/paths.js";
8
+ // The server caps and re-checks everything (type, size, names, content); this
9
+ // is the client's half of the boundary: only a regular file inside the folder
10
+ // the agent works in, and never a credential store, whatever the model asks.
11
+ export const UPLOAD_BYTES_MAX = 50 * 1024 * 1024;
12
+ const DENIED_SEGMENTS = new Set([
13
+ ".aws",
14
+ ".azure",
15
+ ".docker",
16
+ ".git",
17
+ ".git-credentials",
18
+ ".gnupg",
19
+ ".kube",
20
+ ".netrc",
21
+ ".npmrc",
22
+ ".pypirc",
23
+ ".ssh",
24
+ ".talentos",
25
+ ".yarnrc",
26
+ ".yarnrc.yml",
27
+ // Provider and hook configuration, inside a workspace as well as in $HOME.
28
+ ".claude",
29
+ ".codex",
30
+ ".cursor",
31
+ ".mcp.json",
32
+ ]);
33
+ const DENIED_NAME = /^(?:\.env.*|id_(?:rsa|dsa|ecdsa|ed25519).*|credentials?(?:\..*)?|.*\.(?:pem|key|p12|pfx|jks|keystore)|.*_history|\.bash_profile|\.zshenv)$/iu;
34
+ export class UploadRefusal extends Error {
35
+ }
36
+ function refuse(message) {
37
+ throw new UploadRefusal(message);
38
+ }
39
+ function deniedSegment(segments) {
40
+ return segments.find((segment) => DENIED_SEGMENTS.has(segment.toLowerCase()) || DENIED_NAME.test(segment));
41
+ }
42
+ // Opens the file the model named, or refuses. Symlinks are resolved first and
43
+ // the real location must sit inside `root`; the file is then opened without
44
+ // following a final link and the opened descriptor, not the path, is what is
45
+ // measured and read, so a swap between check and read cannot widen access.
46
+ export async function openUploadSource(requested, root, home = homedir()) {
47
+ if (!root) {
48
+ refuse("upload_file needs the job's workspace folder, and this job has none. Create the file with create_file instead, or ask the owner to run you in a workspace.");
49
+ }
50
+ let rootReal;
51
+ try {
52
+ rootReal = await realpath(root);
53
+ }
54
+ catch {
55
+ return refuse("The job's workspace folder is not available.");
56
+ }
57
+ const guard = protectedPaths(home);
58
+ const guarded = (path) => {
59
+ const reason = protectedReason(path, "read", home, guard);
60
+ if (reason) {
61
+ refuse(`That file is in a protected location (${reason}), so it was not uploaded.`);
62
+ }
63
+ };
64
+ const candidate = resolve(rootReal, requested);
65
+ const lexical = relative(rootReal, candidate).split(sep);
66
+ guarded(candidate);
67
+ guarded(canonical(candidate, rootReal, home));
68
+ const lexicalDenied = deniedSegment(lexical);
69
+ if (lexicalDenied) {
70
+ refuse(`${lexicalDenied} is a credential or configuration file, so it was not uploaded.`);
71
+ }
72
+ let real;
73
+ try {
74
+ real = await realpath(candidate);
75
+ }
76
+ catch {
77
+ return refuse("That file does not exist in your working folder.");
78
+ }
79
+ const inside = relative(rootReal, real);
80
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
81
+ refuse("Only files inside your working folder can be uploaded. Copy the file there first.");
82
+ }
83
+ guarded(real);
84
+ const realDenied = deniedSegment(inside.split(sep));
85
+ if (realDenied) {
86
+ refuse(`${realDenied} is a credential or configuration file, so it was not uploaded.`);
87
+ }
88
+ let handle;
89
+ try {
90
+ // biome-ignore lint/suspicious/noBitwiseOperators: open(2) flags combine by OR.
91
+ handle = await open(real, constants.O_RDONLY | constants.O_NOFOLLOW);
92
+ }
93
+ catch {
94
+ return refuse("That file could not be opened.");
95
+ }
96
+ try {
97
+ const opened = await handle.stat();
98
+ if (!opened.isFile()) {
99
+ refuse("Only regular files can be uploaded, not folders or devices.");
100
+ }
101
+ const atPath = await lstat(real);
102
+ if (atPath.dev !== opened.dev || atPath.ino !== opened.ino) {
103
+ refuse("The file changed while it was being opened. Try again.");
104
+ }
105
+ if (opened.size < 1) {
106
+ refuse("That file is empty.");
107
+ }
108
+ if (opened.size > UPLOAD_BYTES_MAX) {
109
+ refuse(`Files up to ${UPLOAD_BYTES_MAX / 1024 / 1024} MiB can be uploaded. Make it smaller or split it.`);
110
+ }
111
+ const hash = createHash("sha256");
112
+ for await (const chunk of handle.createReadStream({
113
+ autoClose: false,
114
+ start: 0,
115
+ })) {
116
+ hash.update(chunk);
117
+ }
118
+ const stream = () => handle.createReadStream({ autoClose: false, start: 0 });
119
+ return {
120
+ handle,
121
+ name: basename(candidate),
122
+ sha256: hash.digest("hex"),
123
+ size: opened.size,
124
+ stream,
125
+ };
126
+ }
127
+ catch (error) {
128
+ await handle.close();
129
+ throw error;
130
+ }
131
+ }
132
+ // prepare -> stream the bytes straight to storage -> commit. Nothing about the
133
+ // file's contents passes through the model or an MCP argument.
134
+ export async function uploadFile(client, args, root, fetchImpl = fetch, home = homedir()) {
135
+ const source = await openUploadSource(args.path, root, home);
136
+ try {
137
+ const prepared = await client.call("prepare_upload", {
138
+ filename: source.name,
139
+ roomId: args.roomId,
140
+ sha256: source.sha256,
141
+ sizeBytes: source.size,
142
+ ...(args.title ? { title: args.title } : {}),
143
+ });
144
+ if (prepared.data.status === "committed" || !prepared.data.uploadUrl) {
145
+ return prepared.text;
146
+ }
147
+ const response = await fetchImpl(prepared.data.uploadUrl, {
148
+ body: Readable.toWeb(source.stream()),
149
+ duplex: "half",
150
+ headers: {
151
+ "Content-Length": String(source.size),
152
+ "Content-Type": "application/octet-stream",
153
+ },
154
+ method: "POST",
155
+ });
156
+ if (!response.ok) {
157
+ refuse(`The upload failed (${response.status}). Try again.`);
158
+ }
159
+ const { storageId } = (await response.json());
160
+ if (!storageId) {
161
+ refuse("The upload failed. Try again.");
162
+ }
163
+ const committed = await client.call("commit_upload", {
164
+ sessionId: prepared.data.sessionId,
165
+ storageId,
166
+ });
167
+ return committed.text;
168
+ }
169
+ finally {
170
+ await source.handle.close();
171
+ }
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talentos",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
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": {