@zeph-to/cli 2.12.0 → 2.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 CHANGED
@@ -200,8 +200,11 @@ block here.
200
200
  ### Setup
201
201
 
202
202
  1. **Install tmux.** The listener uses `send-keys`; the wrapper spawns
203
- named sessions. `brew install tmux` on macOS, `apt install tmux` on
204
- Debian/Ubuntu.
203
+ named sessions. `zeph install` checks for it and, on macOS with Homebrew,
204
+ offers to run `brew install tmux` for you. Elsewhere it prints the line to
205
+ run — `sudo apt install tmux` on Debian/Ubuntu, and so on — because
206
+ installing with `sudo` is not something an installer should do on your
207
+ behalf. tmux has no Windows build, so `zeph cc` needs WSL there.
205
208
 
206
209
  2. **Add `wsUrl` to `~/.zeph/config.json`** (the WebSocket endpoint of
207
210
  your Zeph backend — CDK output `WsApiUrl`):
@@ -527,7 +530,7 @@ zeph notify --title "Hello" --json
527
530
  | `dismiss <id>` | Dismiss a push (or `--all`) |
528
531
  | `rename <name>` | Set the current agent session's display name in the app — run inside a `zeph cc` session (`--clear` resets). Auto-detects the tmux session + this machine's listener device id, so the alias lands on the right device |
529
532
  | `test` | Verify connection and API key |
530
- | `cc` · `codex` · `gemini` | Run the agent in a `zeph-<project>` tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes `-2`, `-3`, …. Auto-spawns the background listener on first invocation so the phone picker just works. Trailing args pass through to the agent (`zeph cc --resume "..."`) |
533
+ | `cc` · `codex` · `gemini` | Run the agent in a `zeph-<project>` tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes `-2`, `-3`, …. Auto-spawns the background listener on first invocation so the phone picker just works. Trailing args pass through to the agent (`zeph cc --resume "..."`). On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no `zeph cc` of its own in `ps` — older runtimes and Windows keep the waiting wrapper |
531
534
  | `mcp` | Run the MCP server on stdio, in this process. What agent MCP configs launch — `zeph install` registers it and you never type it. Replaces the old `npx -y @zeph-to/mcp-server` registration, which left an `npm exec` launcher resident alongside the server for the life of the session. `@zeph-to/mcp-server` ships as a dependency now, so it updates with the CLI rather than being re-fetched by `npx` on every launch — `zeph check-update` reports the version you actually have |
532
535
  | `listener` | (Usually unnecessary — `zeph cc` autospawns it.) Resident daemon: subscribes via WebSocket, reports tmux session inventory every 5 s, injects `agent.command` pushes into the matching session. Run in the foreground for SDK development; otherwise let `zeph cc` manage it |
533
536
 
package/dist/agents.d.ts CHANGED
@@ -3,6 +3,13 @@ export interface Agent {
3
3
  id: string;
4
4
  detected: boolean;
5
5
  }
6
+ /**
7
+ * Absolute path of a command on PATH, or null when it isn't there.
8
+ * `execFileSync`, not a shell: `process.execve` needs a real path (it does no
9
+ * PATH lookup of its own), and building `which ${cmd}` as a shell string would
10
+ * let a path containing a space split into two words.
11
+ */
12
+ export declare const resolveCommand: (cmd: string) => string | null;
6
13
  export declare const hasCommand: (cmd: string) => boolean;
7
14
  export declare const detectAgents: () => Agent[];
8
15
  //# sourceMappingURL=agents.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../src/agents.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,KAAK;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;CACrB;AAID,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,OAOxC,CAAC;AAEF,eAAO,MAAM,YAAY,QAAO,KAAK,EAWpC,CAAC"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../src/agents.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,KAAK;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;CACrB;AAID;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAMrD,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,OAAuC,CAAC;AAEjF,eAAO,MAAM,YAAY,QAAO,KAAK,EAWpC,CAAC"}
package/dist/agents.js CHANGED
@@ -1,20 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.detectAgents = exports.hasCommand = void 0;
3
+ exports.detectAgents = exports.hasCommand = exports.resolveCommand = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
7
7
  const path_1 = require("path");
8
8
  const HOME = (0, os_1.homedir)();
9
- const hasCommand = (cmd) => {
9
+ /**
10
+ * Absolute path of a command on PATH, or null when it isn't there.
11
+ * `execFileSync`, not a shell: `process.execve` needs a real path (it does no
12
+ * PATH lookup of its own), and building `which ${cmd}` as a shell string would
13
+ * let a path containing a space split into two words.
14
+ */
15
+ const resolveCommand = (cmd) => {
10
16
  try {
11
- (0, child_process_1.execSync)(`which ${cmd}`, { stdio: 'pipe' });
12
- return true;
17
+ return (0, child_process_1.execFileSync)('which', [cmd], { encoding: 'utf-8' }).trim() || null;
13
18
  }
14
19
  catch {
15
- return false;
20
+ return null;
16
21
  }
17
22
  };
23
+ exports.resolveCommand = resolveCommand;
24
+ const hasCommand = (cmd) => (0, exports.resolveCommand)(cmd) !== null;
18
25
  exports.hasCommand = hasCommand;
19
26
  const detectAgents = () => [
20
27
  { name: 'Claude Code', id: 'claude', detected: (0, exports.hasCommand)('claude') },
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA+BzC;;;GAGG;AACH;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,YAAY,MAAM,GAAG,SAAS,EAAE,SAAS,OAAO,KAAG,OACxD,CAAC;AAoBzB;6EAC6E;AAC7E,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAqBhF,CAAC;AAsCF;mEACmE;AACnE,eAAO,MAAM,cAAc,GAAI,UAAU,MAAM,EAAE,KAAK,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAQ9F,CAAC;AAmPF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,GAC/B,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,gBAAgB,OAAO,KACtB,KAAK,GAAG,IAAI,GAAG,KAKjB,CAAC;AAgCF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAuH1F,CAAC"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA+BzC;;;GAGG;AACH;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,YAAY,MAAM,GAAG,SAAS,EAAE,SAAS,OAAO,KAAG,OACxD,CAAC;AAoBzB;6EAC6E;AAC7E,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAqBhF,CAAC;AAsCF;mEACmE;AACnE,eAAO,MAAM,cAAc,GAAI,UAAU,MAAM,EAAE,KAAK,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAQ9F,CAAC;AAmPF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,GAC/B,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,gBAAgB,OAAO,KACtB,KAAK,GAAG,IAAI,GAAG,KAKjB,CAAC;AAgCF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CA0I1F,CAAC"}
package/dist/installer.js CHANGED
@@ -10,6 +10,7 @@ const zeph_hook_js_1 = require("./zeph-hook.js");
10
10
  const config_js_1 = require("./config.js");
11
11
  const login_js_1 = require("./login.js");
12
12
  const agents_js_1 = require("./agents.js");
13
+ const tmux_install_js_1 = require("./tmux-install.js");
13
14
  const mcp_command_js_1 = require("./mcp-command.js");
14
15
  const listener_service_js_1 = require("./listener-service.js");
15
16
  const templates_js_1 = require("./templates.js");
@@ -464,7 +465,7 @@ const applyServiceChoice = async (choice) => {
464
465
  try {
465
466
  const { confirm } = await import('@inquirer/prompts');
466
467
  wanted = await confirm({
467
- message: 'Start the listener at every login? (so the phone sees this machine after a reboot)',
468
+ message: 'Start the listener at every login, so the phone sees this machine after a reboot? (enter to confirm)',
468
469
  default: true,
469
470
  });
470
471
  }
@@ -551,6 +552,7 @@ const handleInstall = async (args) => {
551
552
  if (selected.length === 0) {
552
553
  console.log(' (no agents selected — only the config file will be saved)');
553
554
  }
555
+ console.log(' - Check tmux (`zeph cc` runs agents inside it)');
554
556
  if ((0, exports.serviceInstallChoice)(args, nonInteractive) !== 'no') {
555
557
  console.log(' - Offer to start the listener at every login');
556
558
  }
@@ -577,10 +579,28 @@ const handleInstall = async (args) => {
577
579
  if (installer)
578
580
  installer();
579
581
  }
580
- // 7. Login-time service the only thing that makes the phone see this
582
+ // 7. tmux`zeph cc` is tmux or nothing, so check before claiming success.
583
+ console.log('\n Checking tmux...');
584
+ await (0, tmux_install_js_1.ensureTmux)({
585
+ plan: (0, tmux_install_js_1.planTmuxInstall)({ resolve: agents_js_1.resolveCommand, platform: process.platform }),
586
+ nonInteractive,
587
+ askConfirm: async (message) => {
588
+ try {
589
+ const { confirm } = await import('@inquirer/prompts');
590
+ return await confirm({ message, default: true });
591
+ }
592
+ catch {
593
+ // Ctrl-C or no TTY — an unanswered question is not a yes.
594
+ return false;
595
+ }
596
+ },
597
+ run: (argv) => { (0, child_process_1.execFileSync)(argv[0], argv.slice(1), { stdio: 'inherit' }); },
598
+ report: { ok, fail, note: skipNote },
599
+ });
600
+ // 8. Login-time service — the only thing that makes the phone see this
581
601
  // machine after a reboot without a terminal being opened first.
582
602
  await applyServiceChoice((0, exports.serviceInstallChoice)(args, nonInteractive));
583
- // 8. Test connection
603
+ // 9. Test connection
584
604
  console.log('\n Testing connection...');
585
605
  await testConnection(apiKey, baseUrl);
586
606
  console.log('\n Done! Restart your agents.\n');
@@ -0,0 +1,44 @@
1
+ /**
2
+ * tmux is not optional for `zeph cc` — every remote-agent subcommand runs the
3
+ * agent inside a named tmux session so the listener (and the phone picker) can
4
+ * reach it. Nothing in the install flow used to mention that, so a machine
5
+ * without tmux looked fully set up until the first `zeph cc` failed with an
6
+ * error from a program the user was never told they needed.
7
+ */
8
+ /** What this machine can do about a missing tmux. */
9
+ export type TmuxInstallPlan = {
10
+ readonly kind: 'present';
11
+ readonly path: string;
12
+ }
13
+ /** Safe to run unattended — no root, user-writable prefix. */
14
+ | {
15
+ readonly kind: 'command';
16
+ readonly run: readonly string[];
17
+ readonly label: string;
18
+ }
19
+ /** Needs root, or there is no package manager we recognise. Tell, don't do. */
20
+ | {
21
+ readonly kind: 'manual';
22
+ readonly hint: string;
23
+ };
24
+ export declare const planTmuxInstall: (deps: {
25
+ resolve: (cmd: string) => string | null;
26
+ platform: NodeJS.Platform;
27
+ }) => TmuxInstallPlan;
28
+ /**
29
+ * Act on the plan. Everything the outside world touches is injected, because
30
+ * the interesting branch — "offer, then run a package manager" — is otherwise
31
+ * only reachable through an interactive install that no test can drive.
32
+ */
33
+ export declare const ensureTmux: (deps: {
34
+ plan: TmuxInstallPlan;
35
+ nonInteractive: boolean;
36
+ askConfirm: (message: string) => Promise<boolean>;
37
+ run: (argv: readonly string[]) => void;
38
+ report: {
39
+ ok: (m: string) => void;
40
+ fail: (m: string) => void;
41
+ note: (m: string) => void;
42
+ };
43
+ }) => Promise<void>;
44
+ //# sourceMappingURL=tmux-install.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tmux-install.d.ts","sourceRoot":"","sources":["../src/tmux-install.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,qDAAqD;AACrD,MAAM,MAAM,eAAe,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AACrD,8DAA8D;GAC5D;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACvF,+EAA+E;GAC7E;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAWzD,eAAO,MAAM,eAAe,GAAI,MAAM;IAClC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC;CAC7B,KAAG,eAoBH,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAU,MAAM;IACnC,IAAI,EAAE,eAAe,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;IACxB,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,GAAG,EAAE,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IACvC,MAAM,EAAE;QAAE,EAAE,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;CAC7F,KAAG,OAAO,CAAC,IAAI,CAsBf,CAAC"}
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ /**
3
+ * tmux is not optional for `zeph cc` — every remote-agent subcommand runs the
4
+ * agent inside a named tmux session so the listener (and the phone picker) can
5
+ * reach it. Nothing in the install flow used to mention that, so a machine
6
+ * without tmux looked fully set up until the first `zeph cc` failed with an
7
+ * error from a program the user was never told they needed.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ensureTmux = exports.planTmuxInstall = void 0;
11
+ /** Package managers that need root, and the line to hand the user for each. */
12
+ const ROOT_PACKAGE_MANAGERS = [
13
+ { bin: 'apt-get', hint: 'sudo apt install tmux' },
14
+ { bin: 'dnf', hint: 'sudo dnf install tmux' },
15
+ { bin: 'pacman', hint: 'sudo pacman -S tmux' },
16
+ { bin: 'zypper', hint: 'sudo zypper install tmux' },
17
+ { bin: 'apk', hint: 'sudo apk add tmux' },
18
+ ];
19
+ const planTmuxInstall = (deps) => {
20
+ const path = deps.resolve('tmux');
21
+ if (path)
22
+ return { kind: 'present', path };
23
+ // tmux has no native Windows build. `zeph cc` is POSIX-only by way of tmux,
24
+ // so point at WSL rather than at a package that cannot be installed.
25
+ if (deps.platform === 'win32') {
26
+ return { kind: 'manual', hint: '`zeph cc` needs tmux, which has no Windows build — run it under WSL' };
27
+ }
28
+ if (deps.platform === 'darwin') {
29
+ return deps.resolve('brew')
30
+ ? { kind: 'command', run: ['brew', 'install', 'tmux'], label: 'brew install tmux' }
31
+ : { kind: 'manual', hint: 'install Homebrew (brew.sh), then: brew install tmux' };
32
+ }
33
+ const rootManager = ROOT_PACKAGE_MANAGERS.find((pm) => deps.resolve(pm.bin));
34
+ if (rootManager)
35
+ return { kind: 'manual', hint: rootManager.hint };
36
+ return { kind: 'manual', hint: 'install tmux with this system\'s package manager' };
37
+ };
38
+ exports.planTmuxInstall = planTmuxInstall;
39
+ /**
40
+ * Act on the plan. Everything the outside world touches is injected, because
41
+ * the interesting branch — "offer, then run a package manager" — is otherwise
42
+ * only reachable through an interactive install that no test can drive.
43
+ */
44
+ const ensureTmux = async (deps) => {
45
+ const { plan, report } = deps;
46
+ if (plan.kind === 'present')
47
+ return report.ok(`tmux found (${plan.path})`);
48
+ if (plan.kind === 'manual')
49
+ return report.fail(`tmux not found — \`zeph cc\` needs it. ${plan.hint}`);
50
+ if (deps.nonInteractive) {
51
+ return report.note(`tmux not found — \`zeph cc\` needs it. Run: ${plan.label}`);
52
+ }
53
+ const wanted = await deps.askConfirm(`tmux is missing and \`zeph cc\` needs it — run \`${plan.label}\`? (enter to confirm)`);
54
+ if (!wanted) {
55
+ return report.note(`tmux not installed — \`zeph cc\` will not work until you run: ${plan.label}`);
56
+ }
57
+ try {
58
+ deps.run(plan.run);
59
+ report.ok('tmux installed');
60
+ }
61
+ catch {
62
+ report.fail(`\`${plan.label}\` failed — run it by hand`);
63
+ }
64
+ };
65
+ exports.ensureTmux = ensureTmux;
package/dist/verify.d.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  export declare const registeredMcpArgv: (filePath: string, key: string) => string[] | null;
9
9
  /**
10
10
  * Can this launch binary actually be run? Registered commands come out of a
11
- * config file, so they can be an absolute path with spaces in it — the shared
12
- * `hasCommand` interpolates into `which ${cmd}`, where such a path splits into
13
- * two words and reports a perfectly good binary as missing (and where any `;`
14
- * in the file would reach the shell). Nothing here touches a shell.
11
+ * config file, so unlike everywhere else in the CLI they can be an absolute
12
+ * path — `which` is the wrong question for one of those, hence the split.
13
+ * The bare-name half is `resolveCommand`; neither half touches a shell, so a
14
+ * path with a space in it stays one word and a `;` in the file stays inert.
15
15
  */
16
16
  export declare const binaryResolves: (bin: string) => boolean;
17
17
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AA6DA;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,GAAG,IAa5E,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,OAQ5C,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GACrB;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAC7B;IAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAElE,wFAAwF;AACxF,eAAO,MAAM,uBAAuB,GAChC,MAAM,MAAM,EAAE,GAAG,IAAI,EACrB,UAAU,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KACnC,eAAe,GAAG,IAMpB,CAAC;AAYF,eAAO,MAAM,YAAY,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CA+GzF,CAAC"}
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AA4DA;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,EAAE,GAAG,IAa5E,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,OAQ5C,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GACrB;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAC7B;IAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAElE,wFAAwF;AACxF,eAAO,MAAM,uBAAuB,GAChC,MAAM,MAAM,EAAE,GAAG,IAAI,EACrB,UAAU,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KACnC,eAAe,GAAG,IAMpB,CAAC;AAYF,eAAO,MAAM,YAAY,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CA+GzF,CAAC"}
package/dist/verify.js CHANGED
@@ -4,7 +4,6 @@ exports.handleVerify = exports.classifyMcpRegistration = exports.binaryResolves
4
4
  const fs_1 = require("fs");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
7
- const child_process_1 = require("child_process");
8
7
  const agents_js_1 = require("./agents.js");
9
8
  const config_js_1 = require("./config.js");
10
9
  const listener_service_js_1 = require("./listener-service.js");
@@ -85,17 +84,16 @@ const registeredMcpArgv = (filePath, key) => {
85
84
  exports.registeredMcpArgv = registeredMcpArgv;
86
85
  /**
87
86
  * Can this launch binary actually be run? Registered commands come out of a
88
- * config file, so they can be an absolute path with spaces in it — the shared
89
- * `hasCommand` interpolates into `which ${cmd}`, where such a path splits into
90
- * two words and reports a perfectly good binary as missing (and where any `;`
91
- * in the file would reach the shell). Nothing here touches a shell.
87
+ * config file, so unlike everywhere else in the CLI they can be an absolute
88
+ * path — `which` is the wrong question for one of those, hence the split.
89
+ * The bare-name half is `resolveCommand`; neither half touches a shell, so a
90
+ * path with a space in it stays one word and a `;` in the file stays inert.
92
91
  */
93
92
  const binaryResolves = (bin) => {
93
+ if (!bin.includes('/'))
94
+ return (0, agents_js_1.resolveCommand)(bin) !== null;
94
95
  try {
95
- if (bin.includes('/'))
96
- (0, fs_1.accessSync)(bin, fs_1.constants.X_OK);
97
- else
98
- (0, child_process_1.execFileSync)('which', [bin], { stdio: 'pipe' });
96
+ (0, fs_1.accessSync)(bin, fs_1.constants.X_OK);
99
97
  return true;
100
98
  }
101
99
  catch {
package/dist/wrapper.d.ts CHANGED
@@ -27,6 +27,19 @@ export declare const tmuxSessionName: (project: string) => string;
27
27
  * this" from "the user closed the terminal and will come back".
28
28
  */
29
29
  export declare const findAvailableSession: (base: string) => string;
30
+ interface SpawnTarget {
31
+ /**
32
+ * `tmux-new` opens (or reattaches) a session and needs a terminal to
33
+ * attach to; `direct` runs the agent in the pane we are already in and
34
+ * does not. Several decisions downstream turn on which one this is, so
35
+ * `targetForAgent` states it rather than letting them re-derive it from
36
+ * `cmd === 'tmux'`.
37
+ */
38
+ kind: 'direct' | 'tmux-new';
39
+ cmd: string;
40
+ args: string[];
41
+ }
42
+ export declare const targetForAgent: (agent: string, extra: string[]) => SpawnTarget;
30
43
  /**
31
44
  * Whether the daemon on record is running a different build than the one
32
45
  * we're launching from. `npm i -g @zeph-to/cli` swaps dist/ but leaves the
@@ -58,5 +71,27 @@ export declare const ensureListenerRunning: () => Promise<void>;
58
71
  * `zeph cc --resume foo` runs `claude --resume foo` inside the session.
59
72
  * Returns when the agent exits.
60
73
  */
74
+ /** `process.execve` with the optionality resolved — see handOffExec. */
75
+ export type ProcessHandOff = NonNullable<typeof process.execve>;
76
+ /**
77
+ * The exec that replaces this process with the child, or null when the wrapper
78
+ * has to stay and wait instead. Returning the function rather than a boolean
79
+ * keeps one check where two would otherwise drift: the platform gate and the
80
+ * policy are the same answer.
81
+ *
82
+ * `process.execve` is declared optional by @types/node, and is absent on node
83
+ * <22.15 (added there) and on Windows (its doc: "not available on Windows or
84
+ * IBM i"), so its presence covers both without a version check. `engines.node`
85
+ * is `>=18`, so the spawn path below is not a transitional fallback — it stays.
86
+ *
87
+ * `isTTY` is `undefined` rather than `false` on a pipe, hence every `=== true`.
88
+ */
89
+ export declare const handOffExec: (deps: {
90
+ execve: ProcessHandOff | undefined;
91
+ kind: SpawnTarget["kind"];
92
+ stdinTTY: boolean | undefined;
93
+ stdoutTTY: boolean | undefined;
94
+ }) => ProcessHandOff | null;
61
95
  export declare const handleAgentSession: (agent: RemoteAgent, extra?: string[]) => Promise<number>;
96
+ export {};
62
97
  //# sourceMappingURL=wrapper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../src/wrapper.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAOtD,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,QAAO,MAapC,CAAC;AAEF,+DAA+D;AAC/D,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,KAAG,MAA2B,CAAC;AAkC9E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,MAOnD,CAAC;AAmCF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,sBAAsB,GAAI,SAAS,MAAM,GAAG,IAAI,EAAE,WAAW,MAAM,KAAG,OAChC,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAa,OAAO,CAAC,IAAI,CAgC1D,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAU,OAAO,WAAW,EAAE,QAAO,MAAM,EAAO,KAAG,OAAO,CAAC,MAAM,CAmCjG,CAAC"}
1
+ {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../src/wrapper.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAOtD,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,QAAO,MAapC,CAAC;AAEF,+DAA+D;AAC/D,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,KAAG,MAA2B,CAAC;AAkC9E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,MAOnD,CAAC;AAEF,UAAU,WAAW;IACjB;;;;;;OAMG;IACH,IAAI,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,EAAE,CAAC;CAClB;AAOD,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,EAAE,OAAO,MAAM,EAAE,KAAG,WAmB/D,CAAC;AAIF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,sBAAsB,GAAI,SAAS,MAAM,GAAG,IAAI,EAAE,WAAW,MAAM,KAAG,OAChC,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAa,OAAO,CAAC,IAAI,CAgC1D,CAAC;AAEF;;;;;GAKG;AACH,wEAAwE;AACxE,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC;AAEhE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM;IAC9B,MAAM,EAAE,cAAc,GAAG,SAAS,CAAC;IACnC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC1B,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,SAAS,EAAE,OAAO,GAAG,SAAS,CAAC;CAClC,KAAG,cAAc,GAAG,IASpB,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAU,OAAO,WAAW,EAAE,QAAO,MAAM,EAAO,KAAG,OAAO,CAAC,MAAM,CA+DjG,CAAC"}
package/dist/wrapper.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleAgentSession = exports.ensureListenerRunning = exports.listenerVersionDrifted = exports.findAvailableSession = exports.tmuxSessionName = exports.detectProjectName = void 0;
3
+ exports.handleAgentSession = exports.handOffExec = exports.ensureListenerRunning = exports.listenerVersionDrifted = exports.targetForAgent = exports.findAvailableSession = exports.tmuxSessionName = exports.detectProjectName = void 0;
4
4
  /**
5
5
  * `zeph cc` / `zeph codex` / `zeph gemini` — spawn an agent inside a named
6
6
  * tmux session so the resident listener (`zeph listener`) can address it
@@ -14,6 +14,7 @@ exports.handleAgentSession = exports.ensureListenerRunning = exports.listenerVer
14
14
  */
15
15
  const child_process_1 = require("child_process");
16
16
  const path_1 = require("path");
17
+ const agents_js_1 = require("./agents.js");
17
18
  const check_update_js_1 = require("./check-update.js");
18
19
  const config_js_1 = require("./config.js");
19
20
  const listener_process_js_1 = require("./listener-process.js");
@@ -114,7 +115,7 @@ const targetForAgent = (agent, extra) => {
114
115
  // current pane. Nested tmux prefix collisions are confusing and the
115
116
  // listener can't reach a session it didn't name anyway.
116
117
  if (process.env.TMUX) {
117
- return { cmd: agent, args: extra };
118
+ return { kind: 'direct', cmd: agent, args: extra };
118
119
  }
119
120
  const base = (0, exports.tmuxSessionName)((0, exports.detectProjectName)());
120
121
  // Reattach a detached session of this project when there is one,
@@ -127,8 +128,9 @@ const targetForAgent = (agent, extra) => {
127
128
  // `--resume` would be eaten by tmux's own parser. Build one quoted
128
129
  // shell string instead, which tmux passes through verbatim.
129
130
  const shellCmd = [agent, ...extra].map(shellQuote).join(' ');
130
- return { cmd: 'tmux', args: ['new', '-A', '-s', session, shellCmd] };
131
+ return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, shellCmd] };
131
132
  };
133
+ exports.targetForAgent = targetForAgent;
132
134
  // ── Background listener auto-start ────────────────────────────────────
133
135
  /**
134
136
  * Whether the daemon on record is running a different build than the one
@@ -193,27 +195,74 @@ const ensureListenerRunning = async () => {
193
195
  };
194
196
  exports.ensureListenerRunning = ensureListenerRunning;
195
197
  /**
196
- * Launch the agent in a named tmux session (or directly if nested) and
197
- * forward its exit code. `extra` is appended to the agent invocation, so
198
- * `zeph cc --resume foo` runs `claude --resume foo` inside the session.
199
- * Returns when the agent exits.
198
+ * The exec that replaces this process with the child, or null when the wrapper
199
+ * has to stay and wait instead. Returning the function rather than a boolean
200
+ * keeps one check where two would otherwise drift: the platform gate and the
201
+ * policy are the same answer.
202
+ *
203
+ * `process.execve` is declared optional by @types/node, and is absent on node
204
+ * <22.15 (added there) and on Windows (its doc: "not available on Windows or
205
+ * IBM i"), so its presence covers both without a version check. `engines.node`
206
+ * is `>=18`, so the spawn path below is not a transitional fallback — it stays.
207
+ *
208
+ * `isTTY` is `undefined` rather than `false` on a pipe, hence every `=== true`.
200
209
  */
210
+ const handOffExec = (deps) => {
211
+ if (!deps.execve)
212
+ return null;
213
+ // execve runs no cleanup handler, and node only writes stdout synchronously
214
+ // to a TTY on POSIX — anything buffered to a pipe would vanish with us.
215
+ if (deps.stdoutTTY !== true)
216
+ return null;
217
+ // `tmux new` additionally needs a terminal of its own to attach to. That is
218
+ // the failure the spawn path below exists to explain, so send it there.
219
+ if (deps.kind === 'tmux-new' && deps.stdinTTY !== true)
220
+ return null;
221
+ return deps.execve;
222
+ };
223
+ exports.handOffExec = handOffExec;
201
224
  const handleAgentSession = async (agent, extra = []) => {
202
225
  // Best-effort: make sure the phone-bridge daemon is running, and running
203
226
  // the build we were launched from. The user shouldn't need to remember a
204
227
  // second command for the picker on their phone to work.
205
228
  await (0, exports.ensureListenerRunning)();
229
+ const { kind, cmd, args } = (0, exports.targetForAgent)(agent.binary, extra);
230
+ // Hand the terminal over and stop existing. Waiting on the child is all
231
+ // this process does for the rest of the session, and it holds a whole node
232
+ // heap to do it — one resident process per `zeph cc`, purely to forward an
233
+ // exit code that execve gives us for free by being the same process.
234
+ const handOff = (0, exports.handOffExec)({
235
+ execve: process.execve,
236
+ kind,
237
+ stdinTTY: process.stdin.isTTY,
238
+ stdoutTTY: process.stdout.isTTY,
239
+ });
240
+ if (handOff) {
241
+ const file = (0, agents_js_1.resolveCommand)(cmd);
242
+ // execve does no PATH lookup, so resolving is mandatory — and the
243
+ // resolve failing is the same fact the spawn path learns from ENOENT.
244
+ if (!file) {
245
+ console.error(`zeph: '${cmd}' not found on PATH`);
246
+ return 127;
247
+ }
248
+ // Nothing buffered survives: execve runs no cleanup handler and fires
249
+ // no exit event. Safe here only because stdout is a TTY, which node
250
+ // writes synchronously on POSIX — every `zeph:` line above is already out.
251
+ // The env argument is left off: it defaults to process.env.
252
+ handOff(file, [cmd, ...args]);
253
+ }
206
254
  return new Promise((resolve) => {
207
- const { cmd, args } = targetForAgent(agent.binary, extra);
208
255
  const start = Date.now();
209
256
  const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'inherit' });
210
257
  child.on('exit', (code) => {
211
258
  const dur = Date.now() - start;
212
259
  // Short-lived non-zero exits are the symptom of "ran from a
213
- // pane that isn't a real TTY" (iTerm tmux integration pane,
214
- // some IDE terminals). The user otherwise just sees their
215
- // shell return with `[exited]` and no clue what went wrong.
216
- if (code && code !== 0 && dur < 2000) {
260
+ // pane that isn't a real TTY" (some IDE terminals). The user
261
+ // otherwise just sees their shell return with `[exited]` and no
262
+ // clue what went wrong. Only `tmux new` fails that way telling
263
+ // someone whose agent died inside a perfectly good pane to "run
264
+ // from a plain shell pane" would send them after the wrong thing.
265
+ if (kind === 'tmux-new' && code && code !== 0 && dur < 2000) {
217
266
  console.error(`zeph: ${cmd} ${args.join(' ')} exited ${code} after ${dur}ms.\n` +
218
267
  ` If this terminal is itself inside tmux (or an iTerm/Warp\n` +
219
268
  ` tmux-integration pane), run \`zeph cc\` from a plain shell\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",