@zergai/ztc 0.9.0-beta.1 → 0.10.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Agent CLIs that run in ZTC panes under the user's own accounts.
3
+ *
4
+ * ZTC hosts Claude Code and Codex the way a terminal does: it runs the user's
5
+ * own, unmodified `claude` or `codex` binary in a pane, and the user signs in
6
+ * through that CLI's own flow. ZTC never reads, stores or forwards their
7
+ * credentials. (Anthropic's terms for Claude subscription sign-in:
8
+ * https://code.claude.com/docs/en/legal-and-compliance.)
9
+ *
10
+ * What ZTC adds is a session-scoped MCP server, `ztc workspace-mcp`, passed on
11
+ * the command line (Claude Code `--mcp-config`, Codex `-c mcp_servers.ztc.*`),
12
+ * so the agent can list, open, read, type into, message and close the other
13
+ * panes of the workspace. The user's own CLI configuration is never edited.
14
+ */
15
+ import { accessSync, constants, existsSync, statSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { basename, dirname, join } from 'node:path';
18
+ import { resolveOwnedCliLaunch } from '../desktop/cli_launch.js';
19
+ import { buildUserSubprocessEnvOverrides } from '../utils/subprocess_env.js';
20
+ export const AGENT_CLIS = Object.freeze({
21
+ claude: {
22
+ id: 'claude',
23
+ label: 'Claude Code',
24
+ binary: 'claude',
25
+ setupUrl: 'https://code.claude.com/docs/en/setup',
26
+ install: 'curl -fsSL https://claude.ai/install.sh | bash',
27
+ },
28
+ codex: {
29
+ id: 'codex',
30
+ label: 'Codex',
31
+ binary: 'codex',
32
+ setupUrl: 'https://developers.openai.com/codex/cli',
33
+ install: 'npm install -g @openai/codex',
34
+ },
35
+ });
36
+ export const AGENT_CLI_IDS = Object.freeze(['claude', 'codex']);
37
+ export function isAgentCliId(value) {
38
+ return AGENT_CLI_IDS.includes(value);
39
+ }
40
+ const SYSTEM_BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'];
41
+ function isExecutableFile(path) {
42
+ try {
43
+ if (!statSync(path).isFile())
44
+ return false;
45
+ accessSync(path, constants.X_OK);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * Where the installers put the launcher, checked when PATH lacks it: a desktop
54
+ * app started from Finder has a bare PATH, and the login shell a pane runs in
55
+ * never reads ~/.zshrc, where installers usually add ~/.local/bin.
56
+ */
57
+ function installDirs(id, home, systemDirs) {
58
+ const dirs = [join(home, '.local', 'bin')];
59
+ if (id === 'claude')
60
+ dirs.push(join(home, '.claude', 'local'));
61
+ return [...dirs, ...systemDirs];
62
+ }
63
+ /** The absolute path of the user's `claude` or `codex`, or null when it is not installed. */
64
+ export function findAgentCli(id, lookup = {}) {
65
+ const env = lookup.env ?? process.env;
66
+ const platform = lookup.platform ?? process.platform;
67
+ const home = lookup.home ?? homedir();
68
+ const names = platform === 'win32'
69
+ ? [`${AGENT_CLIS[id].binary}.exe`, `${AGENT_CLIS[id].binary}.cmd`]
70
+ : [AGENT_CLIS[id].binary];
71
+ const pathDirs = (env.PATH ?? '').split(platform === 'win32' ? ';' : ':').filter(Boolean);
72
+ for (const dir of [...pathDirs, ...installDirs(id, home, lookup.systemDirs ?? SYSTEM_BIN_DIRS)]) {
73
+ for (const name of names) {
74
+ const candidate = join(dir, name);
75
+ if (isExecutableFile(candidate))
76
+ return candidate;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ // ── Arguments ───────────────────────────────────────────────────────────────
82
+ export class CliArgumentsError extends Error {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = 'CliArgumentsError';
86
+ }
87
+ }
88
+ /**
89
+ * Split words the way a POSIX shell does, without expanding anything: quotes
90
+ * group, a backslash escapes, and `$`, backticks, globs and `;` stay literal.
91
+ */
92
+ export function splitCliArguments(raw) {
93
+ const words = [];
94
+ let word = '';
95
+ let inWord = false;
96
+ let quote = null;
97
+ for (let i = 0; i < raw.length; i++) {
98
+ const char = raw[i];
99
+ if (quote === "'") {
100
+ if (char === "'")
101
+ quote = null;
102
+ else
103
+ word += char;
104
+ continue;
105
+ }
106
+ if (quote === '"') {
107
+ if (char === '"') {
108
+ quote = null;
109
+ }
110
+ else if (char === '\\' && i + 1 < raw.length && '"\\$`\n'.includes(raw[i + 1])) {
111
+ i++;
112
+ if (raw[i] !== '\n')
113
+ word += raw[i];
114
+ }
115
+ else {
116
+ word += char;
117
+ }
118
+ continue;
119
+ }
120
+ if (char === "'" || char === '"') {
121
+ quote = char;
122
+ inWord = true;
123
+ }
124
+ else if (char === '\\') {
125
+ if (i + 1 >= raw.length) {
126
+ word += char;
127
+ inWord = true;
128
+ }
129
+ else if (raw[i + 1] === '\n') {
130
+ i++; // line continuation
131
+ }
132
+ else {
133
+ word += raw[++i];
134
+ inWord = true;
135
+ }
136
+ }
137
+ else if (/\s/.test(char)) {
138
+ if (inWord) {
139
+ words.push(word);
140
+ word = '';
141
+ inWord = false;
142
+ }
143
+ }
144
+ else {
145
+ word += char;
146
+ inWord = true;
147
+ }
148
+ }
149
+ if (quote) {
150
+ throw new CliArgumentsError(`Unclosed ${quote === '"' ? 'double' : 'single'} quote in the arguments.`);
151
+ }
152
+ if (inWord)
153
+ words.push(word);
154
+ return words;
155
+ }
156
+ /** Words as a shell would accept them back: quoted only where needed. */
157
+ export function formatCliArguments(words) {
158
+ return words
159
+ .map(word => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`))
160
+ .join(' ');
161
+ }
162
+ /**
163
+ * Read the text after `/claude` or `/codex`. Text that starts with a flag is
164
+ * the CLI's own arguments, split like a shell command line (`/claude
165
+ * --continue`, `/codex -m gpt-5 "review this"`). Anything else is the opening
166
+ * prompt, kept exactly as typed, apostrophes and all (`/claude don't touch the
167
+ * migrations`).
168
+ */
169
+ export function parseAgentCommandBody(body) {
170
+ const text = body.trim();
171
+ if (!text)
172
+ return { words: [] };
173
+ if (text.startsWith('-'))
174
+ return { words: splitCliArguments(text) };
175
+ return { words: [], prompt: text };
176
+ }
177
+ export const WORKSPACE_MCP_SERVER_NAME = 'ztc';
178
+ /**
179
+ * Variables the workspace server reads. Codex starts MCP servers with a short
180
+ * allowlist of variables, so these are named for it to forward; Claude Code
181
+ * passes its whole environment. They reach the pane through its environment,
182
+ * never its command line.
183
+ */
184
+ export const WORKSPACE_MCP_ENV = Object.freeze([
185
+ 'ZTC_PANE_ID',
186
+ 'ZTC_PANE_RELAY',
187
+ 'ZTC_MIRROR_PORT',
188
+ 'ZTC_MIRROR_INTERNAL_TOKEN',
189
+ 'ZTC_MIRROR_GROUP',
190
+ 'ZTC_NAVIGATOR_GROUP',
191
+ 'ZTC_PROFILE',
192
+ ]);
193
+ function tomlString(value) {
194
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\u0000-\u001f\u007f]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
195
+ }
196
+ function tomlArray(values) {
197
+ return `[${values.map(tomlString).join(',')}]`;
198
+ }
199
+ /** The CLI's own arguments that attach the workspace server, or nothing without one. */
200
+ export function workspaceMcpArgs(id, server) {
201
+ if (!server)
202
+ return [];
203
+ if (id === 'claude') {
204
+ return ['--mcp-config', JSON.stringify({
205
+ mcpServers: { [WORKSPACE_MCP_SERVER_NAME]: { type: 'stdio', command: server.command, args: server.args } },
206
+ })];
207
+ }
208
+ const key = `mcp_servers.${WORKSPACE_MCP_SERVER_NAME}`;
209
+ return [
210
+ '-c', `${key}.command=${tomlString(server.command)}`,
211
+ '-c', `${key}.args=${tomlArray(server.args)}`,
212
+ '-c', `${key}.env_vars=${tomlArray(WORKSPACE_MCP_ENV)}`,
213
+ '-c', `${key}.startup_timeout_sec=30`,
214
+ ];
215
+ }
216
+ /**
217
+ * The CLI's full argument list. Codex takes its overrides first, before any
218
+ * subcommand. Claude Code's `--mcp-config` takes several values, so it goes
219
+ * after the user's flags, ahead of a `--` they typed, and a prompt follows its
220
+ * own `--` so a prompt starting with "-" is never read as a flag.
221
+ */
222
+ export function agentCliArgs(id, request, server) {
223
+ const attach = workspaceMcpArgs(id, server);
224
+ const prompt = request.prompt?.trim() ? ['--', request.prompt] : [];
225
+ if (id === 'codex')
226
+ return [...attach, ...request.words, ...prompt];
227
+ const separator = request.words.indexOf('--');
228
+ if (separator !== -1) {
229
+ return [...request.words.slice(0, separator), ...attach, ...request.words.slice(separator), ...(request.prompt?.trim() ? [request.prompt] : [])];
230
+ }
231
+ return [...request.words, ...attach, ...prompt];
232
+ }
233
+ const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'yash', 'ash']);
234
+ /**
235
+ * Run the binary from a login shell, as custom panes are, so PATH and the rest
236
+ * of the login environment apply. The binary and its arguments travel as
237
+ * separate argv entries (`exec "$0" "$@"`): no shell ever re-parses them, so
238
+ * quotes, `$` and `;` in a prompt arrive exactly as written. A shell outside
239
+ * the POSIX family (fish, nushell), or no shell at all, is replaced by /bin/sh
240
+ * for this one step.
241
+ */
242
+ export function loginShellExec(binary, args, shell, platform = process.platform) {
243
+ if (platform === 'win32')
244
+ return { command: binary, args: [...args] };
245
+ const login = shell && POSIX_SHELLS.has(basename(shell)) ? shell : '/bin/sh';
246
+ return { command: login, args: ['-lc', 'exec "$0" "$@"', binary, ...args] };
247
+ }
248
+ /** "Claude Code", then "Claude Code 2", "Claude Code 3"… so every pane name stays unique. */
249
+ export function nextPaneTitle(label, existing) {
250
+ const taken = new Set(existing.map(title => title.trim().toLowerCase()));
251
+ if (!taken.has(label.toLowerCase()))
252
+ return label;
253
+ for (let n = 2;; n++) {
254
+ const candidate = `${label} ${n}`;
255
+ if (!taken.has(candidate.toLowerCase()))
256
+ return candidate;
257
+ }
258
+ }
259
+ /**
260
+ * The pane's environment overrides: ZTC's control variables are cleared as for
261
+ * any user program, except what the workspace server needs to reach this
262
+ * workspace. Everything else is the user's own environment, untouched, so the
263
+ * CLI offers every sign-in method it normally would.
264
+ */
265
+ export function agentPaneEnv(source = process.env) {
266
+ const env = buildUserSubprocessEnvOverrides();
267
+ for (const key of WORKSPACE_MCP_ENV) {
268
+ if (key === 'ZTC_PANE_ID')
269
+ continue; // the renderer sets each pane's own
270
+ const value = source[key];
271
+ if (value)
272
+ env[key] = value;
273
+ }
274
+ return env;
275
+ }
276
+ /**
277
+ * How to start `ztc workspace-mcp` from the running installation: the desktop
278
+ * app's own launcher, or this Node and entrypoint.
279
+ */
280
+ export function workspaceMcpProgram(argv = process.argv) {
281
+ const owned = resolveOwnedCliLaunch(['workspace-mcp']);
282
+ if (owned)
283
+ return { command: owned.command, args: owned.args };
284
+ const script = argv[1] || '';
285
+ if (!/(^|[\\/])cli_(main|ratatui)\.(js|ts)$/.test(script)) {
286
+ return new Error('the running ZTC entrypoint is unknown');
287
+ }
288
+ if (script.endsWith('.ts')) {
289
+ const tsx = join(dirname(dirname(script)), 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
290
+ if (!existsSync(tsx))
291
+ return new Error('tsx is not installed next to the ZTC sources');
292
+ return { command: tsx, args: [script, 'workspace-mcp'] };
293
+ }
294
+ return { command: process.execPath, args: [script, 'workspace-mcp'] };
295
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Opening agent CLI panes, and typing messages into them.
3
+ *
4
+ * `/claude`, `/codex`, the Ctrl+B c chooser, the desktop app's new-pane menu
5
+ * and the workspace MCP server all open agent panes here, so every entry point
6
+ * launches the same way. The pane is created through whichever pane transport
7
+ * the process has: the overseer's renderer connection, or a child pane's relay.
8
+ */
9
+ import type { PaneCommandTransport } from '../ui/ratatui/pane_relay.js';
10
+ import type { SplitDirection } from '../ui/ratatui/protocol.js';
11
+ import { type AgentCliId, type AgentLaunchRequest, type PaneProgram } from './agent_cli.js';
12
+ /** Request ids of agent pane launches start with this, so the overseer can tell them apart. */
13
+ export declare const AGENT_PANE_REQUEST_PREFIX = "agent-pane:";
14
+ /** Which agent CLI a `create_pane` request id launched, if any. */
15
+ export declare function agentCliFromRequestId(requestId: string | undefined): AgentCliId | null;
16
+ export interface OpenAgentPaneOptions {
17
+ cli: AgentCliId;
18
+ request?: AgentLaunchRequest;
19
+ cwd?: string;
20
+ /** Pane name; defaults to the next free "Claude Code"/"Codex" name. */
21
+ title?: string;
22
+ direction?: SplitDirection;
23
+ ratio?: number;
24
+ target?: number;
25
+ newWindow?: boolean;
26
+ findBinary?: (id: AgentCliId) => string | null;
27
+ /** The workspace server to attach; an Error opens the pane without it. */
28
+ server?: PaneProgram | Error | null;
29
+ /** Source of SHELL and the workspace variables (default: this process). */
30
+ env?: NodeJS.ProcessEnv;
31
+ }
32
+ export interface OpenAgentPaneResult {
33
+ paneId?: number;
34
+ title?: string;
35
+ cwd?: string;
36
+ error?: string;
37
+ /** Set when the pane opened but something about it should be said. */
38
+ notice?: string;
39
+ }
40
+ /** Open the user's own `claude` or `codex` in a new pane, attached to this workspace. */
41
+ export declare function openAgentPane(transport: PaneCommandTransport | null, options: OpenAgentPaneOptions): Promise<OpenAgentPaneResult>;
42
+ interface PaneMessage {
43
+ from: {
44
+ id: number;
45
+ name: string;
46
+ };
47
+ text: string;
48
+ }
49
+ /**
50
+ * A bus message as it is typed into an agent pane: one line, saying who sent
51
+ * it and, for a question or request, how to answer.
52
+ */
53
+ export declare function formatAgentPaneMessage(message: PaneMessage): string;
54
+ /** Delay between typed text and its Enter for programs that read a burst of input as a paste. */
55
+ export declare const TYPED_ENTER_DELAY_MS = 120;
56
+ /**
57
+ * Type a line into a program that is not ZTC (an agent CLI or a shell), then
58
+ * press Enter as a separate keystroke.
59
+ */
60
+ export declare function typeLineIntoPane(transport: PaneCommandTransport, paneId: number, line: string): boolean;
61
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Opening agent CLI panes, and typing messages into them.
3
+ *
4
+ * `/claude`, `/codex`, the Ctrl+B c chooser, the desktop app's new-pane menu
5
+ * and the workspace MCP server all open agent panes here, so every entry point
6
+ * launches the same way. The pane is created through whichever pane transport
7
+ * the process has: the overseer's renderer connection, or a child pane's relay.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { listWorkspacePanes, waitForPaneCreatedOrError } from '../agent/tools/pane.js';
11
+ import { AGENT_CLIS, agentCliArgs, agentPaneEnv, findAgentCli, isAgentCliId, loginShellExec, nextPaneTitle, workspaceMcpProgram, } from './agent_cli.js';
12
+ import { sessionNamePaneTitle } from './labels.js';
13
+ /** Request ids of agent pane launches start with this, so the overseer can tell them apart. */
14
+ export const AGENT_PANE_REQUEST_PREFIX = 'agent-pane:';
15
+ /** Which agent CLI a `create_pane` request id launched, if any. */
16
+ export function agentCliFromRequestId(requestId) {
17
+ if (!requestId?.startsWith(AGENT_PANE_REQUEST_PREFIX))
18
+ return null;
19
+ const id = requestId.slice(AGENT_PANE_REQUEST_PREFIX.length).split(':')[0] ?? '';
20
+ return isAgentCliId(id) ? id : null;
21
+ }
22
+ const NEEDS_PANES = 'needs ZTC\'s panes: start ZTC with zmux (plain `ztc`) or use the desktop app';
23
+ async function existingTitles(transport) {
24
+ const panes = await listWorkspacePanes(transport, 3000);
25
+ return panes instanceof Error ? [] : panes.map(pane => pane.labels?.manual_title || pane.title);
26
+ }
27
+ /** Open the user's own `claude` or `codex` in a new pane, attached to this workspace. */
28
+ export async function openAgentPane(transport, options) {
29
+ const spec = AGENT_CLIS[options.cli];
30
+ if (!transport || !transport.isMuxMode)
31
+ return { error: `${spec.label} opens in a pane, which ${NEEDS_PANES}.` };
32
+ const binary = (options.findBinary ?? findAgentCli)(options.cli);
33
+ if (!binary) {
34
+ return { error: `${spec.label} is not installed. Install it from ${spec.setupUrl}, for example with \`${spec.install}\`, then try again.` };
35
+ }
36
+ const env = options.env ?? process.env;
37
+ const server = options.server === undefined ? workspaceMcpProgram() : options.server;
38
+ const attached = server && !(server instanceof Error) ? server : null;
39
+ const title = options.title?.trim() || nextPaneTitle(spec.label, await existingTitles(transport));
40
+ const program = loginShellExec(binary, agentCliArgs(options.cli, options.request ?? { words: [] }, attached), env.SHELL);
41
+ const requestId = `${AGENT_PANE_REQUEST_PREFIX}${options.cli}:${randomUUID()}`;
42
+ const created = waitForPaneCreatedOrError(transport, { requestId }, 15000);
43
+ const sent = transport.send({
44
+ type: 'create_pane',
45
+ request_id: requestId,
46
+ command: program.command,
47
+ args: program.args,
48
+ ...(options.cwd ? { cwd: options.cwd } : {}),
49
+ title,
50
+ pane_type: 'command',
51
+ ...(options.direction ? { direction: options.direction } : {}),
52
+ ...(options.ratio !== undefined ? { ratio: options.ratio } : {}),
53
+ ...(options.target !== undefined ? { target: options.target } : {}),
54
+ ...(options.newWindow ? { new_window: true } : {}),
55
+ env: agentPaneEnv(env),
56
+ });
57
+ if (!sent) {
58
+ created.catch(() => { });
59
+ return { error: `Could not open ${spec.label}: the renderer is not connected.` };
60
+ }
61
+ let event;
62
+ try {
63
+ event = await created;
64
+ }
65
+ catch (error) {
66
+ return { error: `Could not open ${spec.label}: no pane appeared (${error.message}).` };
67
+ }
68
+ if (event.type === 'error')
69
+ return { error: `Could not open ${spec.label}: the renderer refused the pane (${event.message}).` };
70
+ if (event.type !== 'pane_created')
71
+ return { error: `Could not open ${spec.label}: unexpected response from the renderer.` };
72
+ // The CLI sets its own terminal title as it works; the pane keeps its name.
73
+ transport.send(sessionNamePaneTitle(event.pane_id, title));
74
+ const result = { paneId: event.pane_id, title };
75
+ if (options.cwd)
76
+ result.cwd = options.cwd;
77
+ if (!attached) {
78
+ const why = server instanceof Error ? server.message : 'none was given';
79
+ result.notice = `${title} opened without ZTC's workspace tools (${why}), so it cannot open or message other panes.`;
80
+ }
81
+ return result;
82
+ }
83
+ /**
84
+ * A bus message as it is typed into an agent pane: one line, saying who sent
85
+ * it and, for a question or request, how to answer.
86
+ */
87
+ export function formatAgentPaneMessage(message) {
88
+ let text = message.text;
89
+ let kind = 'Message';
90
+ try {
91
+ const parsed = JSON.parse(text);
92
+ if (parsed && parsed.__ztc_request === true && typeof parsed.text === 'string') {
93
+ text = parsed.text;
94
+ kind = parsed.kind === 'request' ? 'Request' : 'Question';
95
+ }
96
+ }
97
+ catch {
98
+ // Plain text.
99
+ }
100
+ const line = text.replace(/\s*[\r\n]+\s*/g, ' ').trim();
101
+ const who = `${message.from.name} (pane ${message.from.id})`;
102
+ return kind === 'Message'
103
+ ? `[Message from ${who}] ${line}`
104
+ : `[${kind} from ${who} — answer with the ztc send_message tool, to: ${message.from.id}] ${line}`;
105
+ }
106
+ /** Delay between typed text and its Enter for programs that read a burst of input as a paste. */
107
+ export const TYPED_ENTER_DELAY_MS = 120;
108
+ /**
109
+ * Type a line into a program that is not ZTC (an agent CLI or a shell), then
110
+ * press Enter as a separate keystroke.
111
+ */
112
+ export function typeLineIntoPane(transport, paneId, line) {
113
+ if (!transport.send({ type: 'write_pane', pane_id: paneId, input: line, submit: false }))
114
+ return false;
115
+ setTimeout(() => {
116
+ transport.send({ type: 'write_pane', pane_id: paneId, input: '\r', submit: false });
117
+ }, TYPED_ENTER_DELAY_MS);
118
+ return true;
119
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The rows of the new-pane chooser (Ctrl+B c): a shell, a ZTC agent, the
3
+ * agent CLIs the user has installed, then their own custom options.
4
+ */
5
+ import type { CustomPaneOption } from '../config/types.js';
6
+ import { type AgentCliId } from './agent_cli.js';
7
+ export interface NewPaneChoice {
8
+ value: string;
9
+ label: string;
10
+ description: string;
11
+ }
12
+ /** `agent:claude` and `agent:codex` name the agent CLI rows. */
13
+ export declare function agentCliFromChoice(value: string): AgentCliId | null;
14
+ export declare function newPaneChoices(custom: readonly CustomPaneOption[], installed: Partial<Record<AgentCliId, string | null>>): NewPaneChoice[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The rows of the new-pane chooser (Ctrl+B c): a shell, a ZTC agent, the
3
+ * agent CLIs the user has installed, then their own custom options.
4
+ */
5
+ import { basename } from 'node:path';
6
+ import { AGENT_CLIS, AGENT_CLI_IDS } from './agent_cli.js';
7
+ /** `agent:claude` and `agent:codex` name the agent CLI rows. */
8
+ export function agentCliFromChoice(value) {
9
+ const id = value.startsWith('agent:') ? value.slice('agent:'.length) : '';
10
+ return AGENT_CLI_IDS.includes(id) ? id : null;
11
+ }
12
+ function runsBinary(command, binary) {
13
+ const first = command.trim().split(/\s+/)[0] ?? '';
14
+ return basename(first) === binary;
15
+ }
16
+ export function newPaneChoices(custom, installed) {
17
+ const choices = [
18
+ { value: 'bash', label: 'bash', description: 'A shell pane' },
19
+ { value: 'ztc', label: 'ZTC', description: 'A ZTC agent pane' },
20
+ ];
21
+ for (const id of AGENT_CLI_IDS) {
22
+ const spec = AGENT_CLIS[id];
23
+ // Not installed, or already offered by the user's own custom option.
24
+ if (!installed[id] || custom.some(option => runsBinary(option.command, spec.binary)))
25
+ continue;
26
+ choices.push({
27
+ value: `agent:${id}`,
28
+ label: spec.label,
29
+ description: `${spec.label} on your own account, with ZTC's pane and message tools`,
30
+ });
31
+ }
32
+ custom.forEach((option, index) => {
33
+ choices.push({ value: `custom:${index}`, label: option.label, description: option.command });
34
+ });
35
+ return choices;
36
+ }
@@ -51,6 +51,8 @@ export interface PaneLabels {
51
51
  group?: string;
52
52
  pinned?: boolean;
53
53
  }
54
+ /** What a renderer's new-pane menu asks the owner to open; absent means a ZTC pane. */
55
+ export type PaneRequestKind = 'ztc' | 'shell' | 'claude_code' | 'codex';
54
56
  export interface PaneInfo {
55
57
  id: number;
56
58
  title: string;
@@ -533,6 +535,7 @@ export type RustEvent = DesktopTaskAction | DesktopRuntimeAction | DesktopSessio
533
535
  target?: number;
534
536
  new_window?: boolean;
535
537
  cwd?: string;
538
+ kind?: PaneRequestKind;
536
539
  } | {
537
540
  type: 'pane_created';
538
541
  pane_id: number;
@@ -418,6 +418,7 @@ export declare class MirrorStreamServer implements MirrorPublisher {
418
418
  private multiPaneMode;
419
419
  private paneInputCallback;
420
420
  private paneControlCallback;
421
+ private agentPaneMessageCallback;
421
422
  private muxCommandCallback;
422
423
  private supervisionCommandCallback;
423
424
  private paneViewportCallback;
@@ -641,6 +642,12 @@ export declare class MirrorStreamServer implements MirrorPublisher {
641
642
  */
642
643
  onPaneInput(cb: (paneId: number, input: MessageInput) => void): void;
643
644
  onPaneControl(cb: (paneId: number, controlLine: string) => void): void;
645
+ /**
646
+ * Claim messages for panes that run another agent CLI (Claude Code, Codex).
647
+ * The callback returns true when it delivered the message (the owner types
648
+ * it into the pane with its sender); false leaves the usual fallbacks.
649
+ */
650
+ onAgentPaneMessage(cb: (paneId: number, msg: BusMessage) => boolean): void;
644
651
  onMuxCommand(cb: (command: MirrorMuxCommand) => Promise<MirrorMuxCommandResult>): void;
645
652
  onSupervisionCommand(cb: (command: MirrorSupervisionCommand) => Promise<MirrorSupervisionCommandResult>): void;
646
653
  onPaneViewport(cb: (paneId: number, ansi?: boolean) => Promise<MirrorPaneViewportResult>): void;
@@ -167,6 +167,7 @@ export class MirrorStreamServer {
167
167
  // rather than replaying it to the LLM. Kept separate from paneInputCallback (the
168
168
  // user-prompt channel) so the two can never be confused by content.
169
169
  paneControlCallback = null;
170
+ agentPaneMessageCallback = null;
170
171
  muxCommandCallback = null;
171
172
  supervisionCommandCallback = null;
172
173
  paneViewportCallback = null;
@@ -558,6 +559,14 @@ export class MirrorStreamServer {
558
559
  onPaneControl(cb) {
559
560
  this.paneControlCallback = cb;
560
561
  }
562
+ /**
563
+ * Claim messages for panes that run another agent CLI (Claude Code, Codex).
564
+ * The callback returns true when it delivered the message (the owner types
565
+ * it into the pane with its sender); false leaves the usual fallbacks.
566
+ */
567
+ onAgentPaneMessage(cb) {
568
+ this.agentPaneMessageCallback = cb;
569
+ }
561
570
  onMuxCommand(cb) {
562
571
  this.muxCommandCallback = cb;
563
572
  }
@@ -724,6 +733,14 @@ export class MirrorStreamServer {
724
733
  return;
725
734
  }
726
735
  }
736
+ // A pane running Claude Code or Codex has no bus connection: the owner
737
+ // types an agent's message into it, saying who sent it.
738
+ if (typeof targetId === 'number' && targetId !== 0 && msg.type === 'ztc_message'
739
+ && this.agentPaneMessageCallback?.(targetId, msg)) {
740
+ if (process.env.ZTC_DEBUG)
741
+ process.stderr.write(`${tag} delivered to an agent CLI pane\n`);
742
+ return;
743
+ }
727
744
  // Headless ZTC pane children read input from their own stdin — they have no
728
745
  // bus callback or SSE connection.
729
746
  // A ztc_message is an internal control command ({"__ztc_cmd":...}), NOT user
@@ -78502,7 +78502,7 @@ var invokeSkillTool = makeInvokeSkillTool(resolveAmbientSkills);
78502
78502
  // src/zlm.ts
78503
78503
  var ZLM_EMBED_API_VERSION = "zerglang.ztc-embed/1";
78504
78504
  var ZLM_STDIO_PROTOCOL = "zerglang.ztc-host/1";
78505
- var ZLM_ZTC_VERSION = true ? "0.9.0-beta.1" : "development";
78505
+ var ZLM_ZTC_VERSION = true ? "0.10.0-beta.1" : "development";
78506
78506
  var ZLM_MAX_HOST_TOOLS = 64;
78507
78507
  var ZLM_MAX_SYSTEM_PROMPT_BYTES = 128 * 1024;
78508
78508
  var ZLM_HOST_APPROVAL_CAPABILITIES = Object.freeze([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zergai/ztc",
3
- "version": "0.9.0-beta.1",
3
+ "version": "0.10.0-beta.1",
4
4
  "description": "Zerg Terminal Client - CLI agent for continual AI systems",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "homepage": "https://zergai.com/docs/ztc",
@@ -277,7 +277,7 @@
277
277
  "zergRelease": {
278
278
  "branch": "main",
279
279
  "channel": "next",
280
- "sourceCommit": "eac530d341d6b7057b5fcc242ce3d9c835ceeeb1",
280
+ "sourceCommit": "f987bf5a7f5b3b8c9b6ed6e5ace077242bdb2be6",
281
281
  "sourceVersion": "0.2.0-canary.2"
282
282
  },
283
283
  "publishConfig": {