@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.
- package/dist/listener.d.ts +29 -1
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +154 -5
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +73 -5
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +122 -71
- package/package.json +1 -1
package/dist/listener.d.ts
CHANGED
|
@@ -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
|
package/dist/listener.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
package/dist/login.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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,
|
|
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,
|
|
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) => {
|
package/dist/templates.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"
|
|
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.
|
|
30
|
-
// needs to differ, it belongs in
|
|
31
|
-
|
|
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.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
plan the user already approved
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
that label describes
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
##
|
|
165
|
+
## Persistence
|
|
114
166
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|