t3code-relay 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +30 -2
- package/dist/index.js +372 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Breaton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# T3 Code Relay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An ACP agent designed to hand off messages from an ACP client, like [Zed](https://zed.dev/), to an open [T3 Code](https://t3.codes/) thread.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
> [!NOTE]
|
|
8
|
+
> This is not designed to implement the full Agent Client Protocol. T3 Code responses will not be returned to the client, as the intent is to continue the conversation within T3 Code.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Run it from npm with `npx`. Here's an example setup for Zed's `settings.json`:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
"agent_servers": {
|
|
18
|
+
"T3 Code": {
|
|
19
|
+
"type": "custom",
|
|
20
|
+
"command": "npx",
|
|
21
|
+
"args": ["-y", "t3code-relay"]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Options
|
|
27
|
+
|
|
28
|
+
| Option | Description |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `--t3-bin <path>` | Use a specific `t3` CLI. |
|
|
31
|
+
| `--no-focus` | Don't bring T3 Code into focus on the target thread when sending a message. |
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { delimiter, dirname, join, sep } from "node:path";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
const { values: flags } = parseArgs({
|
|
11
|
+
args: process.argv.slice(2),
|
|
12
|
+
options: {
|
|
13
|
+
"t3-bin": { type: "string" },
|
|
14
|
+
debug: { type: "boolean", default: false },
|
|
15
|
+
"dry-run": { type: "boolean", default: false },
|
|
16
|
+
"no-focus": { type: "boolean", default: false },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
// T3's installer doesn't put its CLI on PATH, so its install folder is searched last.
|
|
20
|
+
process.env.PATH = [process.env.PATH, join(homedir(), ".t3/bin")].filter(Boolean).join(delimiter);
|
|
21
|
+
const T3 = flags["t3-bin"] ?? "t3";
|
|
22
|
+
const SCRATCH = real(join(process.env.T3CODE_HOME ?? join(homedir(), ".t3"), "scratch"));
|
|
23
|
+
// How many threads to look up at once when matching workspaces.
|
|
24
|
+
const SCAN_BATCH = 5;
|
|
25
|
+
// How many threads to offer when several match; each one becomes a button in the ACP client.
|
|
26
|
+
const MAX_CHOICES = 4;
|
|
27
|
+
const DONT_SEND = "dont-send";
|
|
28
|
+
// Logs go to stderr; stdout carries the ACP protocol.
|
|
29
|
+
const log = (...args) => console.error("[t3code-relay]", ...args);
|
|
30
|
+
// threadId is the thread the user chose for this chat, when they had to choose.
|
|
31
|
+
const sessions = new Map();
|
|
32
|
+
// Run a command to completion and capture its output.
|
|
33
|
+
function run(command, args, options = {}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = spawn(command, args, { cwd: options.cwd });
|
|
36
|
+
let out = "";
|
|
37
|
+
let err = "";
|
|
38
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => (out += chunk));
|
|
39
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => (err += chunk));
|
|
40
|
+
child.on("error", reject);
|
|
41
|
+
child.on("close", (code) => resolve({ code, out, err }));
|
|
42
|
+
// A child that exits without reading stdin surfaces through its exit code,
|
|
43
|
+
// so the resulting EPIPE must not take the relay down.
|
|
44
|
+
child.stdin.on("error", (e) => log("stdin write failed:", e));
|
|
45
|
+
child.stdin.end(options.stdin);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function t3(args, cwd, stdin) {
|
|
49
|
+
const { code, out, err } = await run(T3, args, { cwd, stdin });
|
|
50
|
+
if (code !== 0)
|
|
51
|
+
throw new Error(`t3 ${args.join(" ")} failed (${code}): ${err.trim() || out.trim()}`);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function real(p) {
|
|
55
|
+
try {
|
|
56
|
+
return realpathSync(p);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const contains = (root, p) => p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
63
|
+
const newestFirst = (a, b) => b.updatedAt.localeCompare(a.updatedAt);
|
|
64
|
+
// Nearest existing directory for a file or directory path.
|
|
65
|
+
function existingDir(p) {
|
|
66
|
+
for (let d = p;; d = dirname(d)) {
|
|
67
|
+
try {
|
|
68
|
+
return real(statSync(d).isDirectory() ? d : dirname(d));
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
if (dirname(d) === d)
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function git(dir, args) {
|
|
76
|
+
const { code, out } = await run("git", ["-C", dir, ...args]);
|
|
77
|
+
return code === 0 && out.trim() ? out.trim() : undefined;
|
|
78
|
+
}
|
|
79
|
+
// For a path in a git checkout: the main checkout, and whether the path is in a linked worktree.
|
|
80
|
+
async function gitInfo(dir) {
|
|
81
|
+
const [top, common] = await Promise.all([
|
|
82
|
+
git(dir, ["rev-parse", "--show-toplevel"]),
|
|
83
|
+
git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]),
|
|
84
|
+
]);
|
|
85
|
+
const main = common?.endsWith("/.git") ? real(dirname(common)) : undefined;
|
|
86
|
+
return { main, inLinkedWorktree: !!top && !!main && real(top) !== main };
|
|
87
|
+
}
|
|
88
|
+
// A thread's worktreePath rarely changes, so cache it briefly to keep sends fast.
|
|
89
|
+
const worktreeCache = new Map();
|
|
90
|
+
async function worktreeOf(t) {
|
|
91
|
+
const hit = worktreeCache.get(t.threadId);
|
|
92
|
+
if (hit && Date.now() - hit.at < 60_000)
|
|
93
|
+
return hit.path;
|
|
94
|
+
const { thread } = JSON.parse(await t3(["thread", "show", t.threadId, "--messages", "0", "--json"], homedir()));
|
|
95
|
+
const path = thread.worktreePath ? real(thread.worktreePath) : null;
|
|
96
|
+
worktreeCache.set(t.threadId, { at: Date.now(), path });
|
|
97
|
+
return path;
|
|
98
|
+
}
|
|
99
|
+
// Find the threads whose workspace contains the given paths (quoted files first,
|
|
100
|
+
// then the ACP client's cwd), newest first. The first path with any match decides.
|
|
101
|
+
// A path in a T3 worktree or scratch folder matches the thread working there;
|
|
102
|
+
// a path in the main checkout matches threads without a worktree.
|
|
103
|
+
// Workspace matching only considers open (non-settled) threads.
|
|
104
|
+
// No match returns an empty list, so a new thread can start in the project
|
|
105
|
+
// folder. The t3 CLI can't start one in an existing worktree, so that's an error.
|
|
106
|
+
async function findThreads(paths) {
|
|
107
|
+
let lastError;
|
|
108
|
+
let firstProject;
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const raw of paths) {
|
|
111
|
+
const dir = existingDir(raw);
|
|
112
|
+
if (!dir || seen.has(dir))
|
|
113
|
+
continue;
|
|
114
|
+
seen.add(dir);
|
|
115
|
+
const { main, inLinkedWorktree } = await gitInfo(dir);
|
|
116
|
+
let threads;
|
|
117
|
+
let projectDir;
|
|
118
|
+
for (const p of new Set([dir, main].filter((x) => !!x))) {
|
|
119
|
+
try {
|
|
120
|
+
threads = JSON.parse(await t3(["thread", "list", "--project", p, "--json"], p));
|
|
121
|
+
projectDir = p;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
lastError = e;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!threads || !projectDir)
|
|
129
|
+
continue;
|
|
130
|
+
const candidates = threads.filter((t) => !t.parentThreadId).sort(newestFirst);
|
|
131
|
+
firstProject ??= { dir, projectDir, inLinkedWorktree, latest: candidates[0] };
|
|
132
|
+
// Scratch folders are named "<date>-<slug>-<last 8 chars of the thread id>".
|
|
133
|
+
const suffix = t3ScratchSuffix(dir);
|
|
134
|
+
const owner = suffix && candidates.find((t) => t.threadId.endsWith(suffix));
|
|
135
|
+
if (owner)
|
|
136
|
+
return { matches: [{ thread: owner, reason: `owns scratch folder …-${suffix}` }], projectDir };
|
|
137
|
+
// Only open (non-settled) threads are matched by workspace.
|
|
138
|
+
const scan = candidates.filter((t) => !t.settled);
|
|
139
|
+
const matches = [];
|
|
140
|
+
for (let i = 0; i < scan.length; i += SCAN_BATCH) {
|
|
141
|
+
const batch = scan.slice(i, i + SCAN_BATCH);
|
|
142
|
+
const wts = await Promise.all(batch.map((t) => worktreeOf(t).catch(() => undefined)));
|
|
143
|
+
batch.forEach((thread, idx) => {
|
|
144
|
+
const wt = wts[idx];
|
|
145
|
+
if (wt ? contains(wt, dir) : wt === null && !inLinkedWorktree) {
|
|
146
|
+
matches.push({ thread, reason: wt ? `works in ${wt}` : "works in the main checkout" });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (matches.length > 0)
|
|
151
|
+
return { matches, projectDir };
|
|
152
|
+
}
|
|
153
|
+
// No project at all (or T3 isn't running): surface the t3 error itself.
|
|
154
|
+
if (!firstProject && lastError)
|
|
155
|
+
throw lastError;
|
|
156
|
+
if (firstProject?.inLinkedWorktree) {
|
|
157
|
+
throw new Error(`No open T3 thread is working in ${firstProject.dir}, and t3 can't start one in an existing worktree.`);
|
|
158
|
+
}
|
|
159
|
+
if (firstProject)
|
|
160
|
+
return { matches: [], projectDir: firstProject.projectDir, latest: firstProject.latest };
|
|
161
|
+
throw new Error(`No T3 thread is working in ${[...seen].join(" or ") || "these paths"}.`);
|
|
162
|
+
}
|
|
163
|
+
function t3ScratchSuffix(dir) {
|
|
164
|
+
if (!contains(SCRATCH, dir) || dir === SCRATCH)
|
|
165
|
+
return undefined;
|
|
166
|
+
const folder = dir.slice(SCRATCH.length + 1).split(sep)[0];
|
|
167
|
+
return /-([0-9a-f]{8})$/.exec(folder)?.[1];
|
|
168
|
+
}
|
|
169
|
+
// Bring the T3 Code desktop app to the thread. Thread links look like
|
|
170
|
+
// https://app.t3.codes/threads/<environmentId>/<threadId>, or without the
|
|
171
|
+
// /threads prefix on a local server; the app only accepts
|
|
172
|
+
// t3code://threads/<environmentId>/<threadId>.
|
|
173
|
+
async function openInT3(thread) {
|
|
174
|
+
if (flags["no-focus"] || !thread.link)
|
|
175
|
+
return;
|
|
176
|
+
const url = `t3code://threads${new URL(thread.link).pathname.replace(/^\/threads(?=\/)/, "")}`;
|
|
177
|
+
// Opening is best effort and runs alongside the send, so it must never reject.
|
|
178
|
+
try {
|
|
179
|
+
const { code } = await run("open", [url]);
|
|
180
|
+
if (code !== 0)
|
|
181
|
+
log(`couldn't open ${url} (exit ${code})`);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
log(`couldn't open ${url}`, e);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function startThread(projectDir, message, latest) {
|
|
188
|
+
const start = (model) => t3(["thread", "start", ...model, projectDir, "-"], projectDir, message);
|
|
189
|
+
let output;
|
|
190
|
+
try {
|
|
191
|
+
output = await start([]);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
// A project with no default model needs one named, so reuse its latest thread's.
|
|
195
|
+
// t3 rejects the start before launching anything, so retrying can't duplicate it.
|
|
196
|
+
if (!String(e).includes("no default model") || !latest?.providerInstanceId || !latest.model)
|
|
197
|
+
throw e;
|
|
198
|
+
output = await start(["--provider", latest.providerInstanceId, "--model", latest.model]);
|
|
199
|
+
}
|
|
200
|
+
const threadId = output.trim();
|
|
201
|
+
log(`started ${threadId} in ${projectDir}`);
|
|
202
|
+
// The message has already landed, so a failure here must not be reported as a failed send.
|
|
203
|
+
try {
|
|
204
|
+
if (flags["no-focus"])
|
|
205
|
+
return;
|
|
206
|
+
const { thread } = JSON.parse(await t3(["thread", "show", threadId, "--messages", "0", "--json"], projectDir));
|
|
207
|
+
await openInT3(thread);
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
log(`couldn't open new thread ${threadId}`, e);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function filePath(uri) {
|
|
214
|
+
return uri.startsWith("file://") ? fileURLToPath(uri.split("#")[0]) : undefined;
|
|
215
|
+
}
|
|
216
|
+
function displayPath(uri, cwd) {
|
|
217
|
+
let path = uri;
|
|
218
|
+
if (uri.startsWith("file://")) {
|
|
219
|
+
const [base, frag] = uri.split("#");
|
|
220
|
+
path = fileURLToPath(base) + (frag ? `#${frag}` : "");
|
|
221
|
+
}
|
|
222
|
+
return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
|
|
223
|
+
}
|
|
224
|
+
// Flatten the prompt's content blocks into one markdown message.
|
|
225
|
+
function toMarkdown(blocks, cwd) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
for (const b of blocks) {
|
|
228
|
+
switch (b.type) {
|
|
229
|
+
case "text":
|
|
230
|
+
parts.push(b.text);
|
|
231
|
+
break;
|
|
232
|
+
case "resource":
|
|
233
|
+
if ("text" in b.resource) {
|
|
234
|
+
parts.push(`\`${displayPath(b.resource.uri, cwd)}\`\n\`\`\`\n${b.resource.text}\n\`\`\``);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
parts.push(`\`${displayPath(b.resource.uri, cwd)}\` (binary)`);
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
case "resource_link":
|
|
241
|
+
parts.push(`\`${displayPath(b.uri, cwd)}\``);
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
parts.push(`[${b.type} omitted]`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return parts.join("\n\n").trim();
|
|
248
|
+
}
|
|
249
|
+
const app = acp
|
|
250
|
+
.agent({ name: "t3code-relay" })
|
|
251
|
+
.onRequest(acp.methods.agent.initialize, () => ({
|
|
252
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
253
|
+
agentCapabilities: {
|
|
254
|
+
// Load and resume are accepted so the ACP client can reopen old chats after a restart.
|
|
255
|
+
// The relay keeps no history, so nothing is replayed and they reopen empty.
|
|
256
|
+
loadSession: true,
|
|
257
|
+
sessionCapabilities: { resume: {} },
|
|
258
|
+
promptCapabilities: { embeddedContext: true, image: false, audio: false },
|
|
259
|
+
},
|
|
260
|
+
agentInfo: { name: "t3code-relay", title: "T3 Code Relay", version: "0.1.0" },
|
|
261
|
+
authMethods: [],
|
|
262
|
+
}))
|
|
263
|
+
.onRequest(acp.methods.agent.session.new, ({ params }) => {
|
|
264
|
+
const sessionId = crypto.randomUUID();
|
|
265
|
+
sessions.set(sessionId, { cwd: params.cwd });
|
|
266
|
+
return { sessionId };
|
|
267
|
+
})
|
|
268
|
+
.onRequest(acp.methods.agent.session.load, ({ params }) => {
|
|
269
|
+
sessions.set(params.sessionId, { cwd: params.cwd });
|
|
270
|
+
return {};
|
|
271
|
+
})
|
|
272
|
+
.onRequest(acp.methods.agent.session.resume, ({ params }) => {
|
|
273
|
+
sessions.set(params.sessionId, { cwd: params.cwd });
|
|
274
|
+
return {};
|
|
275
|
+
})
|
|
276
|
+
.onRequest(acp.methods.agent.session.prompt, async ({ params, client }) => {
|
|
277
|
+
// A chat the ACP client reopened without loading it first still works; the
|
|
278
|
+
// ACP client starts the relay in the project folder, so that stands in for
|
|
279
|
+
// the chat's cwd.
|
|
280
|
+
const session = sessions.get(params.sessionId) ?? { cwd: process.cwd() };
|
|
281
|
+
sessions.set(params.sessionId, session);
|
|
282
|
+
if (flags.debug)
|
|
283
|
+
log("prompt", JSON.stringify(params.prompt));
|
|
284
|
+
const say = (text) => client.notify(acp.methods.client.session.update, {
|
|
285
|
+
sessionId: params.sessionId,
|
|
286
|
+
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
|
|
287
|
+
});
|
|
288
|
+
// ACP has no general "pick one" request that clients reliably support, so
|
|
289
|
+
// the choice is presented as a permission prompt on a placeholder tool call.
|
|
290
|
+
const askWhichThread = async (matches) => {
|
|
291
|
+
const toolCallId = crypto.randomUUID();
|
|
292
|
+
// The SDK's method-keyed overload infers this response as unknown, so name the types.
|
|
293
|
+
const { outcome } = await client.request(acp.methods.client.session.requestPermission, {
|
|
294
|
+
sessionId: params.sessionId,
|
|
295
|
+
toolCall: {
|
|
296
|
+
toolCallId,
|
|
297
|
+
title: "Several T3 threads are open in this workspace. Which one should get this?",
|
|
298
|
+
kind: "other",
|
|
299
|
+
status: "pending",
|
|
300
|
+
},
|
|
301
|
+
options: [
|
|
302
|
+
...matches
|
|
303
|
+
.slice(0, MAX_CHOICES)
|
|
304
|
+
.map((m) => ({ optionId: m.thread.threadId, name: m.thread.title, kind: "allow_once" })),
|
|
305
|
+
{ optionId: DONT_SEND, name: "Don't send", kind: "reject_once" },
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
const chosen = outcome.outcome === "selected" ? matches.find((m) => m.thread.threadId === outcome.optionId) : undefined;
|
|
309
|
+
// The client leaves the placeholder running until it's told otherwise.
|
|
310
|
+
await client.notify(acp.methods.client.session.update, {
|
|
311
|
+
sessionId: params.sessionId,
|
|
312
|
+
update: {
|
|
313
|
+
sessionUpdate: "tool_call_update",
|
|
314
|
+
toolCallId,
|
|
315
|
+
status: chosen ? "completed" : "failed",
|
|
316
|
+
title: chosen ? `Sending to ${chosen.thread.title}` : "Not sent",
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
return chosen;
|
|
320
|
+
};
|
|
321
|
+
const message = toMarkdown(params.prompt, session.cwd);
|
|
322
|
+
if (!message) {
|
|
323
|
+
await say("Nothing to send.");
|
|
324
|
+
return { stopReason: "end_turn" };
|
|
325
|
+
}
|
|
326
|
+
const quoted = params.prompt.flatMap((b) => {
|
|
327
|
+
const uri = b.type === "resource" ? b.resource.uri : b.type === "resource_link" ? b.uri : undefined;
|
|
328
|
+
const path = uri && filePath(uri);
|
|
329
|
+
return path ? [path] : [];
|
|
330
|
+
});
|
|
331
|
+
try {
|
|
332
|
+
const { matches, projectDir, latest } = await findThreads([...quoted, session.cwd]);
|
|
333
|
+
if (matches.length === 0) {
|
|
334
|
+
if (flags["dry-run"])
|
|
335
|
+
await say(`Would start a new thread in ${projectDir}`);
|
|
336
|
+
else
|
|
337
|
+
await startThread(projectDir, message, latest);
|
|
338
|
+
return { stopReason: "end_turn" };
|
|
339
|
+
}
|
|
340
|
+
// A choice made earlier in this chat holds for as long as that thread still matches.
|
|
341
|
+
const match = matches.length === 1
|
|
342
|
+
? matches[0]
|
|
343
|
+
: (matches.find((m) => m.thread.threadId === session.threadId) ?? (await askWhichThread(matches)));
|
|
344
|
+
if (!match)
|
|
345
|
+
return { stopReason: "cancelled" };
|
|
346
|
+
if (matches.length > 1)
|
|
347
|
+
session.threadId = match.thread.threadId;
|
|
348
|
+
const { thread, reason } = match;
|
|
349
|
+
if (flags["dry-run"]) {
|
|
350
|
+
await say(`Would send to **${thread.title}** (${reason})`);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Jump to the thread as soon as it's known; the message lands a moment later.
|
|
354
|
+
const opening = openInT3(thread);
|
|
355
|
+
await t3(["thread", "send", thread.threadId, "-"], projectDir, message);
|
|
356
|
+
log(`sent to ${thread.title} (${thread.threadId}): ${reason}`);
|
|
357
|
+
await opening;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
// Only failures get a reply, so the message isn't silently lost.
|
|
362
|
+
log(e);
|
|
363
|
+
await say(`Couldn't forward to T3: ${e instanceof Error ? e.message : String(e)}`);
|
|
364
|
+
}
|
|
365
|
+
return { stopReason: "end_turn" };
|
|
366
|
+
})
|
|
367
|
+
.onNotification(acp.methods.agent.session.cancel, () => { });
|
|
368
|
+
const stdout = new WritableStream({
|
|
369
|
+
write: (chunk) => new Promise((resolve) => process.stdout.write(chunk, () => resolve())),
|
|
370
|
+
});
|
|
371
|
+
const stdin = Readable.toWeb(process.stdin);
|
|
372
|
+
app.connect(acp.ndJsonStream(stdout, stdin));
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "t3code-relay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An ACP agent that hands off messages from an ACP client, like Zed, to an open T3 Code thread.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"acp",
|
|
7
|
+
"agent-client-protocol",
|
|
8
|
+
"t3code",
|
|
9
|
+
"zed"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/nickbreaton/t3code-relay#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/nickbreaton/t3code-relay/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/nickbreaton/t3code-relay.git"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Nick Breaton",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"t3code-relay": "dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
33
|
+
"lint": "oxlint",
|
|
34
|
+
"prepack": "npm run build",
|
|
35
|
+
"test": "node --test --experimental-test-module-mocks src/index.test.ts",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@agentclientprotocol/sdk": "^2.4.0",
|
|
40
|
+
"zod": "^4.6.7"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@oxlint/plugins": "1.91.0",
|
|
44
|
+
"@types/node": "^26.6.6",
|
|
45
|
+
"oxlint": "1.91.0",
|
|
46
|
+
"typescript": "^7.0.2"
|
|
47
|
+
}
|
|
48
|
+
}
|