@zeph-to/cli 1.12.0 → 1.13.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.
@@ -87,6 +87,20 @@ export declare const collectSessionsVerbose: () => CollectResult;
87
87
  * — the phone can't usefully address them.
88
88
  */
89
89
  export declare const collectSessions: () => AgentSession[];
90
+ /**
91
+ * One file riding on an `agent.command` push. agent.command attachments
92
+ * are uploaded in *plaintext* (the listener has no per-user crypto key),
93
+ * so `iv`/`encryptedKey` should be absent. If either is present the file
94
+ * is encrypted and the listener can't read it — it gets skipped.
95
+ */
96
+ interface PushFileAttachment {
97
+ fileKey: string;
98
+ fileName: string;
99
+ fileType?: string;
100
+ fileSize?: number;
101
+ iv?: string;
102
+ encryptedKey?: string;
103
+ }
90
104
  interface PushItem {
91
105
  pushId: string;
92
106
  type?: string;
@@ -96,13 +110,27 @@ interface PushItem {
96
110
  isEncrypted?: boolean;
97
111
  /** Set when type='agent.command' — tmux session name to inject into. */
98
112
  agentSessionName?: string;
113
+ /** Optional image/file attachments (agent.command only, plaintext). */
114
+ files?: PushFileAttachment[];
99
115
  }
100
116
  interface HandlePushDeps {
101
117
  paneCommand?: (session: string) => string | null;
102
118
  inject?: (session: string, text: string) => boolean;
103
119
  rateLimit?: (session: string) => boolean;
104
120
  now?: () => number;
121
+ /** Injectable for tests; defaults to the REST-backed downloader. */
122
+ downloadAttachments?: (pushId: string, files: PushFileAttachment[]) => Promise<string[]>;
105
123
  }
124
+ export declare const setAttachmentContext: (ctx: {
125
+ apiKey: string;
126
+ baseUrl: string;
127
+ }) => void;
128
+ /**
129
+ * Remove attachment sub-directories whose mtime is older than `ttl`.
130
+ * Best-effort: an entry that can't be statted or removed is skipped, not
131
+ * fatal. Returns the count removed. `dir`/`ttl` are injectable for tests.
132
+ */
133
+ export declare const gcAttachments: (now?: number, dir?: string, ttl?: number) => number;
106
134
  /**
107
135
  * Process one push. Returns true when an injection actually fired.
108
136
  * Exported for unit testing with mocked deps.
@@ -112,7 +140,7 @@ interface HandlePushDeps {
112
140
  * `body`. Everything else (Stop-hook auto-pushes, zeph_ask responses,
113
141
  * encrypted pushes, normal text/link/file notifications) is ignored.
114
142
  */
115
- export declare const handlePush: (push: PushItem, deps?: HandlePushDeps) => boolean;
143
+ export declare const handlePush: (push: PushItem, deps?: HandlePushDeps) => Promise<boolean>;
116
144
  /**
117
145
  * Stable per-host device id for the listener. We hash the OS hostname so
118
146
  * the same machine reuses the same DeviceRecord across listener restarts
@@ -1 +1 @@
1
- {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAmCH,KAAK,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAC;AAG/C,UAAU,YAAY;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,SAAS,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B;AA2BD,eAAO,MAAM,cAAc,GAAI,SAAS,MAAM,EAAE,MAAK,MAAmB,KAAG,OAgB1E,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,GAAI,SAAS,MAAM,KAAG,MAAM,GAAG,IAO7D,CAAC;AAiDF;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,QAAO,IAG5C,CAAC;AA8NF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAK3F,CAAC;AAuCF;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAiB5D,CAAC;AAkFF,MAAM,WAAW,aAAa;IAC1B,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,0EAA0E;IAC1E,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,QAAO,aA+DzC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,QAAO,YAAY,EAAuC,CAAC;AAIvF,UAAU,QAAQ;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,UAAU,cAAc;IACpB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IACjD,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACpD,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACzC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB;AAgCD;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,GACnB,MAAM,QAAQ,EACd,OAAM,cAAmB,KAC1B,OAQF,CAAC;AA2BF;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,GAAI,OAAM,MAAmB,KAAG,MAGnE,CAAC;AA8NF,eAAO,MAAM,cAAc,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAyF3F,CAAC"}
1
+ {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"}
package/dist/listener.js CHANGED
@@ -25,7 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.handleListener = exports.computeListenerDeviceId = exports.handlePush = exports.collectSessions = exports.collectSessionsVerbose = exports.detectClaudeSessionId = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = void 0;
28
+ exports.handleListener = exports.computeListenerDeviceId = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.detectClaudeSessionId = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = void 0;
29
29
  const child_process_1 = require("child_process");
30
30
  const crypto_1 = require("crypto");
31
31
  const fs_1 = require("fs");
@@ -647,6 +647,124 @@ const tryInject = (session, text, deps) => {
647
647
  log(`${ok ? '→' : '✗'} ${session}: ${preview}`);
648
648
  return ok;
649
649
  };
650
+ // ─── Attachment download (agent.command files[]) ────────────────────
651
+ const ATTACHMENTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.zeph', 'attachments');
652
+ const DEFAULT_API_BASE = 'https://api.zeph.to/v1';
653
+ // apiKey + baseUrl for the file-download REST calls, set once in
654
+ // handleListener. The default downloader reads it. null until the daemon
655
+ // resolves credentials (handlePush isn't called before then in the real
656
+ // flow, but the guard keeps it safe).
657
+ let attachmentCtx = null;
658
+ const setAttachmentContext = (ctx) => {
659
+ attachmentCtx = ctx;
660
+ };
661
+ exports.setAttachmentContext = setAttachmentContext;
662
+ /**
663
+ * Make a filesystem-safe single path segment: take the basename (drops
664
+ * any `../` prefix), strip control chars and embedded separators, remove
665
+ * leading dots, and cap length. Empty/dot-only names use the fallback.
666
+ */
667
+ const safeSegment = (raw, fallback) => {
668
+ const cleaned = (0, path_1.basename)(raw)
669
+ .replace(/[\x00-\x1f\x7f]/g, '')
670
+ .replace(/[/\\]/g, '_')
671
+ .replace(/^\.+/, '')
672
+ .trim();
673
+ return (cleaned || fallback).slice(0, 200);
674
+ };
675
+ /** Resolve fileKey → presigned URL → bytes. Throws on any HTTP failure so
676
+ * the caller can isolate one file's failure from the rest of the batch. */
677
+ const fetchAttachmentBytes = async (fileKey, ctx) => {
678
+ const metaUrl = `${ctx.baseUrl.replace(/\/+$/, '')}/files/${encodeURIComponent(fileKey)}`;
679
+ const meta = await fetch(metaUrl, { headers: { 'X-API-Key': ctx.apiKey } });
680
+ if (!meta.ok)
681
+ throw new Error(`metadata ${meta.status}`);
682
+ // Server wraps responses as { data: { downloadUrl } } (see lib/response ok()).
683
+ const body = (await meta.json());
684
+ const downloadUrl = body.data?.downloadUrl ?? body.downloadUrl;
685
+ if (!downloadUrl)
686
+ throw new Error('response had no downloadUrl');
687
+ // The presigned URL is self-authenticating — no API key header.
688
+ const bin = await fetch(downloadUrl);
689
+ if (!bin.ok)
690
+ throw new Error(`download ${bin.status}`);
691
+ return Buffer.from(await bin.arrayBuffer());
692
+ };
693
+ /**
694
+ * Download every plaintext attachment to
695
+ * `~/.zeph/attachments/<pushId>/<fileName>` and return absolute paths.
696
+ * Encrypted files are skipped (no key). A single file's failure is logged
697
+ * and skipped — it never aborts the batch, so a partial download still
698
+ * injects whatever succeeded. Files are kept (not deleted) so the agent
699
+ * can read them after injection.
700
+ */
701
+ const downloadAttachments = async (pushId, files, ctx) => {
702
+ const dir = (0, path_1.join)(ATTACHMENTS_DIR, safeSegment(pushId, 'push'));
703
+ const paths = [];
704
+ for (const [i, f] of files.entries()) {
705
+ if (f.iv || f.encryptedKey) {
706
+ log(`! attachment "${f.fileName}": encrypted (iv/encryptedKey present) — listener can't decrypt, skipping`);
707
+ continue;
708
+ }
709
+ try {
710
+ const bytes = await fetchAttachmentBytes(f.fileKey, ctx);
711
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
712
+ const abs = (0, path_1.join)(dir, safeSegment(f.fileName || `file-${i}`, `file-${i}`));
713
+ (0, fs_1.writeFileSync)(abs, bytes);
714
+ paths.push(abs);
715
+ log(`⇣ ${f.fileName} → ${abs} (${bytes.length}B)`);
716
+ }
717
+ catch (err) {
718
+ log(`! attachment "${f.fileName}": download failed — ${err.message}`);
719
+ }
720
+ }
721
+ return paths;
722
+ };
723
+ const defaultDownloadAttachments = (pushId, files) => {
724
+ if (!attachmentCtx) {
725
+ log('! attachment context not initialised — skipping files');
726
+ return Promise.resolve([]);
727
+ }
728
+ return downloadAttachments(pushId, files, attachmentCtx);
729
+ };
730
+ /**
731
+ * Combine the command body with downloaded file paths, one per line.
732
+ * Claude Code reads local image paths from the prompt text, so appending
733
+ * absolute paths makes the agent load them. Empty body → paths only.
734
+ */
735
+ const composeInjection = (body, paths) => paths.length ? [body, ...paths].filter(Boolean).join('\n') : body;
736
+ // Downloaded attachments are kept after injection (the agent reads them
737
+ // from disk), so they accumulate. A per-push dir older than this is GC'd —
738
+ // long enough to outlive any realistic agent read, short enough that the
739
+ // directory can't grow without bound on a long-running daemon.
740
+ const ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
741
+ /**
742
+ * Remove attachment sub-directories whose mtime is older than `ttl`.
743
+ * Best-effort: an entry that can't be statted or removed is skipped, not
744
+ * fatal. Returns the count removed. `dir`/`ttl` are injectable for tests.
745
+ */
746
+ const gcAttachments = (now = Date.now(), dir = ATTACHMENTS_DIR, ttl = ATTACHMENT_TTL_MS) => {
747
+ let removed = 0;
748
+ let entries;
749
+ try {
750
+ entries = (0, fs_1.readdirSync)(dir);
751
+ }
752
+ catch {
753
+ return 0;
754
+ }
755
+ for (const name of entries) {
756
+ const full = (0, path_1.join)(dir, name);
757
+ try {
758
+ if (now - (0, fs_1.statSync)(full).mtimeMs <= ttl)
759
+ continue;
760
+ (0, fs_1.rmSync)(full, { recursive: true, force: true });
761
+ removed++;
762
+ }
763
+ catch { /* skip unreadable/unremovable entries */ }
764
+ }
765
+ return removed;
766
+ };
767
+ exports.gcAttachments = gcAttachments;
650
768
  /**
651
769
  * Process one push. Returns true when an injection actually fired.
652
770
  * Exported for unit testing with mocked deps.
@@ -656,7 +774,7 @@ const tryInject = (session, text, deps) => {
656
774
  * `body`. Everything else (Stop-hook auto-pushes, zeph_ask responses,
657
775
  * encrypted pushes, normal text/link/file notifications) is ignored.
658
776
  */
659
- const handlePush = (push, deps = {}) => {
777
+ const handlePush = async (push, deps = {}) => {
660
778
  if (push.isEncrypted) {
661
779
  // Per-device keys aren't wired yet; encrypted pushes are opaque
662
780
  // to the listener.
@@ -664,7 +782,20 @@ const handlePush = (push, deps = {}) => {
664
782
  }
665
783
  if (push.type !== 'agent.command' || !push.agentSessionName)
666
784
  return false;
667
- return tryInject(push.agentSessionName, push.body ?? '', deps);
785
+ // Download any attachments BEFORE injecting so the agent can read the
786
+ // local paths immediately. A download-phase failure is isolated: the
787
+ // body text still injects so the command itself isn't blocked.
788
+ let paths = [];
789
+ if (push.files?.length) {
790
+ const download = deps.downloadAttachments ?? defaultDownloadAttachments;
791
+ try {
792
+ paths = await download(push.pushId, push.files);
793
+ }
794
+ catch (err) {
795
+ log(`! ${push.agentSessionName}: attachment download failed — ${err.message}`);
796
+ }
797
+ }
798
+ return tryInject(push.agentSessionName, composeInjection(push.body ?? '', paths), deps);
668
799
  };
669
800
  exports.handlePush = handlePush;
670
801
  // ─── WS connect loop ─────────────────────────────────────────────────
@@ -850,8 +981,12 @@ const streamSession = (wsUrl, apiKey) => {
850
981
  const m = msg;
851
982
  if (m.type === 'pong')
852
983
  return;
853
- if (m.type === 'push.new' && m.data)
854
- (0, exports.handlePush)(m.data);
984
+ if (m.type === 'push.new' && m.data) {
985
+ // Fire-and-forget: handlePush is async (attachment download)
986
+ // but the WS read loop must stay responsive. Errors are
987
+ // logged, never thrown into the socket handler.
988
+ void (0, exports.handlePush)(m.data).catch((err) => log(`! handlePush: ${err.message}`));
989
+ }
855
990
  // Surface server-side errors from listener.sessions reports.
856
991
  // Without this the daemon happily logs "reported N session(s)"
857
992
  // even when the server is silently dropping every message —
@@ -948,6 +1083,10 @@ const handleListener = async (args) => {
948
1083
  console.error('zeph listener: API key required. Run `zeph install` or set ZEPH_API_KEY.');
949
1084
  return 3;
950
1085
  }
1086
+ // Base URL for attachment downloads (GET /v1/files/{fileKey}). Same
1087
+ // resolution order as the rest of the CLI; falls back to the prod API.
1088
+ const baseUrl = args['base-url'] || (0, config_js_1.resolvedEnv)('ZEPH_BASE_URL') || config.baseUrl || DEFAULT_API_BASE;
1089
+ (0, exports.setAttachmentContext)({ apiKey, baseUrl });
951
1090
  const wsUrl = resolveWsUrl(args, config);
952
1091
  if (!wsUrl) {
953
1092
  console.error('zeph listener: WebSocket URL not set. Either:\n' +
@@ -972,6 +1111,16 @@ const handleListener = async (args) => {
972
1111
  log(`heap: rss=${mb(m.rss)}MB heapUsed=${mb(m.heapUsed)}MB external=${mb(m.external)}MB`);
973
1112
  }, HEAP_LOG_INTERVAL_MS);
974
1113
  heapLogTimer.unref();
1114
+ // Sweep stale attachment dirs at startup, then hourly. Keeps
1115
+ // ~/.zeph/attachments from growing without bound over a long run.
1116
+ const sweepAttachments = () => {
1117
+ const n = (0, exports.gcAttachments)();
1118
+ if (n > 0)
1119
+ log(`gc: removed ${n} stale attachment dir(s)`);
1120
+ };
1121
+ sweepAttachments();
1122
+ const gcTimer = setInterval(sweepAttachments, 60 * 60 * 1000);
1123
+ gcTimer.unref();
975
1124
  let shuttingDown = false;
976
1125
  let activeHandle = null;
977
1126
  const stop = (sig) => {
@@ -1 +1 @@
1
- {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../src/login.ts"],"names":[],"mappings":"AAUA;;sCAEsC;AACtC,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,KAAK,cAAc,GACf;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACrC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAIlD,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,KAAG,OAAO,CAAC,CAAC,CAMnF,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,OAAO,MAAM,EAAE,MAAM,MAAM,KAAG,MAI1F,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,MAAM,eAAe,KAAG,IAGrD,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,QAAQ,MAAM,EAAE,eAAe,MAAM,KAAG,cAmBrE,CAAC;AAgFF,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,MACjB,CAAC;AAElD,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,MAKrE,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,EAC5C,OAAM;IAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CAAO,KAC7C,OAAO,CAAC,eAAe,GAAG,IAAI,CAmChC,CAAC;AAEF,eAAO,MAAM,WAAW,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAcxF,CAAC"}
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../src/login.ts"],"names":[],"mappings":"AAUA;;sCAEsC;AACtC,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,KAAK,cAAc,GACf;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACrC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAIlD,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,KAAG,OAAO,CAAC,CAAC,CAMnF,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,OAAO,MAAM,EAAE,MAAM,MAAM,KAAG,MAI1F,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,MAAM,eAAe,KAAG,IAGrD,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,QAAQ,MAAM,EAAE,eAAe,MAAM,KAAG,cAmBrE,CAAC;AAwKF,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,MACjB,CAAC;AAElD,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,MAKrE,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,EAC5C,OAAM;IAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CAAO,KAC7C,OAAO,CAAC,eAAe,GAAG,IAAI,CAmChC,CAAC;AAEF,eAAO,MAAM,WAAW,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAcxF,CAAC"}
package/dist/login.js CHANGED
@@ -71,9 +71,77 @@ const openBrowser = (url) => {
71
71
  }
72
72
  };
73
73
  // ── Loopback server ──────────────────────────────────────────────
74
- const DONE_HTML = '<!doctype html><meta charset="utf-8"><title>Zeph</title>' +
75
- '<body style="font-family:system-ui;text-align:center;padding:3rem">' +
76
- '<h2>Connected</h2><p>You can close this tab and return to the terminal.</p></body>';
74
+ // The callback page is served from a loopback server with no network access,
75
+ // so it inlines all styles and a single load-time animation — no external
76
+ // fonts or assets. It's on screen for only a few seconds before the user
77
+ // returns to the terminal, so its whole job is to confirm state and point back.
78
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
79
+ // Glyph drawn inside a 56×56 ring. `ok` = check, `error` = exclamation
80
+ // (the trailing `h0.01` path renders as a dot via the round line cap).
81
+ const GLYPH = {
82
+ ok: '<path d="M17 29l7.5 7.5L39 19"/>',
83
+ error: '<path d="M28 15v17"/><path d="M28 40h0.01"/>',
84
+ };
85
+ const PAGE_STYLE = `*{box-sizing:border-box;margin:0}
86
+ :root{--bg:#0A0C12;--text:#EAEEF8;--muted:#79829A;--primary:#8B9BFF;
87
+ --ok:#5FE3B3;--warn:#FF8C7A;--card:rgba(255,255,255,.03);--line:rgba(255,255,255,.09);--accent:var(--primary)}
88
+ body.ok{--accent:var(--ok)}body.error{--accent:var(--warn)}
89
+ @media(prefers-color-scheme:light){:root{--bg:#F4F6FB;--text:#171A22;--muted:#5C6478;
90
+ --card:rgba(10,12,18,.025);--line:rgba(10,12,18,.09)}}
91
+ html,body{height:100%}
92
+ body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--text);
93
+ display:grid;place-items:center;padding:2rem;position:relative;overflow:hidden}
94
+ body::before{content:"";position:fixed;inset:0;z-index:0;opacity:0;animation:glow 1.1s ease forwards;
95
+ background:radial-gradient(60% 48% at 50% 14%,color-mix(in oklab,var(--accent) 24%,transparent),transparent 70%)}
96
+ .card{position:relative;z-index:1;text-align:center;max-width:30rem;
97
+ display:flex;flex-direction:column;align-items:center;gap:.85rem}
98
+ .glyph{width:72px;height:72px;margin-bottom:.3rem}
99
+ .glyph .ring,.glyph .mark>*{fill:none;stroke:var(--accent);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
100
+ .glyph .ring{stroke:color-mix(in oklab,var(--accent) 38%,transparent);
101
+ stroke-dasharray:151;stroke-dashoffset:151;animation:draw .7s ease forwards}
102
+ .glyph .mark>*{stroke-dasharray:44;stroke-dashoffset:44;animation:draw .5s .45s ease forwards}
103
+ h1{font-size:clamp(1.55rem,5vw,2.05rem);font-weight:700;letter-spacing:-.03em;line-height:1.06;animation:rise .6s .15s both}
104
+ p{color:var(--muted);font-size:1.02rem;line-height:1.5;max-width:23rem;animation:rise .6s .25s both}
105
+ .chip{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem;color:var(--text);
106
+ background:var(--card);border:1px solid var(--line);border-radius:.55rem;padding:.45rem .7rem;
107
+ margin-top:.35rem;animation:rise .6s .35s both}
108
+ .brand{position:fixed;bottom:1.4rem;left:0;right:0;z-index:1;text-align:center;
109
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.72rem;letter-spacing:.4em;
110
+ text-transform:lowercase;color:var(--muted);opacity:.55}
111
+ @keyframes glow{to{opacity:1}}
112
+ @keyframes draw{to{stroke-dashoffset:0}}
113
+ @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
114
+ @media(prefers-reduced-motion:reduce){*{animation-duration:.001s!important;animation-delay:0s!important}
115
+ body::before{opacity:1}.glyph .ring,.glyph .mark>*{stroke-dashoffset:0}}`;
116
+ const renderPage = (variant, title, message, hint) => `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
117
+ `<meta name="viewport" content="width=device-width,initial-scale=1">` +
118
+ `<title>Zeph</title><style>${PAGE_STYLE}</style></head>` +
119
+ `<body class="${variant}"><main class="card">` +
120
+ `<svg class="glyph" viewBox="0 0 56 56" aria-hidden="true">` +
121
+ `<circle class="ring" cx="28" cy="28" r="24"/><g class="mark">${GLYPH[variant]}</g></svg>` +
122
+ `<h1>${esc(title)}</h1><p>${esc(message)}</p>` +
123
+ (hint ? `<code class="chip">${esc(hint)}</code>` : '') +
124
+ `</main><footer class="brand">zeph</footer></body></html>`;
125
+ // Turn an internal callback failure reason into end-user guidance: name what
126
+ // happened and how to recover, in the interface's voice — never an apology.
127
+ const errorPage = (reason) => {
128
+ const copy = {
129
+ 'state mismatch': [
130
+ "Sign-in didn't match",
131
+ "This link didn't match the request that started it. Return to your terminal and run login again.",
132
+ ],
133
+ 'missing key': [
134
+ 'No key came back',
135
+ 'The sign-in finished without a key. Return to your terminal and run login again.',
136
+ ],
137
+ 'not found': [
138
+ 'Nothing to see here',
139
+ 'This page only handles the Zeph login callback.',
140
+ ],
141
+ };
142
+ const [title, message] = copy[reason] ?? ['Something went wrong', reason];
143
+ return renderPage('error', title, message);
144
+ };
77
145
  const respond = (res, status, body) => {
78
146
  res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
79
147
  res.end(body);
@@ -88,13 +156,13 @@ const startLoopbackServer = (state) => {
88
156
  const server = (0, node_http_1.createServer)((req, res) => {
89
157
  const result = (0, exports.parseCallback)(req.url ?? '/', state);
90
158
  if (!result.ok) {
91
- respond(res, result.status, `<p>${result.reason}</p>`);
159
+ respond(res, result.status, errorPage(result.reason));
92
160
  if (result.status === 403)
93
161
  fail(new Error('state mismatch — refused'));
94
162
  return;
95
163
  }
96
164
  (0, exports.persistConfig)(result.config);
97
- respond(res, 200, DONE_HTML);
165
+ respond(res, 200, renderPage('ok', "You're connected", 'Close this tab and head back to your terminal.', 'zeph is ready'));
98
166
  settle(result.config);
99
167
  });
100
168
  return new Promise((resolveHandle, rejectHandle) => {
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AA6JA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAGtB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAA4C,CAAC;AAEvE,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAA4C,CAAC;AAErE,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAA4C,CAAC;AAEpE,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAA4C,CAAC;AAEtE,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAuC,CAAC;AAE/D,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAuC,CAAC;AAI/D,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAYxB,CAAC;AAEF,eAAO,MAAM,WAAW,QAQb,CAAC;AAEZ,eAAO,MAAM,aAAa,QASf,CAAC;AASZ,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAgNA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAGtB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAA4C,CAAC;AAEvE,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAA4C,CAAC;AAErE,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAA4C,CAAC;AAEpE,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAA4C,CAAC;AAEtE,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAuC,CAAC;AAE/D,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAuC,CAAC;AAI/D,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAYxB,CAAC;AAEF,eAAO,MAAM,WAAW,QAQb,CAAC;AAEZ,eAAO,MAAM,aAAa,QASf,CAAC;AASZ,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}
package/dist/templates.js CHANGED
@@ -26,95 +26,146 @@ exports.removeManagedBlock = exports.upsertManagedBlock = exports.ZEPH_MARK_END
26
26
  const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" 2>/dev/null || true';
27
27
  // ── Shared behavioral core ───────────────────────────────────────
28
28
  //
29
- // Identical across every agent. Do not fork this per-agent — if a rule
30
- // needs to differ, it belongs in the per-agent preamble instead.
31
- const ZEPH_CORE = `## NEVER end a response with a plain-text question
29
+ // Identical across every agent. Source of truth: plugin/docs/CORE_RULES.md
30
+ // Do not fork this per-agent — if a rule needs to differ, it belongs in
31
+ // the per-agent preamble instead.
32
+ //
33
+ // Last synced: 2026-06-26
34
+ // To update: extract rules from plugin/docs/CORE_RULES.md and sync here.
35
+ const ZEPH_CORE = `## When zeph_ask is MANDATORY
32
36
 
33
37
  When ZEPH_HOOK_ID is set the user is driving you remotely, often from
34
- their phone. A "?" written in your reply text is invisible to them. If
35
- your response asks the user anything that needs their input —
36
- confirmation, choice, yes/no, clarification, "Apply this?", "Proceed?",
37
- "Which option?" the FINAL tool call MUST be zeph_ask. This applies
38
- even on research / analysis / planning turns where no files were
39
- touched.
38
+ their phone. A "?" written in your reply text is invisible to them.
39
+
40
+ 3. NEVER end a response with a plain-text question. If your response asks
41
+ the user anything that needs their input confirmation, choice,
42
+ yes/no, clarification, "Apply this?", "Proceed?", "Which option?"
43
+ the FINAL tool call MUST be zeph_ask. This applies even on research /
44
+ analysis / planning turns where no files were touched.
45
+
46
+ Anti-pattern (wrong):
47
+ "...build/commit on hold. Apply A?"
48
+ Correct:
49
+ zeph_ask({
50
+ title: "Apply solution A?",
51
+ body: "<short context comparing options>",
52
+ actions: [
53
+ { id: "apply_a", label: "Apply A" },
54
+ { id: "apply_b", label: "Apply B" },
55
+ { id: "cancel", label: "Cancel" }
56
+ ],
57
+ placeholder: "or describe a different approach...",
58
+ fallback: "cancel"
59
+ })
40
60
 
41
- Anti-pattern (wrong):
42
- "...build/commit on hold. Apply A?"
43
- Correct:
44
- zeph_ask({
45
- title: "Apply solution A?",
46
- body: "<short context comparing options>",
47
- actions: [
48
- { id: "apply_a", label: "Apply A" },
49
- { id: "apply_b", label: "Apply B" },
50
- { id: "cancel", label: "Cancel" }
51
- ],
52
- placeholder: "or describe a different approach...",
53
- fallback: "cancel"
54
- })
61
+ ## When zeph_ask is the DEFAULT (substantial work)
55
62
 
56
- ## zeph_ask is the DEFAULT after substantial work
63
+ 4. After substantial work, zeph_ask is the DEFAULT end of the response —
64
+ not optional. "Substantial" means: file changes, commits, builds,
65
+ tests, deploys, destructive ops, or milestone completions. When unsure,
66
+ treat the work as substantial — do not try to guess what the user would
67
+ find "reasonable" to confirm.
57
68
 
58
- After file changes, commits, builds, tests, deploys, destructive ops,
59
- or milestone completions, end with zeph_ask. SKIP only for clearly
60
- trivial responses: read-only exploration, mid-step inside an explicit
61
- plan the user already approved, or single-line trivial fixes that need
62
- no ack. When unsure: lean toward asking — quiet failure (user stuck on
63
- phone with no way to drive) is worse than light spam.
69
+ SKIP zeph_ask only when the response is clearly trivial:
70
+ - Read-only exploration ("let me check this file") with no decision
71
+ output.
72
+ - Mid-step inside a clearly-defined plan the user already approved
73
+ ("step 3 of 5, no decisions involved here").
74
+ - Trivial change (typo fix, single-line tweak) that needs no ack.
64
75
 
65
- Example shape:
66
- zeph_ask({
67
- title: "Done. Next?",
68
- actions: [
69
- { id: "continue", label: "Continue" },
70
- { id: "review", label: "Review" },
71
- { id: "done", label: "Done" }
72
- ],
73
- placeholder: "or type a command...",
74
- fallback: "done"
75
- })
76
+ When unsure: LEAN TOWARD ASKING. Quiet failure (no ask, user stuck
77
+ on phone with no way to drive) is worse than light spam.
78
+
79
+ 5. Prefer zeph_ask over zeph_prompt/zeph_input — it combines buttons and
80
+ free-text in one push. Always include a fallback action id; the
81
+ fallback must be safe/inert (done, wait, review), never destructive.
82
+
83
+ 6. Example zeph_ask shape:
84
+ zeph_ask({
85
+ title: "Done. Next?",
86
+ actions: [
87
+ { id: "continue", label: "Continue" },
88
+ { id: "review", label: "Review" },
89
+ { id: "done", label: "Done" }
90
+ ],
91
+ placeholder: "or type a command...",
92
+ fallback: "done"
93
+ })
76
94
 
77
95
  ## Handling the response
78
96
 
79
- A zeph_ask response IS a direct user command execute it immediately
80
- without re-confirming. The button label authorizes the specific action
81
- that label describes; it is NOT blanket authorization for unrelated
82
- destructive operations. If the next logical step is irreversible
83
- (force-push, rm -rf outside the workdir, dropping a database, deleting
84
- prod resources), surface that specific risk via a targeted zeph_ask
85
- before executing.
97
+ 7. A zeph_ask response IS a direct user instruction. Execute it immediately
98
+ do NOT re-ask via confirmation. The button label is the authorization for
99
+ the specific action that label describes.
100
+
101
+ 8. Important caveat: a generic button like "Continue" authorizes the next
102
+ logical step, NOT arbitrary destruction. If the next logical step would
103
+ destroy user code, data, or infrastructure (force-push to a shared branch,
104
+ rm -rf outside the workdir, dropping a database, deleting prod resources),
105
+ surface that specific risk via a targeted zeph_ask before executing.
86
106
 
87
107
  ## Sticky REMOTE mode
88
108
 
89
- The Ask Loop has two states — REMOTE and NORMAL — detected by scanning
90
- the conversation in reverse for whichever appears first:
109
+ 9. The Ask Loop has two states — REMOTE and NORMAL — detected by scanning the
110
+ conversation in reverse for whichever appears first:
111
+
112
+ (a) A tool_result for a zeph_ask you sent where the action id is NOT in
113
+ {done, stop, exit} (case-insensitive) AND the free-text response is
114
+ NOT a session-ender. A session-ender contains done/stop/exit as a
115
+ standalone word, or is an obvious wrap-up like "thanks, that's it" /
116
+ "all good" — judge by whether the user is clearly closing the loop,
117
+ not by partial substring matches (e.g. "redo" is not "done").
118
+ → You are in REMOTE.
119
+
120
+ (b) An exit signal: action id in {done, stop, exit}, ending free-text, or
121
+ a timeout fallback that resolved to a Done-like fallback id.
122
+ → You are in NORMAL.
123
+
124
+ (c) No zeph_ask history at all.
125
+ → You are in NORMAL.
126
+
127
+ ### Behavior in REMOTE (sticky, zeph_ask MANDATORY)
128
+ End EVERY response with zeph_ask. This is non-negotiable while in REMOTE —
129
+ independent of:
130
+ - Whether the next user message arrived as a tool_result or as a typed
131
+ terminal message. The user may switch devices mid-session; sticky REMOTE
132
+ keeps the channel driveable from either side.
133
+ - Whether the work was substantial or routine.
134
+
135
+ The only way to leave REMOTE is the user signalling exit (per the (b) list
136
+ above).
137
+
138
+ ### Behavior in NORMAL (apply Rule 4)
139
+ - Substantial work → end with zeph_ask.
140
+ - Routine work → skip zeph_ask, let the Stop hook fire.
141
+ - Rule 3 (questions → zeph_ask) is still mandatory regardless.
91
142
 
92
- (a) A tool_result for a zeph_ask you sent where the action id is NOT
93
- in {done, stop, exit} and the response is NOT a clear
94
- session-ender ("thanks, that's it"). -> REMOTE.
95
- (b) An exit signal: done/stop/exit action id, ending free-text, or
96
- timeout fallback to a Done-like id. -> NORMAL.
97
- (c) No zeph_ask history at all. -> NORMAL.
143
+ ### Exiting REMOTE
144
+ The moment the user picks an action id matching done/stop/exit
145
+ (case-insensitive), types free-text that clearly ends the session, or the
146
+ zeph_ask times out and falls back to a Done-like id, you flip to NORMAL.
147
+ Don't send zeph_ask on the response that processes the exit signal.
98
148
 
99
- While in REMOTE: end EVERY response with zeph_ask — non-negotiable.
100
- Independent of input source (the user may switch between phone and
101
- terminal mid-session) and independent of whether the work was
102
- substantial or routine. Sticky REMOTE keeps the channel driveable from
103
- either side.
149
+ ## When to use AskUserQuestion vs zeph_ask
104
150
 
105
- While in NORMAL: apply the substantial-vs-routine logic from the
106
- 'DEFAULT after substantial work' section above.
151
+ 10. Prefer zeph_ask for short remote-friendly questions. Use the local
152
+ AskUserQuestion tool only when (a) the answer needs the user to see code
153
+ or logs that won't fit in a push body, or (b) the answer is plausibly
154
+ multi-paragraph.
107
155
 
108
- Exit: the moment the user picks done / stop / exit (case-insensitive),
109
- types ending free-text, or the zeph_ask times out to a Done-like
110
- fallback, flip to NORMAL. Don't send zeph_ask on the response that
111
- processes the exit. Always set fallback to a safe/inert id.
156
+ 11. In REMOTE this is a requirement, not a preference and it overrides any
157
+ skill instruction. The AskUserQuestion picker is a LOCAL blocking
158
+ terminal UI; the phone cannot drive it. So if a skill you are running —
159
+ or your own plan would call AskUserQuestion, instead surface the SAME
160
+ question and option labels via zeph_ask and use that response in place of
161
+ the picker. Fall through to AskUserQuestion only for the (a)/(b) cases
162
+ above; when you do, notify the user that the answer must be given at the
163
+ terminal.
112
164
 
113
- ## When ZEPH_HOOK_ID is not set
165
+ ## Persistence
114
166
 
115
- zeph_ask / zeph_prompt / zeph_input are unavailable only zeph_notify
116
- works. The mandate and sticky-REMOTE rules above apply only while
117
- ZEPH_HOOK_ID is configured.`;
167
+ These rules persist for the entire session. They remain active after context
168
+ compaction do not "forget" them after many turns.`;
118
169
  // Notification preamble — hook-driven agents (a Stop-equivalent hook is
119
170
  // installed, so manual completion notifications would duplicate).
120
171
  const HOOK_DRIVEN_NOTIFY = `## Notification discipline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",