opencode-remote-login 0.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/NOTES.md ADDED
@@ -0,0 +1,100 @@
1
+ # Implementation Notes
2
+
3
+ ## Architecture
4
+
5
+ ```
6
+ opencode-remote-login/
7
+ ├── package.json # exports: ./server (plugin entry)
8
+ ├── hosts.json # host aliases (gitignored)
9
+ ├── hosts.example.json # template
10
+ └── src/
11
+ └── index.ts # server plugin (~250 lines)
12
+ ```
13
+
14
+ The plugin registers a single tool `remote_login` with two modes (`one-way` / `round-trip`) and a `tool.execute.after` hook that triggers `promptAsync` after round-trip completes.
15
+
16
+ ## Round-trip Flow
17
+
18
+ ```
19
+ local remote
20
+ ───── ──────
21
+ 1. opencode export <sid> → stdout JSON
22
+ 2. fixPendingRemoteLogin() ← patch pending tool-call in JSON
23
+ 3. writeFile → SCP → /tmp/opencode-import-<sid>.json
24
+ 4. SSH opencode import → writes to remote SQLite
25
+ 5. SCP script → SSH bash → nohup opencode run --session=<sid>
26
+ 6. poll: SSH "test -f back-file" → wait for remote export
27
+ 7. SCP back-file ← ← opencode export <sid>
28
+ 8. opencode import back-file → writes to local SQLite
29
+ 9. [hook] promptAsync fork → LLM processes imported context
30
+ ```
31
+
32
+ ## Pitfalls and Lessons
33
+
34
+ ### 1. `opencode export` captures the tool mid-execution
35
+
36
+ The export subprocess runs while the `remote_login` tool is still executing. The exported JSON shows the tool call in `"pending"` state with empty input. The remote LLM sees an incomplete tool call and gets confused.
37
+
38
+ **Fix**: `fixPendingRemoteLogin()` patches the last assistant message's `remote_login` tool part from `pending` → `completed` with proper output and a `step-finish` part.
39
+
40
+ ### 2. Shell quoting across SSH boundaries
41
+
42
+ Building shell commands with nested quoting (ssh → bash → opencode run) is fragile. Double quotes in the continuation message were passed literally to the remote LLM.
43
+
44
+ **Fix**: Write a shell script file locally, SCP it to remote, then execute it. Avoids quoting hell entirely.
45
+
46
+ ### 3. `execSync` blocks the event loop
47
+
48
+ `child_process.execSync` is synchronous and blocks Node's event loop. This is fine for shell commands but incompatible with async SDK calls.
49
+
50
+ **Fix**: All SDK calls (`promptAsync`) are done OUTSIDE `execSync` blocks, either after all sync work is done or in hooks.
51
+
52
+ ### 4. `session.prompt({ noReply: true })` does not persist
53
+
54
+ Looking at the source (`prompt.ts:1631`): when `noReply === true`, the message is created in memory and returned via HTTP but never written to SQLite. No SSE events are emitted. The TUI never sees it.
55
+
56
+ **Lesson**: Always verify persistence behavior in the source code.
57
+
58
+ ### 5. `session.prompt({ noReply: false })` blocks the tool pipeline
59
+
60
+ Calling `session.prompt` (without `noReply`) from within `tool.execute.after` hook blocks the entire tool execution pipeline waiting for LLM processing. The session state transitions cause conflicts.
61
+
62
+ **Lesson**: `tool.execute.after` runs synchronously within the tool execution Effect fiber. Don't block it.
63
+
64
+ ### 6. `promptAsync` has a different API shape than `prompt`
65
+
66
+ ```ts
67
+ // prompt (v1 SDK)
68
+ session.prompt({ id: string, parts: [...] })
69
+
70
+ // promptAsync (v1 SDK)
71
+ session.promptAsync({ path: { id: string }, body: { parts: [...] } })
72
+ ```
73
+
74
+ Using the `prompt` shape for `promptAsync` resulted in `{id}` not being substituted in the URL path, causing `%7Bid%7D` errors.
75
+
76
+ **Lesson**: Always check the generated SDK types. Don't assume consistent API shapes.
77
+
78
+ ### 7. `promptAsync` forks via `Effect.forkIn(scope)` — scope matters
79
+
80
+ The `promptAsync` handler forks the LLM loop into the HTTP request's scope. The forked fiber runs independently of the caller. The hook returns immediately while the LLM processes in the background. SSE events are emitted for the response.
81
+
82
+ **Key insight**: This is the only way to trigger LLM processing from a plugin hook without blocking. The `Effect.forkIn(scope, { startImmediately: true })` pattern makes the prompt fire-and-forget.
83
+
84
+ ### 8. TUI session messages are loaded once, then updated via SSE events only
85
+
86
+ The TUI loads session messages on first entry (`createResource` → `sync.session.sync()`). After that, messages are only added/updated via SSE events (`message.updated`). Direct DB writes by subprocesses produce no SSE events, so the TUI never shows them.
87
+
88
+ **Fix**: Use `promptAsync` (which goes through the server API and emits SSE) to trigger a visible response. The full imported history is in the DB and visible after restart.
89
+
90
+ ### 9. Cross-platform `sleep` needs a JavaScript spin-loop
91
+
92
+ `child_process.execSync('sleep 2')` fails on Windows. `execSync('timeout /t 2')` fails on Linux. Using `process.platform` branching is fragile.
93
+
94
+ **Fix**: Simple spin-loop `while (Date.now() < end) {}` works everywhere.
95
+
96
+ ### 10. Host config should be separate from code
97
+
98
+ Hardcoding SSH addresses in the plugin is inflexible and leaks info to the LLM.
99
+
100
+ **Fix**: `hosts.json` maps host names to addresses. Only names are injected into the tool description. The file is gitignored; `hosts.example.json` serves as a template.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # opencode-remote-login
2
+
3
+ Dispatch opencode sessions to remote hosts and continue tasks there.
4
+
5
+ ## Setup
6
+
7
+ 1. Copy `hosts.example.json` to `hosts.json` and configure your hosts:
8
+
9
+ ```json
10
+ {
11
+ "hosts": {
12
+ "pi": "user@192.168.1.100",
13
+ "dev": "user@10.0.0.5"
14
+ }
15
+ }
16
+ ```
17
+
18
+ 2. Ensure SSH key-based auth is configured for each host:
19
+
20
+ ```bash
21
+ ssh-copy-id user@192.168.1.100
22
+ ```
23
+
24
+ 3. Add to your `opencode.json`:
25
+
26
+ ```json
27
+ {
28
+ "plugin": ["../opencode-remote-login/src/index.ts"]
29
+ }
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### One-way mode (default)
35
+
36
+ Dispatch the session and return immediately. The remote host takes over autonomously.
37
+
38
+ ```
39
+ > call remote_login host=pi, we need to fix the login bug in auth.ts
40
+ ```
41
+
42
+ ### Round-trip mode
43
+
44
+ Dispatch, wait for remote to complete, then pull the updated session back locally.
45
+
46
+ ```
47
+ > call remote_login mode=round-trip host=pi, investigate the memory leak in worker.ts
48
+ ```
49
+
50
+ ## hostnames.json
51
+
52
+ Only host **names** are exposed to the LLM in the tool description. Actual SSH addresses remain private.
53
+
54
+ ```json
55
+ {
56
+ "hosts": {
57
+ "pi": "wenjun@192.168.100.100"
58
+ }
59
+ }
60
+ ```
61
+
62
+ The LLM sees: `Available hosts: pi`
63
+
64
+ ## How it works
65
+
66
+ ```
67
+ local opencode remote opencode
68
+ ───────────── ───────────────
69
+ 1. export session (JSON)
70
+ 2. SCP → remote import
71
+ 3. Trigger remote task (nohup)
72
+ 4. [round-trip] poll for back-file
73
+ 5. [round-trip] SCP ← remote export
74
+ 6. [round-trip] local import
75
+ ```
@@ -0,0 +1,9 @@
1
+ {
2
+ "hosts": {
3
+ "gpu-server": {
4
+ "host": "dev@10.0.0.5",
5
+ "agent": "build",
6
+ "model": "opencode/big-pickle"
7
+ }
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "opencode-remote-login",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ "./server": "./src/index.ts"
7
+ },
8
+ "engines": {
9
+ "opencode": "^1.0.0"
10
+ },
11
+ "dependencies": {
12
+ "@opencode-ai/plugin": "latest"
13
+ }
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,378 @@
1
+ import { execSync } from "child_process"
2
+ import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs"
3
+ import { tmpdir, homedir } from "os"
4
+ import { join, dirname } from "path"
5
+ import { randomBytes } from "crypto"
6
+ import { fileURLToPath } from "url"
7
+
8
+ const pluginDir = dirname(fileURLToPath(import.meta.url))
9
+
10
+ type HostConfig = { host: string; agent?: string; model?: string }
11
+
12
+ function opencodeConfigDir(): string {
13
+ if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR
14
+ const home = homedir()
15
+ if (process.platform === "win32") return join(process.env.APPDATA || home, "opencode")
16
+ if (process.platform === "darwin") return join(home, "Library", "Application Support", "opencode")
17
+ return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "opencode")
18
+ }
19
+
20
+ function loadHosts(inlineHosts: any): Record<string, HostConfig> {
21
+ const paths = [join(opencodeConfigDir(), "hosts.json"), join(pluginDir, "..", "hosts.json")]
22
+
23
+ const inline = parseHostsEntry(inlineHosts)
24
+ if (inline) return inline
25
+
26
+ for (const configPath of paths) {
27
+ if (!existsSync(configPath)) continue
28
+ try {
29
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"))
30
+ const parsed = parseHostsEntry(raw?.hosts)
31
+ if (parsed) return parsed
32
+ } catch {}
33
+ }
34
+ return {}
35
+ }
36
+
37
+ function parseHostsEntry(raw: unknown): Record<string, HostConfig> | undefined {
38
+ if (!raw || typeof raw !== "object") return undefined
39
+ const entries = raw as Record<string, unknown>
40
+ const result: Record<string, HostConfig> = {}
41
+ let found = false
42
+ for (const [name, value] of Object.entries(entries)) {
43
+ if (typeof value === "string") {
44
+ result[name] = { host: value }
45
+ found = true
46
+ } else if (value && typeof value === "object") {
47
+ const v = value as { host: string; agent?: string; model?: string }
48
+ if (typeof v.host === "string") {
49
+ result[name] = { host: v.host, agent: v.agent, model: v.model }
50
+ found = true
51
+ }
52
+ }
53
+ }
54
+ return found ? result : undefined
55
+ }
56
+
57
+ const plugin = {
58
+ id: "opencode-remote-login",
59
+ server: async (_input: any, options?: Record<string, unknown>) => {
60
+ const hosts = loadHosts(options?.hosts)
61
+ const hostList = Object.keys(hosts).sort().join(", ")
62
+
63
+ const localClient = _input.client
64
+ const localDir = _input.directory
65
+ const localOS = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"
66
+ return {
67
+ tool: {
68
+ remote_login: {
69
+ description: `Dispatch the current session to a remote opencode host and continue the task there.
70
+ Available hosts: ${hostList || "(none configured in hosts.json)"}`,
71
+ args: {
72
+ host: {
73
+ type: "string",
74
+ description: `Target host name. Available: ${hostList || "none"}`,
75
+ },
76
+ mode: {
77
+ type: "string",
78
+ description: "one-way: dispatch and return immediately. round-trip: wait for remote to finish, then pull the updated session back.",
79
+ },
80
+ directory: {
81
+ type: "string",
82
+ description: "Optional: working directory on the remote host (auto-created if missing). Defaults to home directory.",
83
+ },
84
+ },
85
+ async execute(args: any, ctx: any) {
86
+ const sessionID = ctx.sessionID
87
+ const hostName = ((args.host as string) ?? "").trim()
88
+ const hostCfg = hosts[hostName]
89
+ const host = hostCfg?.host ?? hostName
90
+ const mode = (args.mode as string) ?? "one-way"
91
+ const workDir = ((args.directory as string) ?? "").trim()
92
+
93
+ if (!hostName) {
94
+ return { output: `host is required. Available: ${hostList || "none"}` }
95
+ }
96
+
97
+ // 1. Export current session
98
+ let exportJson: string
99
+ try {
100
+ exportJson = execSync(`opencode export ${winQuote(sessionID)}`, {
101
+ encoding: "utf-8",
102
+ stdio: ["pipe", "pipe", "pipe"],
103
+ })
104
+ } catch (e: any) {
105
+ return { output: `Export failed: ${e.message}` }
106
+ }
107
+
108
+ if (!exportJson.trim()) {
109
+ return { output: `Export returned empty data for session ${sessionID}` }
110
+ }
111
+
112
+ // 2. Fix pending remote_login tool-call
113
+ let exportData: any
114
+ try { exportData = JSON.parse(exportJson) } catch {
115
+ return { output: "Export JSON parse failed" }
116
+ }
117
+ fixPendingRemoteLogin(exportData, hostName, sessionID)
118
+
119
+ const localAgent = exportData.info?.agent
120
+ const localModel = exportData.info?.model
121
+ if (hostCfg) patchAgentAndModel(exportData, hostCfg.agent, hostCfg.model)
122
+
123
+ // 3. Write JSON to temp file
124
+ const tmpFile = join(tmpdir(), `opencode-export-${randomBytes(6).toString("hex")}.json`)
125
+ writeFileSync(tmpFile, JSON.stringify(exportData), "utf-8")
126
+
127
+ // 4. SCP to remote
128
+ const remoteFile = `/tmp/opencode-import-${sessionID}.json`
129
+ try {
130
+ execSync(`scp ${winQuote(tmpFile)} ${host}:${remoteFile}`, {
131
+ encoding: "utf-8",
132
+ stdio: "pipe",
133
+ })
134
+ } catch (e: any) {
135
+ unlinkSync(tmpFile)
136
+ return { output: `SCP transfer failed: ${e.message}` }
137
+ }
138
+
139
+ // 5. Import on remote
140
+ try {
141
+ execSync(`ssh ${host} opencode import ${remoteFile}`, {
142
+ encoding: "utf-8",
143
+ stdio: "pipe",
144
+ })
145
+ } catch (e: any) {
146
+ unlinkSync(tmpFile)
147
+ return { output: `Remote import failed: ${e.message}` }
148
+ }
149
+
150
+ unlinkSync(tmpFile)
151
+ try { execSync(`ssh ${host} rm ${remoteFile}`, { stdio: "ignore" }) } catch { /* ok */ }
152
+
153
+ // 6. Execute on remote
154
+ const cdLine = workDir ? `mkdir -p ${workDir} && cd ${workDir}` : `cd ~`
155
+ const scriptFile = join(tmpdir(), `opencode-continue-${randomBytes(6).toString("hex")}.sh`)
156
+ const logFile = `/tmp/opencode-continue-${sessionID}.log`
157
+
158
+ if (mode === "round-trip") {
159
+ writeFileSync(scriptFile, [
160
+ `#!/bin/bash`,
161
+ cdLine,
162
+ `CWD=$(pwd)`,
163
+ `export MSG="[Environment]`,
164
+ `You are now on remote host ${hostName}, working directory: $CWD.`,
165
+ `This session was migrated from a ${localOS} machine — all conversation history, tool results, and task context are preserved.`,
166
+ ``,
167
+ `[Constraints]`,
168
+ `- The local machine's filesystem and tools are NOT accessible from here.`,
169
+ `- Use this host's filesystem, tools, and resources exclusively.`,
170
+ `- Do not delegate to other hosts or call remote_login again.`,
171
+ ``,
172
+ `[Task]`,
173
+ `Complete the assigned task on this host.`,
174
+ ``,
175
+ `[Return]`,
176
+ `After you finish, this session will be exported and pulled back to the original machine automatically. Stop immediately once the work is done. Do not continue chatting or ask follow-up questions."`,
177
+ `nohup bash -c 'opencode run --session=${sessionID} "$MSG" > ${logFile} 2>&1; opencode export ${sessionID} > /tmp/opencode-back-${sessionID}.json' &`,
178
+ ].join("\n"), "utf-8")
179
+
180
+ try {
181
+ execSync(`scp ${winQuote(scriptFile)} ${host}:/tmp/opencode-continue.sh`, { encoding: "utf-8", stdio: "pipe" })
182
+ unlinkSync(scriptFile)
183
+ execSync(`ssh ${host} "bash /tmp/opencode-continue.sh && rm /tmp/opencode-continue.sh"`, { encoding: "utf-8", stdio: "pipe" })
184
+ } catch (e: any) {
185
+ return { output: `Remote execution failed: ${e.message}` }
186
+ }
187
+
188
+ // Poll for back-file
189
+ const backRemote = `/tmp/opencode-back-${sessionID}.json`
190
+ let ready = false
191
+ for (let i = 0; i < 30; i++) {
192
+ wait(2)
193
+ try {
194
+ const r = execSync(`ssh ${host} "test -f ${backRemote} && echo ok"`, { encoding: "utf-8", stdio: "pipe" })
195
+ if (r.trim() === "ok") { ready = true; break }
196
+ } catch {}
197
+ }
198
+
199
+ if (!ready) {
200
+ return {
201
+ output: [
202
+ `Task dispatched to ${hostName} but results not ready after 60s.`,
203
+ `Pull manually: scp ${host}:${backRemote} . && opencode import <file>`,
204
+ ].join("\n"),
205
+ }
206
+ }
207
+
208
+ // Pull back
209
+ const backFile = join(tmpdir(), `opencode-back-${randomBytes(6).toString("hex")}.json`)
210
+ try {
211
+ execSync(`scp ${host}:${backRemote} ${winQuote(backFile)}`, { encoding: "utf-8", stdio: "pipe" })
212
+ execSync(`ssh ${host} rm ${backRemote}`, { stdio: "ignore" })
213
+ if (hostCfg) restoreAgentAndModel(backFile, localAgent, localModel)
214
+ execSync(`opencode import ${winQuote(backFile)}`, { encoding: "utf-8", stdio: "pipe" })
215
+ unlinkSync(backFile)
216
+ } catch (e: any) {
217
+ return { output: `Pull-back failed: ${e.message}` }
218
+ }
219
+
220
+ return {
221
+ title: `Round-trip completed via ${hostName}`,
222
+ output: [
223
+ `Session ${sessionID} completed on ${hostName} and pulled back.`,
224
+ `The updated session with remote results is now available locally.`,
225
+ ].join("\n"),
226
+ }
227
+ }
228
+
229
+ // One-way: nohup background
230
+ writeFileSync(scriptFile, [
231
+ `#!/bin/bash`,
232
+ cdLine,
233
+ `CWD=$(pwd)`,
234
+ `MSG="[Environment]`,
235
+ `You are now on remote host ${hostName}, working directory: $CWD.`,
236
+ `This session was migrated from a ${localOS} machine — all conversation history, tool results, and task context are preserved.`,
237
+ ``,
238
+ `[Constraints]`,
239
+ `- The local machine's filesystem and tools are NOT accessible from here.`,
240
+ `- Use this host's filesystem, tools, and resources exclusively.`,
241
+ `- Do not delegate to other hosts or call remote_login again.`,
242
+ ``,
243
+ `[Task]`,
244
+ `Continue the assigned task from where it left off. Complete the work and stop."`,
245
+ `nohup opencode run --session=${sessionID} "$MSG" > ${logFile} 2>&1 &`,
246
+ ].join("\n"), "utf-8")
247
+
248
+ try {
249
+ execSync(`scp ${winQuote(scriptFile)} ${host}:/tmp/opencode-continue.sh`, { encoding: "utf-8", stdio: "pipe" })
250
+ unlinkSync(scriptFile)
251
+ execSync(`ssh ${host} "bash /tmp/opencode-continue.sh && rm /tmp/opencode-continue.sh"`, { encoding: "utf-8", stdio: "pipe" })
252
+ } catch (e: any) {
253
+ return {
254
+ output: [
255
+ `Session ${sessionID} migrated to ${hostName}, but auto-continue failed: ${e.message}`,
256
+ `Run manually: ssh ${host} "opencode --session=${sessionID}"`,
257
+ ].join("\n"),
258
+ }
259
+ }
260
+
261
+ return {
262
+ title: `Task handed off to ${hostName}`,
263
+ output: [
264
+ `Session ${sessionID} has been migrated to ${hostName}. The remote host is executing the task.`,
265
+ `This conversation ends here. Do not reply.`,
266
+ ].join("\n"),
267
+ }
268
+ },
269
+ },
270
+ },
271
+ "tool.execute.after": async (input: any) => {
272
+ if (input.tool !== "remote_login") return
273
+ if (input.args?.mode !== "round-trip") return
274
+ const host = input.args?.host ?? "remote"
275
+ const dir = input.args?.directory || "~"
276
+ try {
277
+ await localClient.session.promptAsync({
278
+ path: { id: input.sessionID },
279
+ body: {
280
+ parts: [{
281
+ text: [
282
+ `[Status]`,
283
+ `Round-trip completed. Remote host "${host}" (${dir}) has finished execution and all results have been merged into this session.`,
284
+ ``,
285
+ `[Environment]`,
286
+ `You are now back on the local ${localOS} machine in ${localDir}.`,
287
+ ``,
288
+ `[Constraints]`,
289
+ `- Remote files and paths (e.g., /tmp, ~/ on "${host}") are NOT directly accessible from this environment.`,
290
+ `- Any further operations requiring the remote host must use the remote_login tool again.`,
291
+ ``,
292
+ `[Next Steps]`,
293
+ `Review the remote execution results from the conversation history above. Do not re-execute tasks already completed on the remote.`,
294
+ ].join("\n"),
295
+ type: "text",
296
+ }],
297
+ },
298
+ })
299
+ } catch {}
300
+ },
301
+ }
302
+ },
303
+ }
304
+
305
+ export default plugin
306
+
307
+ function winQuote(arg: string): string {
308
+ return `"${arg}"`
309
+ }
310
+
311
+ function wait(seconds: number) {
312
+ const end = Date.now() + seconds * 1000
313
+ while (Date.now() < end) {}
314
+ }
315
+
316
+ function patchAgentAndModel(data: any, agentVal: string | undefined, modelVal: string | undefined) {
317
+ if (!data?.info) return
318
+ const agent = ((agentVal as string) ?? "").trim()
319
+ const model = ((modelVal as string) ?? "").trim()
320
+
321
+ if (agent) {
322
+ data.info.agent = agent
323
+ }
324
+
325
+ if (model) {
326
+ const [providerID, ...rest] = model.split("/")
327
+ const modelID = rest.join("/")
328
+ if (providerID && modelID) {
329
+ data.info.model = { providerID, id: modelID }
330
+ }
331
+ }
332
+ }
333
+
334
+ function restoreAgentAndModel(filePath: string, agent: any, model: any) {
335
+ if (agent === undefined && model === undefined) return
336
+ try {
337
+ const data = JSON.parse(readFileSync(filePath, "utf-8"))
338
+ if (!data?.info) return
339
+ if (agent !== undefined) data.info.agent = agent
340
+ if (model !== undefined) data.info.model = model
341
+ writeFileSync(filePath, JSON.stringify(data), "utf-8")
342
+ } catch {}
343
+ }
344
+
345
+ function fixPendingRemoteLogin(data: any, hostName: string, sessionID: string) {
346
+ if (!data?.messages?.length) return
347
+ const msgs = data.messages as any[]
348
+ const last = msgs[msgs.length - 1]
349
+ if (!last?.info || last.info.role !== "assistant" || !last.parts?.length) return
350
+
351
+ const toolPart = last.parts.find((p: any) => p.type === "tool" && p.tool === "remote_login" && p.state?.status === "pending")
352
+ if (!toolPart) return
353
+
354
+ const now = Date.now()
355
+ const output = `Login successful. Session migrated to ${hostName}. Proceed with the task.`
356
+
357
+ toolPart.state = {
358
+ status: "completed",
359
+ input: { host: hostName },
360
+ output,
361
+ metadata: { truncated: false },
362
+ title: `Task handed off to ${hostName}`,
363
+ time: { start: now - 100, end: now },
364
+ }
365
+
366
+ last.parts.push({
367
+ reason: "tool-calls",
368
+ type: "step-finish",
369
+ tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { write: 0, read: 0 } },
370
+ cost: 0,
371
+ id: `prt_${randomBytes(8).toString("hex")}`,
372
+ sessionID,
373
+ messageID: last.info.id,
374
+ })
375
+
376
+ last.info.time = { ...last.info.time, completed: now }
377
+ last.info.finish = "tool-calls"
378
+ }