talon-agent 1.49.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/codex/handler/message.ts +10 -0
- package/src/backend/kilo/factory.ts +2 -0
- package/src/backend/kilo/handler/message.ts +10 -0
- package/src/backend/openai-agents/factory.ts +2 -0
- package/src/backend/openai-agents/handler/message.ts +10 -0
- package/src/backend/opencode/factory.ts +2 -0
- package/src/backend/opencode/handler/message.ts +10 -0
- package/src/backend/shared/index.ts +4 -0
- package/src/backend/shared/turn-interrupt.ts +61 -0
- package/src/bootstrap.ts +5 -0
- package/src/cli/events.ts +47 -12
- package/src/cli/fs.ts +138 -0
- package/src/cli/index.ts +31 -7
- package/src/cli/tasks.ts +85 -20
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/native.ts +151 -24
- package/src/core/engine/gateway-actions/vfs.ts +69 -0
- package/src/core/engine/gateway.ts +28 -0
- package/src/core/tasks/table.ts +1 -1
- package/src/core/tasks/types.ts +6 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/native.ts +19 -6
- package/src/core/tools/types.ts +2 -1
- package/src/core/tools/vfs.ts +61 -0
- package/src/core/vfs/index.ts +113 -0
- package/src/core/vfs/mounts/files.ts +154 -0
- package/src/core/vfs/mounts/plugins.ts +72 -0
- package/src/core/vfs/mounts/proc.ts +125 -0
- package/src/core/vfs/types.ts +103 -0
- package/src/core/vfs/vfs.ts +237 -0
- package/src/core/weaver/weaver.ts +40 -3
- package/src/frontend/native/discovery.ts +3 -0
- package/src/frontend/native/index.ts +10 -1
- package/src/frontend/native/server.ts +65 -6
- package/src/frontend/native/tls.ts +313 -0
- package/src/storage/journal.ts +91 -0
- package/src/storage/repositories/journal-repo.ts +38 -0
- package/src/storage/sql/journal.sql +16 -0
- package/src/storage/sql/schema.sql +15 -0
- package/src/storage/sql/statements.generated.ts +24 -1
- package/src/util/config.ts +9 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +2 -0
package/src/cli/tasks.ts
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* `talon ps` / `talon kill` — the task table from the outside.
|
|
3
3
|
*
|
|
4
4
|
* Data comes from the running daemon's gateway (`GET /tasks`,
|
|
5
|
-
* `POST /tasks/kill`);
|
|
6
|
-
*
|
|
7
|
-
* lives in
|
|
5
|
+
* `POST /tasks/kill`); `--all` also folds in settled runs from the
|
|
6
|
+
* durable journal in talon.db, which answers across restarts — with or
|
|
7
|
+
* without a daemon. This module only renders — the table lives in
|
|
8
|
+
* core/tasks/, the journal in storage/journal.ts.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import pc from "picocolors";
|
|
12
|
+
import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
11
13
|
import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
|
|
12
14
|
import type {
|
|
13
15
|
KillOutcome,
|
|
@@ -64,7 +66,12 @@ function displayOrder(tasks: TaskRecord[]): TaskRecord[] {
|
|
|
64
66
|
(t) => t.state !== "running" && t.state !== "queued",
|
|
65
67
|
);
|
|
66
68
|
live.sort((a, b) => a.id - b.id);
|
|
67
|
-
|
|
69
|
+
// By time, not id: journal history spans daemon restarts, and per-process
|
|
70
|
+
// ids restart with the daemon.
|
|
71
|
+
settled.sort(
|
|
72
|
+
(a, b) =>
|
|
73
|
+
(b.endedAt ?? b.queuedAt) - (a.endedAt ?? a.queuedAt) || b.id - a.id,
|
|
74
|
+
);
|
|
68
75
|
return [...live, ...settled];
|
|
69
76
|
}
|
|
70
77
|
|
|
@@ -105,27 +112,85 @@ function renderTable(tasks: TaskRecord[], now: number): void {
|
|
|
105
112
|
console.log();
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
|
|
115
|
+
/** Settled runs from the journal, minus any already in the live table. */
|
|
116
|
+
async function journalTasks(
|
|
117
|
+
liveTasks: readonly TaskRecord[],
|
|
118
|
+
limit: number,
|
|
119
|
+
): Promise<TaskRecord[]> {
|
|
120
|
+
const { readJournal } = await import("../storage/journal.js");
|
|
121
|
+
const seen = new Set(liveTasks.map((t) => `${t.id}:${t.queuedAt}`));
|
|
122
|
+
const historical: TaskRecord[] = [];
|
|
123
|
+
for (const entry of readJournal({ type: "task.settled", limit })) {
|
|
124
|
+
if (entry.event.type !== "task.settled") continue;
|
|
125
|
+
const task = entry.event.task;
|
|
126
|
+
// (id, queuedAt) identifies a run across surfaces — per-process ids
|
|
127
|
+
// repeat between daemon runs, enqueue times don't.
|
|
128
|
+
if (seen.has(`${task.id}:${task.queuedAt}`)) continue;
|
|
129
|
+
seen.add(`${task.id}:${task.queuedAt}`);
|
|
130
|
+
historical.push(task);
|
|
131
|
+
}
|
|
132
|
+
return historical;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function showTasks(all = false): Promise<void> {
|
|
109
136
|
console.log();
|
|
110
|
-
const port = await requireGatewayPort();
|
|
111
|
-
if (port === null) return;
|
|
112
137
|
|
|
113
|
-
let tasks: TaskRecord[];
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
let tasks: TaskRecord[] = [];
|
|
139
|
+
let daemonUp = true;
|
|
140
|
+
if (all) {
|
|
141
|
+
// History works without a daemon — fold in the live table only when
|
|
142
|
+
// one is actually running.
|
|
143
|
+
const instance = await findRunningInstance();
|
|
144
|
+
if (instance?.port) {
|
|
145
|
+
try {
|
|
146
|
+
const body = (await fetchGateway(instance.port, "/tasks")) as {
|
|
147
|
+
tasks?: TaskRecord[];
|
|
148
|
+
};
|
|
149
|
+
tasks = body.tasks ?? [];
|
|
150
|
+
} catch {
|
|
151
|
+
daemonUp = false;
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
daemonUp = false;
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const port = await requireGatewayPort();
|
|
158
|
+
if (port === null) return;
|
|
159
|
+
try {
|
|
160
|
+
const body = (await fetchGateway(port, "/tasks")) as {
|
|
161
|
+
tasks?: TaskRecord[];
|
|
162
|
+
};
|
|
163
|
+
tasks = body.tasks ?? [];
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.log(
|
|
166
|
+
` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (all) {
|
|
173
|
+
if (!daemonUp) {
|
|
174
|
+
console.log(
|
|
175
|
+
` ${pc.dim("Talon is not running — journal history only.")}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
tasks = [...tasks, ...(await journalTasks(tasks, 200))];
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.log(
|
|
182
|
+
` ${pc.red("✖")} Could not read the journal: ${err instanceof Error ? err.message : err}\n`,
|
|
183
|
+
);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
124
187
|
}
|
|
125
188
|
|
|
126
189
|
if (tasks.length === 0) {
|
|
127
190
|
console.log(
|
|
128
|
-
|
|
191
|
+
all
|
|
192
|
+
? ` ${pc.dim("No tasks — nothing live and the journal is empty.")}\n`
|
|
193
|
+
: ` ${pc.dim("No tasks — the table starts empty on each daemon start. Older runs: talon ps --all")}\n`,
|
|
129
194
|
);
|
|
130
195
|
return;
|
|
131
196
|
}
|
|
@@ -174,7 +239,7 @@ export async function killTask(rawId: string | undefined): Promise<void> {
|
|
|
174
239
|
return;
|
|
175
240
|
case "not-killable":
|
|
176
241
|
console.log(
|
|
177
|
-
` ${pc.yellow("!")} Task ${id} has no abort hook
|
|
242
|
+
` ${pc.yellow("!")} Task ${id} has no abort hook — its backend can't interrupt a running turn.\n`,
|
|
178
243
|
);
|
|
179
244
|
return;
|
|
180
245
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* - `plugins` — plugin hot-reload
|
|
18
18
|
* - `models` — model / backend discovery
|
|
19
19
|
* - `mesh` — companion device mesh (presence + location)
|
|
20
|
+
* - `vfs` — the unified talon:// namespace (list/read/write)
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import type { ActionResult } from "../../types.js";
|
|
@@ -33,6 +34,7 @@ import { pluginHandlers } from "./plugins.js";
|
|
|
33
34
|
import { modelHandlers } from "./models.js";
|
|
34
35
|
import { meshHandlers } from "./mesh.js";
|
|
35
36
|
import { nativeHandlers } from "./native.js";
|
|
37
|
+
import { vfsHandlers } from "./vfs.js";
|
|
36
38
|
|
|
37
39
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
38
40
|
// can't resolve an inherited Object.prototype method — `handlers[action]` only
|
|
@@ -49,6 +51,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
49
51
|
...modelHandlers,
|
|
50
52
|
...meshHandlers,
|
|
51
53
|
...nativeHandlers,
|
|
54
|
+
...vfsHandlers,
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
export async function handleSharedAction(
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
setTeleport,
|
|
34
34
|
setTeleportCwd,
|
|
35
35
|
} from "../../mesh/teleport.js";
|
|
36
|
+
import { getVfs } from "../../vfs/index.js";
|
|
36
37
|
import {
|
|
37
38
|
clampExecOutput,
|
|
38
39
|
createOutputCapture,
|
|
@@ -447,6 +448,65 @@ async function bashTeleported(
|
|
|
447
448
|
};
|
|
448
449
|
}
|
|
449
450
|
|
|
451
|
+
// ── talon:// address resolution ─────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
const NAMESPACE_SCHEME = "talon://";
|
|
454
|
+
|
|
455
|
+
type AddressResolution =
|
|
456
|
+
| { kind: "disk"; path: string }
|
|
457
|
+
| { kind: "virtual" }
|
|
458
|
+
| { kind: "error"; text: string };
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Native fs tools accept talon:// addresses: `Vfs.locate` maps them to the
|
|
462
|
+
* disk location the address names, and the tool then operates on the real
|
|
463
|
+
* file. Synthetic nodes (proc/, plugins/) have no disk location — reads are
|
|
464
|
+
* served straight from the namespace, mutation refuses honestly. Teleport
|
|
465
|
+
* can't cross address spaces: talon:// names daemon state, and a teleported
|
|
466
|
+
* chat's paths belong to the device, so the combination is refused rather
|
|
467
|
+
* than resolved against the wrong filesystem.
|
|
468
|
+
*/
|
|
469
|
+
function resolveNamespaceAddress(
|
|
470
|
+
address: string,
|
|
471
|
+
teleportedTo: string | undefined,
|
|
472
|
+
): AddressResolution {
|
|
473
|
+
if (teleportedTo !== undefined) {
|
|
474
|
+
return {
|
|
475
|
+
kind: "error",
|
|
476
|
+
text:
|
|
477
|
+
`${address} names the daemon's namespace, but native tools are ` +
|
|
478
|
+
`teleported onto ${teleportedTo} — teleport_back first, or use a device path.`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const located = getVfs().locate(address);
|
|
482
|
+
if (!located.ok) {
|
|
483
|
+
return {
|
|
484
|
+
kind: "error",
|
|
485
|
+
text: `Cannot resolve ${address}: ${located.error}${located.detail ? ` — ${located.detail}` : ""}`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return located.value === undefined
|
|
489
|
+
? { kind: "virtual" }
|
|
490
|
+
: { kind: "disk", path: located.value };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** glob/search root: a talon:// address must name a disk-backed directory. */
|
|
494
|
+
function resolveSearchRoot(
|
|
495
|
+
path: string | undefined,
|
|
496
|
+
teleportedTo: string | undefined,
|
|
497
|
+
): { root: string } | { error: string } {
|
|
498
|
+
const root = path ?? ".";
|
|
499
|
+
if (!root.startsWith(NAMESPACE_SCHEME)) return { root };
|
|
500
|
+
const resolved = resolveNamespaceAddress(root, teleportedTo);
|
|
501
|
+
if (resolved.kind === "error") return { error: resolved.text };
|
|
502
|
+
if (resolved.kind === "virtual") {
|
|
503
|
+
return {
|
|
504
|
+
error: `${root} is a synthetic view with no files on disk — browse it with vfs_list instead.`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return { root: resolved.path };
|
|
508
|
+
}
|
|
509
|
+
|
|
450
510
|
// ── read / write / edit ─────────────────────────────────────────────────────
|
|
451
511
|
|
|
452
512
|
async function read(
|
|
@@ -455,15 +515,38 @@ async function read(
|
|
|
455
515
|
offset: unknown,
|
|
456
516
|
limit: unknown,
|
|
457
517
|
): Promise<Result> {
|
|
458
|
-
const
|
|
459
|
-
if (!
|
|
518
|
+
const address = str(path);
|
|
519
|
+
if (!address) return { ok: false, text: "A file path is required." };
|
|
460
520
|
const active = await getTeleport(chatId);
|
|
461
521
|
const where = active ? active.deviceName : "local";
|
|
462
522
|
|
|
523
|
+
let p = address;
|
|
524
|
+
let virtualContent: string | undefined;
|
|
525
|
+
if (address.startsWith(NAMESPACE_SCHEME)) {
|
|
526
|
+
const resolved = resolveNamespaceAddress(address, active?.deviceName);
|
|
527
|
+
if (resolved.kind === "error") return { ok: false, text: resolved.text };
|
|
528
|
+
if (resolved.kind === "disk") {
|
|
529
|
+
p = resolved.path;
|
|
530
|
+
} else {
|
|
531
|
+
const served = getVfs().read(address);
|
|
532
|
+
if (!served.ok) {
|
|
533
|
+
return {
|
|
534
|
+
ok: false,
|
|
535
|
+
text: `Cannot read ${address}: ${served.error}${served.detail ? ` — ${served.detail}` : ""}`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
virtualContent = served.value;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const shown = p === address ? p : `${address} → ${p}`;
|
|
542
|
+
|
|
463
543
|
// Image files: return a viewable image block, not the raw bytes decoded as
|
|
464
544
|
// UTF-8. Without this, `read`ing a photo/screenshot/design hands the model
|
|
465
545
|
// mojibake and it can't see the picture at all.
|
|
466
|
-
const mime =
|
|
546
|
+
const mime =
|
|
547
|
+
virtualContent === undefined
|
|
548
|
+
? IMAGE_MIME[extname(p).toLowerCase()]
|
|
549
|
+
: undefined;
|
|
467
550
|
if (mime) {
|
|
468
551
|
let bytes: Buffer;
|
|
469
552
|
if (active) {
|
|
@@ -476,7 +559,7 @@ async function read(
|
|
|
476
559
|
} catch (err) {
|
|
477
560
|
return {
|
|
478
561
|
ok: false,
|
|
479
|
-
text: `Cannot read ${
|
|
562
|
+
text: `Cannot read ${shown}: ${(err as Error).message}`,
|
|
480
563
|
};
|
|
481
564
|
}
|
|
482
565
|
}
|
|
@@ -484,14 +567,14 @@ async function read(
|
|
|
484
567
|
return {
|
|
485
568
|
ok: false,
|
|
486
569
|
text:
|
|
487
|
-
`${
|
|
570
|
+
`${shown} [${where}] is ${(bytes.length / 1_048_576).toFixed(1)}MB — over the ` +
|
|
488
571
|
`${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
|
|
489
572
|
`(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
|
|
490
573
|
};
|
|
491
574
|
}
|
|
492
575
|
return {
|
|
493
576
|
ok: true,
|
|
494
|
-
text: `${
|
|
577
|
+
text: `${shown} [${where}] — image (${mime}, ${bytes.length} bytes)`,
|
|
495
578
|
image: { data: bytes.toString("base64"), mimeType: mime },
|
|
496
579
|
};
|
|
497
580
|
}
|
|
@@ -499,7 +582,9 @@ async function read(
|
|
|
499
582
|
const start = num(offset) ?? 0;
|
|
500
583
|
const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
|
|
501
584
|
let content: string;
|
|
502
|
-
if (
|
|
585
|
+
if (virtualContent !== undefined) {
|
|
586
|
+
content = virtualContent;
|
|
587
|
+
} else if (active) {
|
|
503
588
|
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
504
589
|
if ("error" in res) return { ok: false, text: res.error };
|
|
505
590
|
content = res.data.toString("utf8");
|
|
@@ -507,7 +592,10 @@ async function read(
|
|
|
507
592
|
try {
|
|
508
593
|
content = await readFile(p, "utf8");
|
|
509
594
|
} catch (err) {
|
|
510
|
-
return {
|
|
595
|
+
return {
|
|
596
|
+
ok: false,
|
|
597
|
+
text: `Cannot read ${shown}: ${(err as Error).message}`,
|
|
598
|
+
};
|
|
511
599
|
}
|
|
512
600
|
}
|
|
513
601
|
const lines = content.split("\n");
|
|
@@ -521,7 +609,7 @@ async function read(
|
|
|
521
609
|
: "";
|
|
522
610
|
return {
|
|
523
611
|
ok: true,
|
|
524
|
-
text: `${
|
|
612
|
+
text: `${shown} [${where}] — ${lines.length} lines\n${numbered}${more}`,
|
|
525
613
|
};
|
|
526
614
|
}
|
|
527
615
|
|
|
@@ -530,10 +618,23 @@ async function write(
|
|
|
530
618
|
path: unknown,
|
|
531
619
|
content: unknown,
|
|
532
620
|
): Promise<Result> {
|
|
533
|
-
const
|
|
534
|
-
if (!
|
|
621
|
+
const address = str(path);
|
|
622
|
+
if (!address) return { ok: false, text: "A file path is required." };
|
|
535
623
|
const body = typeof content === "string" ? content : "";
|
|
536
624
|
const active = await getTeleport(chatId);
|
|
625
|
+
let p = address;
|
|
626
|
+
if (address.startsWith(NAMESPACE_SCHEME)) {
|
|
627
|
+
const resolved = resolveNamespaceAddress(address, active?.deviceName);
|
|
628
|
+
if (resolved.kind === "error") return { ok: false, text: resolved.text };
|
|
629
|
+
if (resolved.kind === "virtual") {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
text: `${address} is a synthetic view — there is no file on disk to write. Writable mounts: vfs_list "" shows where each lives.`,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
p = resolved.path;
|
|
636
|
+
}
|
|
637
|
+
const shown = p === address ? p : `${address} → ${p}`;
|
|
537
638
|
if (active) {
|
|
538
639
|
return getMeshService().writeFileToDevice(active.deviceId, p, body);
|
|
539
640
|
}
|
|
@@ -541,9 +642,12 @@ async function write(
|
|
|
541
642
|
await mkdir(dirname(p), { recursive: true });
|
|
542
643
|
await writeFile(p, body);
|
|
543
644
|
} catch (err) {
|
|
544
|
-
return {
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
text: `Cannot write ${shown}: ${(err as Error).message}`,
|
|
648
|
+
};
|
|
545
649
|
}
|
|
546
|
-
return { ok: true, text: `Wrote ${body.length} bytes to ${
|
|
650
|
+
return { ok: true, text: `Wrote ${body.length} bytes to ${shown} [local].` };
|
|
547
651
|
}
|
|
548
652
|
|
|
549
653
|
async function edit(
|
|
@@ -553,13 +657,26 @@ async function edit(
|
|
|
553
657
|
newString: unknown,
|
|
554
658
|
replaceAll: unknown,
|
|
555
659
|
): Promise<Result> {
|
|
556
|
-
const
|
|
557
|
-
if (!
|
|
660
|
+
const address = str(path);
|
|
661
|
+
if (!address) return { ok: false, text: "A file path is required." };
|
|
558
662
|
const from = typeof oldString === "string" ? oldString : "";
|
|
559
663
|
const to = typeof newString === "string" ? newString : "";
|
|
560
664
|
if (from === to)
|
|
561
665
|
return { ok: false, text: "old_string and new_string are identical." };
|
|
562
666
|
const active = await getTeleport(chatId);
|
|
667
|
+
let p = address;
|
|
668
|
+
if (address.startsWith(NAMESPACE_SCHEME)) {
|
|
669
|
+
const resolved = resolveNamespaceAddress(address, active?.deviceName);
|
|
670
|
+
if (resolved.kind === "error") return { ok: false, text: resolved.text };
|
|
671
|
+
if (resolved.kind === "virtual") {
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
text: `${address} is a synthetic view — there is no file on disk to edit. Read it with vfs_read instead.`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
p = resolved.path;
|
|
678
|
+
}
|
|
679
|
+
const shown = p === address ? p : `${address} → ${p}`;
|
|
563
680
|
const svc = getMeshService();
|
|
564
681
|
|
|
565
682
|
let content: string;
|
|
@@ -571,7 +688,10 @@ async function edit(
|
|
|
571
688
|
try {
|
|
572
689
|
content = await readFile(p, "utf8");
|
|
573
690
|
} catch (err) {
|
|
574
|
-
return {
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
text: `Cannot read ${shown}: ${(err as Error).message}`,
|
|
694
|
+
};
|
|
575
695
|
}
|
|
576
696
|
}
|
|
577
697
|
|
|
@@ -579,13 +699,13 @@ async function edit(
|
|
|
579
699
|
if (count === 0) {
|
|
580
700
|
return {
|
|
581
701
|
ok: false,
|
|
582
|
-
text: `old_string not found in ${
|
|
702
|
+
text: `old_string not found in ${shown}.${nearMissHint(content, from)}`,
|
|
583
703
|
};
|
|
584
704
|
}
|
|
585
705
|
if (count > 1 && replaceAll !== true) {
|
|
586
706
|
return {
|
|
587
707
|
ok: false,
|
|
588
|
-
text: `old_string appears ${count}× in ${
|
|
708
|
+
text: `old_string appears ${count}× in ${shown}; pass replace_all or make it unique.`,
|
|
589
709
|
};
|
|
590
710
|
}
|
|
591
711
|
const updated =
|
|
@@ -598,18 +718,21 @@ async function edit(
|
|
|
598
718
|
return res.ok
|
|
599
719
|
? {
|
|
600
720
|
ok: true,
|
|
601
|
-
text: `Edited ${
|
|
721
|
+
text: `Edited ${shown} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}).`,
|
|
602
722
|
}
|
|
603
723
|
: res;
|
|
604
724
|
}
|
|
605
725
|
try {
|
|
606
726
|
await writeFile(p, updated);
|
|
607
727
|
} catch (err) {
|
|
608
|
-
return {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
text: `Cannot write ${shown}: ${(err as Error).message}`,
|
|
731
|
+
};
|
|
609
732
|
}
|
|
610
733
|
return {
|
|
611
734
|
ok: true,
|
|
612
|
-
text: `Edited ${
|
|
735
|
+
text: `Edited ${shown} [local] (${count} replacement${count === 1 ? "" : "s"}).`,
|
|
613
736
|
};
|
|
614
737
|
}
|
|
615
738
|
|
|
@@ -622,8 +745,10 @@ async function glob(
|
|
|
622
745
|
): Promise<Result> {
|
|
623
746
|
const pat = str(pattern);
|
|
624
747
|
if (!pat) return { ok: false, text: "A glob pattern is required." };
|
|
625
|
-
const root = str(path) ?? ".";
|
|
626
748
|
const active = await getTeleport(chatId);
|
|
749
|
+
const rooted = resolveSearchRoot(str(path), active?.deviceName);
|
|
750
|
+
if ("error" in rooted) return { ok: false, text: rooted.error };
|
|
751
|
+
const root = rooted.root;
|
|
627
752
|
if (active) {
|
|
628
753
|
// Prefer rg on the device; fall back to find (basename patterns via
|
|
629
754
|
// -name, path patterns via -path). `command -v` gates the choice so a
|
|
@@ -669,7 +794,10 @@ async function search(
|
|
|
669
794
|
): Promise<Result> {
|
|
670
795
|
const pat = str(pattern);
|
|
671
796
|
if (!pat) return { ok: false, text: "A search pattern is required." };
|
|
672
|
-
const
|
|
797
|
+
const active = await getTeleport(chatId);
|
|
798
|
+
const rooted = resolveSearchRoot(str(path), active?.deviceName);
|
|
799
|
+
if ("error" in rooted) return { ok: false, text: rooted.error };
|
|
800
|
+
const root = rooted.root;
|
|
673
801
|
const g = str(globPat);
|
|
674
802
|
const ci = caseInsensitive === true;
|
|
675
803
|
// `-e` keeps a pattern that starts with "-" from being parsed as a flag
|
|
@@ -680,7 +808,6 @@ async function search(
|
|
|
680
808
|
...(ci ? ["-i"] : []),
|
|
681
809
|
...(g ? ["-g", g] : []),
|
|
682
810
|
];
|
|
683
|
-
const active = await getTeleport(chatId);
|
|
684
811
|
if (active) {
|
|
685
812
|
// Prefer rg on the device, fall back to grep (Android toybox has grep
|
|
686
813
|
// but rarely rg). --include is grep's closest analogue of -g.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS — the `talon://` namespace over the gateway: list / read / write.
|
|
3
|
+
* Formatting only; the namespace itself lives in core/vfs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getVfs, type VfsResult, type VfsStat } from "../../vfs/index.js";
|
|
7
|
+
import { log } from "../../../util/log.js";
|
|
8
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
9
|
+
|
|
10
|
+
function describeError(
|
|
11
|
+
path: string,
|
|
12
|
+
result: { error: string; detail?: string },
|
|
13
|
+
): string {
|
|
14
|
+
return `talon://${path}: ${result.error}${result.detail ? ` — ${result.detail}` : ""}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatSize(entry: VfsStat): string {
|
|
18
|
+
return entry.kind === "dir" ? "-" : String(entry.size ?? "?");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatEntry(entry: VfsStat): string {
|
|
22
|
+
const kind = entry.kind === "dir" ? "d" : "-";
|
|
23
|
+
const write = entry.writable ? "w" : "-";
|
|
24
|
+
const suffix = entry.kind === "dir" ? "/" : "";
|
|
25
|
+
// Root entries (single-segment paths) show where the mount lives on
|
|
26
|
+
// disk — the bridge for OS-level tools, which can't read talon://.
|
|
27
|
+
const mapping =
|
|
28
|
+
entry.osPath !== undefined && !entry.path.includes("/")
|
|
29
|
+
? ` → ${entry.osPath}`
|
|
30
|
+
: "";
|
|
31
|
+
return `${kind}${write} ${formatSize(entry).padStart(9)} ${entry.name}${suffix}${mapping}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalize(body: Record<string, unknown>): string {
|
|
35
|
+
return String(body.path ?? "").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const vfsHandlers: SharedActionHandlers = {
|
|
39
|
+
vfs_list: (body) => {
|
|
40
|
+
const path = normalize(body);
|
|
41
|
+
const result = getVfs().list(path);
|
|
42
|
+
if (!result.ok) return { ok: false, error: describeError(path, result) };
|
|
43
|
+
const header = `talon://${path.replace(/\/+$/, "")}${path ? "/" : ""} (${result.value.length} entries)`;
|
|
44
|
+
const lines = result.value.map(formatEntry);
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
text: [header, ...(lines.length > 0 ? lines : ["(empty)"])].join("\n"),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
vfs_read: (body) => {
|
|
52
|
+
const path = normalize(body);
|
|
53
|
+
const result = getVfs().read(path);
|
|
54
|
+
if (!result.ok) return { ok: false, error: describeError(path, result) };
|
|
55
|
+
return { ok: true, text: result.value };
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
vfs_write: (body) => {
|
|
59
|
+
const path = normalize(body);
|
|
60
|
+
const content = String(body.content ?? "");
|
|
61
|
+
const result: VfsResult<VfsStat> = getVfs().write(path, content);
|
|
62
|
+
if (!result.ok) return { ok: false, error: describeError(path, result) };
|
|
63
|
+
log("gateway", `vfs_write: talon://${result.value.path}`);
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
text: `Wrote ${result.value.size ?? content.length} bytes to talon://${result.value.path}`,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -414,6 +414,34 @@ export class Gateway {
|
|
|
414
414
|
return;
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
if (
|
|
418
|
+
req.method === "GET" &&
|
|
419
|
+
(req.url?.startsWith("/vfs/list") || req.url?.startsWith("/vfs/read"))
|
|
420
|
+
) {
|
|
421
|
+
// The talon:// namespace — the transport for `talon ls` /
|
|
422
|
+
// `talon cat`, which need the daemon's live synthetic mounts
|
|
423
|
+
// (proc, plugins). Same 127.0.0.1 trust boundary as /action.
|
|
424
|
+
const url = new URL(req.url, "http://gateway");
|
|
425
|
+
const path = url.searchParams.get("path") ?? "";
|
|
426
|
+
const { getVfs } = await import("../vfs/index.js");
|
|
427
|
+
const vfs = getVfs();
|
|
428
|
+
let result: Record<string, unknown>;
|
|
429
|
+
if (url.pathname === "/vfs/list") {
|
|
430
|
+
const listed = vfs.list(path);
|
|
431
|
+
result = listed.ok
|
|
432
|
+
? { ok: true, entries: listed.value }
|
|
433
|
+
: { ok: false, error: listed.error, detail: listed.detail };
|
|
434
|
+
} else {
|
|
435
|
+
const content = vfs.read(path);
|
|
436
|
+
result = content.ok
|
|
437
|
+
? { ok: true, content: content.value }
|
|
438
|
+
: { ok: false, error: content.error, detail: content.detail };
|
|
439
|
+
}
|
|
440
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
441
|
+
res.end(JSON.stringify(result));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
417
445
|
if (req.method === "GET" && req.url === "/tasks") {
|
|
418
446
|
// The task table — every live/recent unit of agent work. Read by
|
|
419
447
|
// `talon ps`. Same 127.0.0.1 trust boundary as /action.
|
package/src/core/tasks/table.ts
CHANGED
|
@@ -92,7 +92,7 @@ export class TaskTable {
|
|
|
92
92
|
start: () => this.start(id),
|
|
93
93
|
bind: (binding) => this.bind(id, binding),
|
|
94
94
|
succeed: (usage) => this.settle(id, "done", undefined, usage),
|
|
95
|
-
fail: (error) => this.settle(id, "failed", error),
|
|
95
|
+
fail: (error, usage) => this.settle(id, "failed", error, usage),
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
package/src/core/tasks/types.ts
CHANGED
|
@@ -87,8 +87,12 @@ export interface TaskHandle {
|
|
|
87
87
|
bind(binding: TaskBinding): void;
|
|
88
88
|
/** Settle as `done`, optionally recording token usage. */
|
|
89
89
|
succeed(usage?: TaskUsage): void;
|
|
90
|
-
/**
|
|
91
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Settle as `failed` — or `killed` when a kill was requested first.
|
|
92
|
+
* Usage is recorded when the owner knows what the run burned before it
|
|
93
|
+
* died (an interrupted turn still spent real tokens).
|
|
94
|
+
*/
|
|
95
|
+
fail(error: unknown, usage?: TaskUsage): void;
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
/** Result of `TaskTable.kill`. */
|
package/src/core/tools/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { adminTools } from "./admin.js";
|
|
|
23
23
|
import { modelTools } from "./models.js";
|
|
24
24
|
import { meshTools } from "./mesh.js";
|
|
25
25
|
import { nativeTools } from "./native.js";
|
|
26
|
+
import { vfsTools } from "./vfs.js";
|
|
26
27
|
|
|
27
28
|
/** All built-in tool definitions. */
|
|
28
29
|
export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
@@ -41,6 +42,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
|
41
42
|
...adminTools,
|
|
42
43
|
...modelTools,
|
|
43
44
|
...meshTools,
|
|
45
|
+
...vfsTools,
|
|
44
46
|
];
|
|
45
47
|
|
|
46
48
|
/**
|