@zeph-to/cli 2.6.0 → 2.8.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
@@ -244,7 +244,8 @@ block here.
244
244
  `zeph listener` by hand — every `zeph cc` checks the PID file and
245
245
  skips the spawn when one is already alive, so opening a dozen
246
246
  terminals doesn't create a dozen daemons. The daemon survives
247
- between `zeph cc` invocations.
247
+ between `zeph cc` invocations — but not a reboot, which is what the
248
+ login-time service below is for.
248
249
 
249
250
  Project name resolves from `CLAUDE_PROJECT_DIR` /
250
251
  `CURSOR_PROJECT_DIR` / `WINDSURF_PROJECT_DIR` if set, else the git
@@ -271,6 +272,43 @@ block here.
271
272
  listener can't target an unnamed session that way, but you keep your
272
273
  existing multiplexer setup.
273
274
 
275
+ ### Starting the listener at login (macOS)
276
+
277
+ A reboot ends the daemon, and nothing starts it again until you open a
278
+ terminal and run `zeph cc`. Until you do, the phone shows **no agents** —
279
+ the machine is on, the past sessions are on disk, and the app has no one
280
+ to hear from.
281
+
282
+ ```bash
283
+ zeph listener --install-service
284
+ ```
285
+
286
+ That registers a launchd LaunchAgent (`to.zeph.listener`) that starts the
287
+ listener at every login. `--uninstall-service` removes it and
288
+ `--service-status` shows what the installed one points at; `zeph install`
289
+ offers it during setup and `zeph uninstall` takes it away again.
290
+
291
+ What you get back after a reboot is your **past sessions**, ready to
292
+ resume from the phone — not live agents. tmux does not survive a reboot
293
+ either, and this doesn't try to bring those sessions back to life.
294
+
295
+ Two details worth knowing:
296
+
297
+ - It fires at **user login**, not at boot. Without automatic login, the
298
+ machine stays silent while it sits on the login screen.
299
+ - The plist bakes in absolute paths for node and the CLI, a `PATH` that
300
+ can reach tmux, and a UTF-8 `LANG`. launchd gives a job
301
+ `/usr/bin:/bin:/usr/sbin:/sbin` and no locale at all: without the PATH
302
+ the daemon exits 127 at every login, and without the locale tmux
303
+ escapes the separator the session list is split on, so every session is
304
+ silently dropped while everything else looks healthy. If any of that
305
+ drifts — a version-manager node upgrade, say — `zeph verify` names it,
306
+ and re-running `--install-service` repoints the plist.
307
+
308
+ With the service installed, launchd owns the process: `zeph cc` and
309
+ `zeph listener --stop|--restart` ask launchd rather than signalling the
310
+ PID, so there is never a second daemon racing the first.
311
+
274
312
  ### Diagnostics
275
313
 
276
314
  The auto-spawned listener writes to three files under `~/.zeph/`:
package/dist/ask.js CHANGED
@@ -141,7 +141,7 @@ const handleAsk = async (args) => {
141
141
  // hook maps that to DENY — a command blocked for a reason that has nothing
142
142
  // to do with the user. Every other command in cli.ts resolves env this way.
143
143
  const apiKey = args['api-key'] || (0, config_js_1.resolvedEnv)('ZEPH_API_KEY') || config.apiKey;
144
- const hookId = args.hook || (0, config_js_1.resolvedEnv)('ZEPH_HOOK_ID') || config.hookId;
144
+ const hookId = args.hook || (0, config_js_1.resolveHookId)();
145
145
  const title = args.title || '';
146
146
  if (!apiKey || !hookId || !title) {
147
147
  const missing = [!title && '--title', !apiKey && 'an API key', !hookId && 'a hook id']
package/dist/cli.js CHANGED
@@ -90,6 +90,15 @@ ${usageAgentLines()}
90
90
  --restart stop it and relaunch in the background
91
91
  (needed after an upgrade: 'npm i -g' swaps
92
92
  the package but never the live process)
93
+ --install-service
94
+ start the listener at every login (macOS
95
+ launchd). Without it the daemon only exists
96
+ after a 'zeph cc', so a reboot leaves the
97
+ phone picker empty until you open a terminal
98
+ --uninstall-service
99
+ remove the login-time service
100
+ --service-status
101
+ show what the installed service points at
93
102
 
94
103
  Notify options:
95
104
  --title <text> Push title
@@ -126,6 +135,10 @@ Install options:
126
135
  to manual key entry)
127
136
  --key <api-key> API key (non-interactive; skips browser login)
128
137
  --hook <hook-id> Hook ID (non-interactive)
138
+ --service Install the login-time listener service without
139
+ asking (macOS). Interactive runs offer it anyway;
140
+ scripted runs need this flag
141
+ --no-service Never install it
129
142
  --base-url <url> Base URL (non-interactive)
130
143
  --relogin Force a fresh browser sign-in even if a login is
131
144
  already saved (switch account)
package/dist/config.d.ts CHANGED
@@ -7,7 +7,13 @@ export interface ZephConfig {
7
7
  wsUrl?: string;
8
8
  deviceId?: string;
9
9
  }
10
- export declare const resolvedEnv: (key: string) => string | undefined;
10
+ export declare const resolvedEnv: (key: string, env?: NodeJS.ProcessEnv) => string | undefined;
11
+ /**
12
+ * The two-way hook id: `ZEPH_HOOK_ID` when it carries a real value, else
13
+ * `hookId` from ~/.zeph/config.json — what `zeph setup` writes. The plugin's
14
+ * gate.sh `zeph_hook_id` and the MCP server resolve it in the same order.
15
+ */
16
+ export declare const resolveHookId: (env?: NodeJS.ProcessEnv) => string | undefined;
11
17
  export declare const PROJECT_DIR_ENV_VARS: readonly ["CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_DIR", "WINDSURF_PROJECT_DIR"];
12
18
  /** First set project-dir env (unresolved `${VAR}` placeholders ignored), else cwd. */
13
19
  export declare const detectProjectDir: () => string;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,QAA2B,CAAC;AACnD,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,SAGlD,CAAC;AAMF,eAAO,MAAM,oBAAoB,+EAAgF,CAAC;AAElH,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,QAAO,MAMnC,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,UAM7B,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,QAAQ,UAAU,KAAG,IAM/C,CAAC;AAEF,eAAO,MAAM,OAAO,QAOhB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,QAA2B,CAAC;AACnD,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,CAAC,UAAwB,KAAG,MAAM,GAAG,SAGxF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAK,MAAM,CAAC,UAAwB,KAAG,MAAM,GAAG,SACrB,CAAC;AAM1D,eAAO,MAAM,oBAAoB,+EAAgF,CAAC;AAElH,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,QAAO,MAMnC,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,UAM7B,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,QAAQ,UAAU,KAAG,IAM/C,CAAC;AAEF,eAAO,MAAM,OAAO,QAOhB,CAAC"}
package/dist/config.js CHANGED
@@ -1,16 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.VERSION = exports.saveConfig = exports.loadConfig = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.resolvedEnv = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
3
+ exports.VERSION = exports.saveConfig = exports.loadConfig = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.resolveHookId = exports.resolvedEnv = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
4
4
  const fs_1 = require("fs");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
7
7
  exports.CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), '.zeph');
8
8
  exports.CONFIG_FILE = (0, path_1.join)(exports.CONFIG_DIR, 'config.json');
9
- const resolvedEnv = (key) => {
10
- const val = process.env[key];
9
+ const resolvedEnv = (key, env = process.env) => {
10
+ const val = env[key];
11
11
  return val && !val.startsWith('${') ? val : undefined;
12
12
  };
13
13
  exports.resolvedEnv = resolvedEnv;
14
+ /**
15
+ * The two-way hook id: `ZEPH_HOOK_ID` when it carries a real value, else
16
+ * `hookId` from ~/.zeph/config.json — what `zeph setup` writes. The plugin's
17
+ * gate.sh `zeph_hook_id` and the MCP server resolve it in the same order.
18
+ */
19
+ const resolveHookId = (env = process.env) => (0, exports.resolvedEnv)('ZEPH_HOOK_ID', env) || (0, exports.loadConfig)().hookId;
20
+ exports.resolveHookId = resolveHookId;
14
21
  // Per-agent project-dir env vars, in precedence order. Deliberately NOT part
15
22
  // of the remote-agent registry: Cursor/Windsurf carry project-dir envs but
16
23
  // are not remote-controllable via tmux — the two tables have different
@@ -18,5 +18,16 @@ export declare const mergeJsonFile: (filePath: string, patch: Record<string, unk
18
18
  * unit testing.
19
19
  */
20
20
  export declare const filterAgentsByIds: (detected: Agent[], only: string) => Agent[];
21
+ /**
22
+ * Whether this install should register the LaunchAgent that starts the
23
+ * listener at login.
24
+ *
25
+ * Interactive installs offer it and default to yes: an empty phone picker
26
+ * after every reboot is precisely the kind of thing a default should fix.
27
+ * Scripted installs (`--key`, CI, provisioning) get it only when asked —
28
+ * planting a background job on a machine whose operator only wanted an API
29
+ * key written is a surprise, not a convenience.
30
+ */
31
+ export declare const serviceInstallChoice: (args: Record<string, string | boolean>, nonInteractive: boolean) => "yes" | "no" | "ask";
21
32
  export declare const handleInstall: (args: Record<string, string | boolean>) => Promise<number>;
22
33
  //# sourceMappingURL=installer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA4BzC;;;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;AAgQF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAgH1F,CAAC"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA6BzC;;;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;AAgQF;;;;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"}
package/dist/installer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleInstall = exports.filterAgentsByIds = exports.mergeJsonFile = exports.shouldReauth = void 0;
3
+ exports.handleInstall = exports.serviceInstallChoice = exports.filterAgentsByIds = exports.mergeJsonFile = exports.shouldReauth = 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");
@@ -10,11 +10,13 @@ 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 listener_service_js_1 = require("./listener-service.js");
13
14
  const templates_js_1 = require("./templates.js");
14
15
  const HOME = (0, os_1.homedir)();
15
16
  // ── Helpers ──────────────────────────────────────────────────────
16
17
  const ok = (msg) => console.log(` + ${msg}`);
17
18
  const fail = (msg) => console.log(` - ${msg}`);
19
+ const skipNote = (msg) => console.log(` · ${msg}`);
18
20
  /**
19
21
  * True when install should auto-open browser login (ADR 0002): interactive
20
22
  * context with no existing credential (--key/env/config all absent).
@@ -392,6 +394,57 @@ const collectCredentials = async (args, installArgs, nonInteractive, existing) =
392
394
  const hookInput = await promptInput(' Hook ID (optional, for prompt/input): ');
393
395
  return { apiKey, hookId: hookInput || undefined, baseUrl: existing.baseUrl };
394
396
  };
397
+ // ── Login-time service ───────────────────────────────────────────
398
+ /**
399
+ * Whether this install should register the LaunchAgent that starts the
400
+ * listener at login.
401
+ *
402
+ * Interactive installs offer it and default to yes: an empty phone picker
403
+ * after every reboot is precisely the kind of thing a default should fix.
404
+ * Scripted installs (`--key`, CI, provisioning) get it only when asked —
405
+ * planting a background job on a machine whose operator only wanted an API
406
+ * key written is a surprise, not a convenience.
407
+ */
408
+ const serviceInstallChoice = (args, nonInteractive) => {
409
+ if (args['no-service'] === true)
410
+ return 'no';
411
+ if (!(0, listener_service_js_1.serviceSupported)())
412
+ return 'no';
413
+ if (args.service === true)
414
+ return 'yes';
415
+ return nonInteractive ? 'no' : 'ask';
416
+ };
417
+ exports.serviceInstallChoice = serviceInstallChoice;
418
+ /** Run the choice: ask when it should be asked, then install if wanted. */
419
+ const applyServiceChoice = async (choice) => {
420
+ if (choice === 'no')
421
+ return;
422
+ let wanted = choice === 'yes';
423
+ if (choice === 'ask') {
424
+ try {
425
+ const { confirm } = await import('@inquirer/prompts');
426
+ wanted = await confirm({
427
+ message: 'Start the listener at every login? (so the phone sees this machine after a reboot)',
428
+ default: true,
429
+ });
430
+ }
431
+ catch {
432
+ // Ctrl-C or no TTY — an unanswered question is not a yes.
433
+ wanted = false;
434
+ }
435
+ }
436
+ if (!wanted) {
437
+ skipNote('login-time service not installed (add it later: zeph listener --install-service)');
438
+ return;
439
+ }
440
+ const result = await (0, listener_service_js_1.installService)();
441
+ for (const note of result.notes)
442
+ ok(note);
443
+ if (result.ok)
444
+ ok('listener starts at every login');
445
+ else
446
+ fail(`login-time service: ${result.reason}`);
447
+ };
395
448
  // ── Main Install Flow ────────────────────────────────────────────
396
449
  const handleInstall = async (args) => {
397
450
  const installArgs = {
@@ -458,6 +511,9 @@ const handleInstall = async (args) => {
458
511
  if (selected.length === 0) {
459
512
  console.log(' (no agents selected — only the config file will be saved)');
460
513
  }
514
+ if ((0, exports.serviceInstallChoice)(args, nonInteractive) !== 'no') {
515
+ console.log(' - Offer to start the listener at every login');
516
+ }
461
517
  console.log(' - Test connection');
462
518
  }
463
519
  // 5. Save config — merge over the latest on-disk config (re-read, since a
@@ -481,7 +537,10 @@ const handleInstall = async (args) => {
481
537
  if (installer)
482
538
  installer();
483
539
  }
484
- // 7. Test connection
540
+ // 7. Login-time service — the only thing that makes the phone see this
541
+ // machine after a reboot without a terminal being opened first.
542
+ await applyServiceChoice((0, exports.serviceInstallChoice)(args, nonInteractive));
543
+ // 8. Test connection
485
544
  console.log('\n Testing connection...');
486
545
  await testConnection(apiKey, baseUrl);
487
546
  console.log('\n Done! Restart your agents.\n');
@@ -2,6 +2,7 @@ export declare const LISTENER_PID_FILE: string;
2
2
  /** CLI version the running daemon booted from. Absent ⇒ pre-stamp build. */
3
3
  export declare const LISTENER_VERSION_FILE: string;
4
4
  export declare const LISTENER_LOG_FILE: string;
5
+ export declare const sleep: (ms: number) => Promise<void>;
5
6
  /**
6
7
  * PID of the listener on record, or null when the file is missing or stale.
7
8
  * Stale PID files (process gone) read as "no listener" so a crashed daemon
@@ -1 +1 @@
1
- {"version":3,"file":"listener-process.d.ts","sourceRoot":"","sources":["../src/listener-process.ts"],"names":[],"mappings":"AAwBA,eAAO,MAAM,iBAAiB,QAAiC,CAAC;AAChE,4EAA4E;AAC5E,eAAO,MAAM,qBAAqB,QAAqC,CAAC;AACxE,eAAO,MAAM,iBAAiB,QAAiC,CAAC;AAIhE;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,QAAO,MAAM,GAAG,IAS9C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAAM,GAAG,IAMlD,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,oBAAoB,GAAI,SAAS,MAAM,KAAG,IAItD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,KAAG,IASlD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAAO,IAQ5C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GAAU,KAAK,MAAM,EAAE,kBAAiB,KAAG,OAAO,CAAC,OAAO,CAqBlF,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,QAAO,MAAM,GAAG,IAM1C,CAAC;AASF,eAAO,MAAM,wBAAwB,QAAO,IAM3C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAO,OAiBxC,CAAC"}
1
+ {"version":3,"file":"listener-process.d.ts","sourceRoot":"","sources":["../src/listener-process.ts"],"names":[],"mappings":"AAwBA,eAAO,MAAM,iBAAiB,QAAiC,CAAC;AAChE,4EAA4E;AAC5E,eAAO,MAAM,qBAAqB,QAAqC,CAAC;AACxE,eAAO,MAAM,iBAAiB,QAAiC,CAAC;AAEhE,eAAO,MAAM,KAAK,GAAI,IAAI,MAAM,KAAG,OAAO,CAAC,IAAI,CAAsD,CAAC;AAEtG;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,QAAO,MAAM,GAAG,IAS9C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAAM,GAAG,IAMlD,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,oBAAoB,GAAI,SAAS,MAAM,KAAG,IAItD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,MAAM,KAAG,IASlD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAAO,IAQ5C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GAAU,KAAK,MAAM,EAAE,kBAAiB,KAAG,OAAO,CAAC,OAAO,CAqBlF,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,QAAO,MAAM,GAAG,IAM1C,CAAC;AAgBF,eAAO,MAAM,wBAAwB,QAAO,IAO3C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAO,OAgBxC,CAAC"}
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.spawnListenerDetached = exports.rotateListenerLogIfLarge = exports.resolveCliPath = exports.stopListener = exports.clearStaleListenerRuntime = exports.clearListenerRuntime = exports.writeListenerRuntime = exports.runningListenerVersion = exports.runningListenerPid = exports.LISTENER_LOG_FILE = exports.LISTENER_VERSION_FILE = exports.LISTENER_PID_FILE = void 0;
3
+ exports.spawnListenerDetached = exports.rotateListenerLogIfLarge = exports.resolveCliPath = exports.stopListener = exports.clearStaleListenerRuntime = exports.clearListenerRuntime = exports.writeListenerRuntime = exports.runningListenerVersion = exports.runningListenerPid = exports.sleep = exports.LISTENER_LOG_FILE = exports.LISTENER_VERSION_FILE = exports.LISTENER_PID_FILE = void 0;
4
4
  const node_child_process_1 = require("node:child_process");
5
5
  const node_fs_1 = require("node:fs");
6
6
  const node_os_1 = require("node:os");
@@ -28,6 +28,7 @@ exports.LISTENER_PID_FILE = (0, node_path_1.join)(ZEPH_DIR, 'listener.pid');
28
28
  exports.LISTENER_VERSION_FILE = (0, node_path_1.join)(ZEPH_DIR, 'listener.version');
29
29
  exports.LISTENER_LOG_FILE = (0, node_path_1.join)(ZEPH_DIR, 'listener.log');
30
30
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
+ exports.sleep = sleep;
31
32
  /**
32
33
  * PID of the listener on record, or null when the file is missing or stale.
33
34
  * Stale PID files (process gone) read as "no listener" so a crashed daemon
@@ -121,7 +122,7 @@ const stopListener = async (pid, timeoutMs = 3_000) => {
121
122
  }
122
123
  const deadline = Date.now() + timeoutMs;
123
124
  while (Date.now() < deadline) {
124
- await sleep(50);
125
+ await (0, exports.sleep)(50);
125
126
  try {
126
127
  process.kill(pid, 0);
127
128
  }
@@ -166,7 +167,14 @@ exports.resolveCliPath = resolveCliPath;
166
167
  /**
167
168
  * Rotate the listener log once it grows past 5 MB. The daemon runs for days
168
169
  * and writes 2-3 lines per 5-s cycle, so without rotation the file climbs
169
- * into the tens of megabytes. Keep the previous run's tail under `.old`.
170
+ * into the tens of megabytes. Keep the previous window under `.old`.
171
+ *
172
+ * Copy-truncate rather than rename, because the writer is not always ours.
173
+ * Under the login-time LaunchAgent, launchd opens `StandardOutPath` itself
174
+ * and holds that fd for the life of the job: a rename moves the directory
175
+ * entry and leaves launchd appending to the very same inode, now called
176
+ * `.old`. The log the user tails would never shrink. Emptying the file in
177
+ * place keeps the inode — and the fd — valid.
170
178
  */
171
179
  const LISTENER_LOG_MAX_BYTES = 5 * 1024 * 1024;
172
180
  const rotateListenerLogIfLarge = () => {
@@ -175,7 +183,8 @@ const rotateListenerLogIfLarge = () => {
175
183
  return;
176
184
  if ((0, node_fs_1.statSync)(exports.LISTENER_LOG_FILE).size <= LISTENER_LOG_MAX_BYTES)
177
185
  return;
178
- (0, node_fs_1.renameSync)(exports.LISTENER_LOG_FILE, exports.LISTENER_LOG_FILE + '.old');
186
+ (0, node_fs_1.copyFileSync)(exports.LISTENER_LOG_FILE, exports.LISTENER_LOG_FILE + '.old');
187
+ (0, node_fs_1.truncateSync)(exports.LISTENER_LOG_FILE, 0);
179
188
  }
180
189
  catch { /* best-effort */ }
181
190
  };
@@ -192,7 +201,6 @@ const spawnListenerDetached = () => {
192
201
  return false;
193
202
  try {
194
203
  (0, node_fs_1.mkdirSync)(ZEPH_DIR, { recursive: true });
195
- (0, exports.rotateListenerLogIfLarge)();
196
204
  const out = (0, node_fs_1.openSync)(exports.LISTENER_LOG_FILE, 'a');
197
205
  const child = (0, node_child_process_1.spawn)(process.execPath, [cliPath, 'listener'], {
198
206
  detached: true,
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The listener as an OS service: a launchd LaunchAgent that starts it at user
3
+ * login so the phone's picker is populated without anyone opening a terminal.
4
+ *
5
+ * Until this existed, the only thing that ever started the daemon was
6
+ * `zeph cc` (wrapper.ts → spawnListenerDetached). After a reboot that means an
7
+ * empty picker until the user happens to launch an agent — the machine is
8
+ * online, the past sessions are on disk, and the app says "no agents".
9
+ *
10
+ * Two constraints shape this file:
11
+ *
12
+ * 1. **Node builtins only.** wrapper.ts reads `serviceInstalled()` on the
13
+ * `zeph cc` hot path, and the same rule that keeps listener.ts out of
14
+ * wrapper.ts (ws + the crypto stack on every agent launch) applies here.
15
+ * listener-process.ts is the one import, and it is builtins-only too.
16
+ *
17
+ * 2. **Everything the job needs is resolved at install time and baked into
18
+ * the plist.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and
19
+ * nothing else — no shell profile, no homebrew prefix. `verifyTmux()`
20
+ * (listener.ts) exits 127 when tmux is not on PATH, so a plist without a
21
+ * baked PATH produces a service that dies at every login and is restarted
22
+ * forever by KeepAlive. The interpreter is baked for the same reason.
23
+ */
24
+ /** Reverse-DNS launchd label. Lives on the user's machine once installed —
25
+ * renaming it orphans every plist already out there. */
26
+ export declare const SERVICE_LABEL = "to.zeph.listener";
27
+ export interface ServiceSpec {
28
+ readonly nodePath: string;
29
+ readonly cliPath: string;
30
+ readonly tmuxPath: string;
31
+ readonly logPath: string;
32
+ /** PATH baked into the job: tmux dir, node dir, then launchd's own. */
33
+ readonly pathEnv: string;
34
+ /** UTF-8 locale baked into the job. Always UTF-8 — see resolveServiceSpec. */
35
+ readonly lang: string;
36
+ }
37
+ export type SpecResolution = {
38
+ readonly ok: true;
39
+ readonly value: ServiceSpec;
40
+ readonly warning?: string;
41
+ } | {
42
+ readonly ok: false;
43
+ readonly reason: string;
44
+ };
45
+ /** The machine facts spec resolution needs, injectable so tests describe a
46
+ * machine instead of inheriting the developer's own. */
47
+ export interface ServiceEnv {
48
+ readonly exists: (path: string) => boolean;
49
+ /** Major version of the node at `path`, or null when it won't run. */
50
+ readonly nodeMajor: (path: string) => number | null;
51
+ /** Absolute tmux path from the invoking shell's PATH, or null. */
52
+ readonly whichTmux: () => string | null;
53
+ readonly cliPath: () => string | null;
54
+ /** The invoking shell's LANG, or undefined when it has none. */
55
+ readonly lang: () => string | undefined;
56
+ }
57
+ export interface ServiceStatus {
58
+ readonly supported: boolean;
59
+ readonly installed: boolean;
60
+ readonly label: string;
61
+ readonly plistPath: string;
62
+ /** Read back from the installed plist — null when nothing is installed. */
63
+ readonly nodePath: string | null;
64
+ readonly cliPath: string | null;
65
+ readonly pathEnv: string | null;
66
+ readonly langEnv: string | null;
67
+ /** Programs the plist names that are no longer on disk. The shape a node
68
+ * upgrade leaves behind: registered, listed by launchctl, unable to run. */
69
+ readonly missing: readonly string[];
70
+ }
71
+ /** launchd only exists on macOS. Every other platform is an explicit refusal,
72
+ * never a quiet success. */
73
+ export declare const serviceSupported: () => boolean;
74
+ export declare const servicePlistPath: () => string;
75
+ export declare const serviceInstalled: () => boolean;
76
+ export declare const defaultServiceEnv: ServiceEnv;
77
+ /**
78
+ * Decide what to bake into the plist, or say why we can't. Nothing is written
79
+ * and nothing is registered — a caller that gets `ok: false` has touched
80
+ * nothing on the machine.
81
+ */
82
+ export declare const resolveServiceSpec: (env?: ServiceEnv) => SpecResolution;
83
+ export declare const renderLaunchAgentPlist: (spec: ServiceSpec) => string;
84
+ /**
85
+ * What is registered on this machine and whether it can still run. The single
86
+ * source `verify`, `--service-status` and the `zeph cc` branch all read, so
87
+ * they can never disagree about whether a service is installed.
88
+ */
89
+ export declare const serviceStatus: () => ServiceStatus;
90
+ /**
91
+ * Side effects the service operations need, injectable so tests can drive the
92
+ * launchd sequence without a launchd.
93
+ */
94
+ export interface ServiceOpDeps {
95
+ readonly supported: () => boolean;
96
+ readonly resolveSpec: () => SpecResolution;
97
+ /** PID of a listener running right now, or null. */
98
+ readonly runningPid: () => number | null;
99
+ readonly stopListener: (pid: number) => Promise<boolean>;
100
+ readonly launchctl: (args: string[]) => {
101
+ ok: boolean;
102
+ stderr: string;
103
+ };
104
+ readonly settle: (ms: number) => Promise<void>;
105
+ }
106
+ export type ServiceOpResult = {
107
+ readonly ok: true;
108
+ readonly notes: readonly string[];
109
+ } | {
110
+ readonly ok: false;
111
+ readonly reason: string;
112
+ readonly notes: readonly string[];
113
+ };
114
+ export declare const defaultServiceOpDeps: ServiceOpDeps;
115
+ /**
116
+ * Register the LaunchAgent and get the daemon running under it.
117
+ *
118
+ * The order is load-bearing, not incidental:
119
+ *
120
+ * - **Any listener already running is stopped first.** `handleListener` exits
121
+ * **0** when it finds another listener alive, and it only does so quietly
122
+ * under ZEPH_LISTENER_AUTOSTART, which a launchd job does not have. So a
123
+ * kickstart into a live `zeph cc` daemon produces a clean exit, and
124
+ * `KeepAlive:{SuccessfulExit:false}` reads a clean exit as "stay down" —
125
+ * for the whole login session. `launchctl list` shows the job; nothing runs.
126
+ * - **Nothing is written until the spec resolves.** A refusal leaves the
127
+ * machine exactly as it was.
128
+ * - **The daemon is confirmed alive afterwards.** Without that check the
129
+ * failure above ships silently.
130
+ */
131
+ export declare const installService: (deps?: ServiceOpDeps) => Promise<ServiceOpResult>;
132
+ /** Unregister the job and remove the plist. Doing only one of the two leaves
133
+ * either a running service with no plist or a plist launchd ignores. */
134
+ export declare const uninstallService: (deps?: ServiceOpDeps) => Promise<ServiceOpResult>;
135
+ /**
136
+ * Stop the service until the next login.
137
+ *
138
+ * `bootout` and not `disable`: disable writes to launchd's persistent disabled
139
+ * database, survives logins, and leaves no obvious way back — a `--stop` that
140
+ * quietly becomes permanent. Session-scoped is what stopping should mean.
141
+ */
142
+ export declare const stopService: (deps?: ServiceOpDeps) => ServiceOpResult;
143
+ /** Restart the service in place. `-k` kills the running instance first, so this
144
+ * is also how a version-drifted daemon is replaced. */
145
+ export declare const restartService: (deps?: ServiceOpDeps) => ServiceOpResult;
146
+ export interface ServiceHealthRow {
147
+ readonly label: string;
148
+ readonly state: 'pass' | 'warn' | 'fail';
149
+ }
150
+ /** The two live facts a health check can't read off the plist. */
151
+ export interface ServiceProbe {
152
+ /** Can a process started with this PATH find tmux? */
153
+ readonly tmuxOnPath: (pathEnv: string) => boolean;
154
+ /** Is launchd actually running the job right now? */
155
+ readonly loaded: () => boolean;
156
+ }
157
+ export declare const defaultServiceProbe: ServiceProbe;
158
+ /**
159
+ * Why this exists: every way the service breaks leaves it looking installed.
160
+ * `launchctl list` shows the job whether or not its interpreter still exists,
161
+ * and a PATH without tmux makes the daemon exit 127 at every login while the
162
+ * registration sits there unchanged. None of that is visible without asking.
163
+ */
164
+ export declare const serviceHealthChecks: (status: ServiceStatus, probe?: ServiceProbe) => ServiceHealthRow[];
165
+ //# sourceMappingURL=listener-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listener-service.d.ts","sourceRoot":"","sources":["../src/listener-service.ts"],"names":[],"mappings":"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"}