privateer-agent 0.12.57 → 0.13.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 +37 -2
- package/extensions/privateer-brand.ts +2 -2
- package/extensions/privateer-gate.ts +17 -3
- package/extensions/privateer-media.ts +6 -0
- package/extensions/privateer-speak.ts +9 -1
- package/extensions/privateer-terminals.ts +73 -0
- package/package.json +2 -2
- package/src/config/mediaModels.ts +64 -0
- package/src/config/moatManifest.json +1 -0
- package/src/permissions/classify.ts +22 -0
- package/src/terminals/backends.ts +376 -0
- package/src/terminals/index.ts +200 -0
- package/src/terminals/registry.ts +107 -0
- package/src/tools/media.ts +44 -17
- package/src/tools/mediaModelCommands.ts +175 -0
- package/src/tools/terminal.ts +80 -0
package/README.md
CHANGED
|
@@ -126,6 +126,9 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
126
126
|
and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
|
|
127
127
|
machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
|
|
128
128
|
makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
|
|
129
|
+
- **Named terminals.** The agent can open windows you watch — a dev server, a REPL, a second
|
|
130
|
+
agent — give each a name, and bring any of them to the front; `/term api` switches you there
|
|
131
|
+
yourself. macOS Terminal, iTerm2, and tmux anywhere. See [Named terminals](#named-terminals).
|
|
129
132
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
130
133
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
131
134
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
@@ -359,8 +362,9 @@ One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab`
|
|
|
359
362
|
by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
|
|
360
363
|
|
|
361
364
|
Voice is **off by default and interactive-only** — harbor, ACP and channel sessions never
|
|
362
|
-
speak. The mic opens only on something you did (`/talk`, the push-to-talk key,
|
|
363
|
-
conversation turn you switched on
|
|
365
|
+
speak. The mic opens only on something you did (`/talk`, the push-to-talk key, a
|
|
366
|
+
conversation turn you switched on, or switching to a window while dictation or conversation
|
|
367
|
+
mode is on); there is no wake word, capture is hard-capped, audio is
|
|
364
368
|
held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
|
|
365
369
|
every tool call it leads to hits the same permission gate as anything you type. Recording
|
|
366
370
|
needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
|
|
@@ -368,6 +372,37 @@ needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` o
|
|
|
368
372
|
standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
|
|
369
373
|
in any Pi agent.
|
|
370
374
|
|
|
375
|
+
## Named terminals
|
|
376
|
+
|
|
377
|
+
Ask for "a terminal running the dev server" and the agent opens a new window, names it, and
|
|
378
|
+
puts it in front of you. Every Privateer window names itself too (after its folder, or the
|
|
379
|
+
name the agent gave it), so you and the agent can hop between them by name:
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
/term pick a terminal to switch to
|
|
383
|
+
/term api switch to "api"
|
|
384
|
+
/term open api npm run dev open one yourself
|
|
385
|
+
/term name backend rename this window
|
|
386
|
+
/term close api close it, ending what runs there
|
|
387
|
+
/term list every named terminal (← marks this one)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The agent does the same through its `terminal` tool. Opening a window with a command goes
|
|
391
|
+
through the permission gate exactly like running that command in bash; switching and listing
|
|
392
|
+
don't ask. The agent can't read what a window prints: that's what bash and background tasks
|
|
393
|
+
are for. These windows are for you.
|
|
394
|
+
|
|
395
|
+
Voice follows you between windows. Every window shares one voice setup, so with
|
|
396
|
+
dictation or conversation mode on, the window you switch to listens for your next prompt,
|
|
397
|
+
including a new agent the moment its window opens in front. The window you left pauses
|
|
398
|
+
its mic. (That's [privateer-speak](https://www.npmjs.com/package/privateer-speak)'s focus
|
|
399
|
+
awareness; it needs a terminal that reports focus.)
|
|
400
|
+
|
|
401
|
+
Works in **macOS Terminal**, **iTerm2**, and inside **tmux** on any platform; other terminals
|
|
402
|
+
get told so. macOS may ask once for permission to let the terminal control itself. Names are
|
|
403
|
+
shared across every Privateer on the machine (`~/.privateer/terminals.json`), and a window
|
|
404
|
+
that's been closed drops off the list on its own.
|
|
405
|
+
|
|
371
406
|
## The Privateer app
|
|
372
407
|
|
|
373
408
|
The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
|
|
@@ -285,10 +285,10 @@ function contextLine(p: Palette): string {
|
|
|
285
285
|
// or three things a returning user should notice. `cmd`, when present, is rendered in the
|
|
286
286
|
// accent color so the actionable bit stands out from the prose. Trim this as it ages.
|
|
287
287
|
const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
|
|
288
|
+
{ text: "Open and switch named terminals —", cmd: "/term" },
|
|
289
|
+
{ text: "Voice follows the window you're in" },
|
|
288
290
|
{ text: "Searchable model picker —", cmd: "/models" },
|
|
289
291
|
{ text: "Start over with a new agent —", cmd: "/fresh" },
|
|
290
|
-
{ text: "Text-only models can read images now" },
|
|
291
|
-
{ text: "Live crypto prices and Solana wallets" },
|
|
292
292
|
];
|
|
293
293
|
|
|
294
294
|
function whatsNewRows(p: Palette): string[] {
|
|
@@ -491,11 +491,25 @@ const approveInAppMs = isSubagentChild() ? 0 : Number(process.env.PRIVATEER_APPR
|
|
|
491
491
|
// anything, and media.ts has no business in the TUI's startup graph.
|
|
492
492
|
async function quoteMediaCall(tool: string, input: unknown, signal?: AbortSignal): Promise<number | null> {
|
|
493
493
|
const { readMediaCapabilities, quoteMediaCallUsd } = await import("../src/tools/media.ts");
|
|
494
|
+
const { effectiveMediaModel } = await import("../src/config/mediaModels.ts");
|
|
494
495
|
const a = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
495
|
-
|
|
496
|
-
|
|
496
|
+
// Price the model the tool will actually SEND — its own `model`, else this machine's
|
|
497
|
+
// /image-model or /video-model choice — not the account default it would fall past.
|
|
498
|
+
let priced = a;
|
|
499
|
+
let query: { imageModel?: string; videoModel?: string; model?: string } = {};
|
|
500
|
+
if (tool === "generate_image") {
|
|
501
|
+
const imageModel = effectiveMediaModel("image", a.model);
|
|
502
|
+
priced = { ...a, model: imageModel };
|
|
503
|
+
query = { imageModel };
|
|
504
|
+
} else if (tool === "generate_video" || tool === "generate_sprite") {
|
|
505
|
+
const imageModel = tool === "generate_sprite" ? effectiveMediaModel("image", a.image_model) : undefined;
|
|
506
|
+
priced = tool === "generate_sprite" ? { ...a, image_model: imageModel } : a;
|
|
507
|
+
query = { videoModel: effectiveMediaModel("video", a.model), imageModel };
|
|
508
|
+
} else if (tool === "generate_model") {
|
|
509
|
+
query = { model: typeof a.model === "string" && a.model ? a.model : undefined };
|
|
510
|
+
}
|
|
497
511
|
const caps = await readMediaCapabilities(query, signal);
|
|
498
|
-
return caps.ok ? quoteMediaCallUsd(tool,
|
|
512
|
+
return caps.ok ? quoteMediaCallUsd(tool, priced, caps.data) : null;
|
|
499
513
|
}
|
|
500
514
|
|
|
501
515
|
const cliLedger = cliGrant ? new CliSpendLedger(cliGrant, quoteMediaCall) : null;
|
|
@@ -20,14 +20,20 @@
|
|
|
20
20
|
// already on disk — no account, no network, no spend — so it stays useful to a
|
|
21
21
|
// signed-out terminal, and to a child whose job is to cut together what its parent
|
|
22
22
|
// generated.
|
|
23
|
+
//
|
|
24
|
+
// /image-model and /video-model are registered unconditionally too: they only write a
|
|
25
|
+
// local preference, and a signed-out terminal is told to /login when it opens the
|
|
26
|
+
// picker rather than finding the command missing.
|
|
23
27
|
import { makeMediaTools } from "../src/tools/media.ts";
|
|
24
28
|
import { makeComposeTools } from "../src/tools/videoCompose.ts";
|
|
25
29
|
import { mediaEnabled } from "../src/config/hosted.ts";
|
|
26
30
|
import { childHoldsSpendGrant } from "../src/permissions/childSpend.ts";
|
|
27
31
|
import { isSubagentChild } from "../src/remote/subagentRelay.ts";
|
|
32
|
+
import { registerMediaModelCommands } from "../src/tools/mediaModelCommands.ts";
|
|
28
33
|
|
|
29
34
|
export default function privateerMedia(pi: any): void {
|
|
30
35
|
const canSpend = !isSubagentChild() || childHoldsSpendGrant();
|
|
31
36
|
if (mediaEnabled() && canSpend) makeMediaTools()(pi);
|
|
32
37
|
makeComposeTools()(pi);
|
|
38
|
+
registerMediaModelCommands(pi);
|
|
33
39
|
}
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "privateer-speak";
|
|
62
62
|
import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
|
|
63
63
|
import { globalDir } from "../src/config/paths.ts";
|
|
64
|
+
import { thisTerminalIsFront } from "../src/terminals/index.ts";
|
|
64
65
|
import { VOICE_DESCRIPTIONS } from "./speak-voices.data.ts";
|
|
65
66
|
|
|
66
67
|
const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
|
|
@@ -358,7 +359,14 @@ export default function privateerSpeak(pi: any): void {
|
|
|
358
359
|
// voiceCommands: saying one of these AS THE WHOLE UTTERANCE runs /fresh (see
|
|
359
360
|
// extensions/privateer-fresh.ts) instead of sending the words to the model. Whole
|
|
360
361
|
// utterance only, so "why did the fresh start fail" is still a question.
|
|
361
|
-
|
|
362
|
+
// isFocused: a window that has just started (say, one the agent opened with the
|
|
363
|
+
// terminal tool) asks Terminal/iTerm2 whether it's in front, so a hands-free mode
|
|
364
|
+
// carries on in the new agent without a keypress — see src/terminals/.
|
|
365
|
+
makePiSpeakExtension({
|
|
366
|
+
configFile: join(globalDir(), "speak.json"),
|
|
367
|
+
voiceCommands: VOICE_COMMANDS,
|
|
368
|
+
isFocused: thisTerminalIsFront,
|
|
369
|
+
})(pi);
|
|
362
370
|
}
|
|
363
371
|
|
|
364
372
|
const VOICE_COMMANDS: Record<string, string> = {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Named terminals — the `terminal` tool and /term, so the agent can open windows the
|
|
2
|
+
// user watches (a dev server, a REPL, a second agent) and either of them can switch
|
|
3
|
+
// between those windows by name. See src/terminals/ for how windows are found and why
|
|
4
|
+
// only tmux, macOS Terminal and iTerm2 are driven.
|
|
5
|
+
//
|
|
6
|
+
// Interactive only: the window registers itself at session start, which a harbor or ACP
|
|
7
|
+
// host has no window to do, and the tool refuses without a UI.
|
|
8
|
+
|
|
9
|
+
import { closeTerminal, describeListing, focusTerminal, openTerminal, renameSelf, terminalListing, backend, claimSelf } from "../src/terminals/index.ts";
|
|
10
|
+
import { terminalToolDefinition } from "../src/tools/terminal.ts";
|
|
11
|
+
|
|
12
|
+
const USAGE =
|
|
13
|
+
"/term — pick a terminal to switch to · /term <name> — switch · /term open <name> [command] · " +
|
|
14
|
+
"/term name <name> — rename this one · /term close <name> · /term list";
|
|
15
|
+
|
|
16
|
+
export default function privateerTerminals(pi: any): void {
|
|
17
|
+
pi.registerTool?.(terminalToolDefinition);
|
|
18
|
+
|
|
19
|
+
// Name this window as soon as the session starts, so it can be switched back to from
|
|
20
|
+
// any other — not only after its first /term.
|
|
21
|
+
pi.on?.("session_start", (_e: any, ctx: any) => {
|
|
22
|
+
if (!ctx?.hasUI) return;
|
|
23
|
+
const b = backend();
|
|
24
|
+
if ("unsupported" in b) return;
|
|
25
|
+
void claimSelf(b, ctx.cwd ?? process.cwd()).catch(() => undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
pi.registerCommand?.("term", {
|
|
29
|
+
description: "Switch between named terminals, or open one",
|
|
30
|
+
handler: async (args: string, ctx: any) => {
|
|
31
|
+
const notify = (m: string, kind: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(m, kind);
|
|
32
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
33
|
+
const [sub = "", ...rest] = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
const report = (o: { ok: boolean; message: string }) => notify(o.message, o.ok ? "info" : "warning");
|
|
35
|
+
|
|
36
|
+
switch (sub.toLowerCase()) {
|
|
37
|
+
case "": {
|
|
38
|
+
const l = await terminalListing(cwd);
|
|
39
|
+
if ("unsupported" in l) return notify(l.unsupported, "warning");
|
|
40
|
+
const others = l.records.filter((r) => r.id !== l.selfId);
|
|
41
|
+
if (!others.length) return notify(`No other named terminals. ${USAGE}`);
|
|
42
|
+
if (typeof ctx?.ui?.select !== "function") return notify(describeListing(l));
|
|
43
|
+
const labels = others.map((r) => `${r.name} ${r.command ?? r.cwd}`);
|
|
44
|
+
const picked = await ctx.ui.select("Switch to terminal", labels);
|
|
45
|
+
const rec = picked ? others[labels.indexOf(picked)] : undefined;
|
|
46
|
+
if (rec) report(await focusTerminal(rec.name, cwd));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case "list": {
|
|
50
|
+
const l = await terminalListing(cwd);
|
|
51
|
+
return notify("unsupported" in l ? l.unsupported : describeListing(l), "unsupported" in l ? "warning" : "info");
|
|
52
|
+
}
|
|
53
|
+
case "open": {
|
|
54
|
+
const [name, ...cmd] = rest;
|
|
55
|
+
if (!name) return notify("Usage: /term open <name> [command]", "warning");
|
|
56
|
+
return report(await openTerminal({ name, cwd, ...(cmd.length ? { command: cmd.join(" ") } : {}) }));
|
|
57
|
+
}
|
|
58
|
+
case "name":
|
|
59
|
+
case "rename":
|
|
60
|
+
if (!rest.length) return notify("Usage: /term name <name>", "warning");
|
|
61
|
+
return report(await renameSelf(rest.join(" "), cwd));
|
|
62
|
+
case "close":
|
|
63
|
+
if (!rest.length) return notify("Usage: /term close <name>", "warning");
|
|
64
|
+
return report(await closeTerminal(rest.join(" "), cwd));
|
|
65
|
+
case "help":
|
|
66
|
+
return notify(USAGE);
|
|
67
|
+
default:
|
|
68
|
+
// Anything else is a name: /term api, /term "dev server".
|
|
69
|
+
return report(await focusTerminal([sub, ...rest].join(" "), cwd));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"pi-privacy": "0.14.1",
|
|
87
87
|
"pi-subagents": "0.70.0",
|
|
88
88
|
"picomatch": "4.0.5",
|
|
89
|
-
"privateer-speak": "0.
|
|
89
|
+
"privateer-speak": "0.7.0",
|
|
90
90
|
"privateer-workflow": "0.1.0",
|
|
91
91
|
"tinfoil": "1.1.11",
|
|
92
92
|
"tsx": "4.23.0",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which image and video models this machine's agent generates with, when the agent
|
|
3
|
+
* doesn't name one itself. Set by `/image-model` and `/video-model`
|
|
4
|
+
* (extensions/privateer-media.ts), stored as `media.imageModel` / `media.videoModel`
|
|
5
|
+
* in ~/.privateer/config.json.
|
|
6
|
+
*
|
|
7
|
+
* PER MACHINE, NOT PER ACCOUNT. The account already has a default for each (the app's
|
|
8
|
+
* own picker writes it, server-side), and that is what an unset value here falls through
|
|
9
|
+
* to. A terminal choice deliberately does not write back to it: picking a cheap video
|
|
10
|
+
* model for a batch of terminal drafts should not change what the phone renders with.
|
|
11
|
+
*
|
|
12
|
+
* PRECEDENCE, highest first: the tool call's own `model` → this file → the account
|
|
13
|
+
* default. The agent naming a model still wins, because it only does that when a job
|
|
14
|
+
* calls for a specific one (a 4K clip, a model that takes a last frame).
|
|
15
|
+
*
|
|
16
|
+
* Read at CALL time, like piiAllow.ts: a pick made in one terminal reaches a harbor or
|
|
17
|
+
* a second terminal on the same machine on its next generation, without a restart.
|
|
18
|
+
* Missing file, unreadable file and malformed JSON all mean "no choice" — the account
|
|
19
|
+
* default — never a crash in the middle of a tool call.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import { configPath } from "./paths.ts";
|
|
24
|
+
|
|
25
|
+
export type MediaKind = "image" | "video";
|
|
26
|
+
|
|
27
|
+
const KEY: Record<MediaKind, "imageModel" | "videoModel"> = { image: "imageModel", video: "videoModel" };
|
|
28
|
+
|
|
29
|
+
function readConfig(): Record<string, unknown> {
|
|
30
|
+
try {
|
|
31
|
+
const cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
32
|
+
return cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The chosen model id for `kind`, or undefined to use the account default. */
|
|
39
|
+
export function mediaModelPref(kind: MediaKind): string | undefined {
|
|
40
|
+
const media = readConfig().media as Record<string, unknown> | undefined;
|
|
41
|
+
const v = media?.[KEY[kind]];
|
|
42
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set (or, with null, clear) the choice for `kind`. Every other key in config.json is
|
|
47
|
+
* preserved — it also holds webhooks, privacy and coauthor settings this must not drop.
|
|
48
|
+
*/
|
|
49
|
+
export function setMediaModelPref(kind: MediaKind, id: string | null): void {
|
|
50
|
+
const cfg = readConfig();
|
|
51
|
+
const media = { ...((cfg.media as Record<string, unknown>) ?? {}) };
|
|
52
|
+
const clean = id?.trim();
|
|
53
|
+
if (clean) media[KEY[kind]] = clean;
|
|
54
|
+
else delete media[KEY[kind]];
|
|
55
|
+
const next: Record<string, unknown> = { ...cfg, media };
|
|
56
|
+
if (Object.keys(media).length === 0) delete next.media;
|
|
57
|
+
mkdirSync(dirname(configPath()), { recursive: true });
|
|
58
|
+
writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The model a call should send: its own `model`, else this machine's choice, else none. */
|
|
62
|
+
export function effectiveMediaModel(kind: MediaKind, requested: unknown): string | undefined {
|
|
63
|
+
return typeof requested === "string" && requested.trim() ? requested.trim() : mediaModelPref(kind);
|
|
64
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
21
21
|
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
22
22
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
|
23
|
+
{ "entry": "extensions/privateer-terminals.ts", "name": "privateer-terminals", "note": "named terminal windows — the terminal tool + /term to switch between them" },
|
|
23
24
|
{ "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
|
|
24
25
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
25
26
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
@@ -648,6 +648,28 @@ export function classifyToolCall(
|
|
|
648
648
|
};
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
// terminal (src/tools/terminal.ts) — named windows the user watches. Opening one
|
|
652
|
+
// with a command RUNS that command, so it is bash-kind with the command as the
|
|
653
|
+
// detail: the same danger list, allowlist and plan-mode refusal as typing it into
|
|
654
|
+
// bash. A bare shell is bash-kind too (it puts a new window on the user's screen),
|
|
655
|
+
// and so is close, which ends whatever runs in the window. Focus and list only move
|
|
656
|
+
// a window to the front or read names: no gate, in any mode.
|
|
657
|
+
if (name === "terminal") {
|
|
658
|
+
const action = str(obj.action) || "list";
|
|
659
|
+
if (action === "focus" || action === "list") return null;
|
|
660
|
+
const label = str(obj.name);
|
|
661
|
+
if (action === "close") {
|
|
662
|
+
return { tool: toolName, kind: "bash", title: `Close terminal "${label}"`, detail: `close terminal ${label}` };
|
|
663
|
+
}
|
|
664
|
+
const command = str(obj.command).trim();
|
|
665
|
+
return {
|
|
666
|
+
tool: toolName,
|
|
667
|
+
kind: "bash",
|
|
668
|
+
title: command ? `Open terminal "${label}" running a command` : `Open terminal "${label}"`,
|
|
669
|
+
detail: command || `open a shell${obj.cwd ? ` in ${str(obj.cwd)}` : ""}`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
651
673
|
// Network reads (web fetch / search / http).
|
|
652
674
|
if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
|
|
653
675
|
return {
|