@zeph-to/cli 1.14.0 → 1.15.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.
@@ -0,0 +1,33 @@
1
+ import { type DetectionManifest } from './agent-state.js';
2
+ export declare const RULES_CACHE_FILE: string;
3
+ export declare const RULES_REFRESH_INTERVAL_MS: number;
4
+ /**
5
+ * Structural validation of an untrusted manifest. Engine-version gate
6
+ * included: a manifest authored for a future engine is rejected whole —
7
+ * partial interpretation of unknown semantics is worse than falling
8
+ * back to bundled rules.
9
+ */
10
+ export declare const validateManifest: (value: unknown) => DetectionManifest | null;
11
+ export type ManifestSource = 'remote' | 'cache' | 'bundled';
12
+ export declare const getActiveManifest: () => DetectionManifest;
13
+ export declare const getActiveManifestSource: () => ManifestSource;
14
+ /** Test hook. */
15
+ export declare const resetActiveManifest: () => void;
16
+ /**
17
+ * Startup path (synchronous): promote the disk cache if it validates,
18
+ * else stay on bundled. Never throws — detection must work offline.
19
+ */
20
+ export declare const loadManifestFromCache: () => ManifestSource;
21
+ export declare const rulesUrl: () => string;
22
+ export interface RefreshResult {
23
+ source: ManifestSource;
24
+ /** 'updated' | 'not-modified' | 'invalid' | 'error' — for verbose logs. */
25
+ outcome: string;
26
+ version?: string;
27
+ }
28
+ /**
29
+ * One refresh attempt: conditional GET, validate, persist, activate.
30
+ * All failure modes degrade to the current active manifest.
31
+ */
32
+ export declare const refreshManifest: (fetchImpl?: typeof fetch) => Promise<RefreshResult>;
33
+ //# sourceMappingURL=agent-rules-fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-rules-fetch.d.ts","sourceRoot":"","sources":["../src/agent-rules-fetch.ts"],"names":[],"mappings":"AAeA,OAAO,EAEH,KAAK,iBAAiB,EACzB,MAAM,kBAAkB,CAAC;AAG1B,eAAO,MAAM,gBAAgB,QAAuC,CAAC;AACrE,eAAO,MAAM,yBAAyB,QAAqB,CAAC;AA6C5D;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,OAAO,KAAG,iBAAiB,GAAG,IAWrE,CAAC;AAIF,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;AAK5D,eAAO,MAAM,iBAAiB,QAAO,iBAAmC,CAAC;AACzE,eAAO,MAAM,uBAAuB,QAAO,cAA8B,CAAC;AAS1E,iBAAiB;AACjB,eAAO,MAAM,mBAAmB,QAAO,IAEtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAO,cAWxC,CAAC;AAWF,eAAO,MAAM,QAAQ,QAAO,MAK3B,CAAC;AAEF,MAAM,WAAW,aAAa;IAC1B,MAAM,EAAE,cAAc,CAAC;IACvB,2EAA2E;IAC3E,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAU,YAAW,OAAO,KAAa,KAAG,OAAO,CAAC,aAAa,CAsC5F,CAAC"}
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refreshManifest = exports.rulesUrl = exports.loadManifestFromCache = exports.resetActiveManifest = exports.getActiveManifestSource = exports.getActiveManifest = exports.validateManifest = exports.RULES_REFRESH_INTERVAL_MS = exports.RULES_CACHE_FILE = void 0;
4
+ /**
5
+ * OTA delivery for agent detection rules (SPEC-AGENT-AWARENESS §S7).
6
+ *
7
+ * Agent UIs change on their release cadence, not ours: when Claude
8
+ * Code reshapes its status line, detection must be fixable by shipping
9
+ * DATA, not a new daemon. The listener therefore resolves its manifest
10
+ * as: valid remote fetch → valid disk cache → bundled defaults. Every
11
+ * tier fails closed to the next — a dead endpoint, corrupt cache, or
12
+ * hostile payload can never leave the listener without rules, and
13
+ * `disabledRuleIds` in a fetched manifest acts as a same-day
14
+ * kill-switch for a misfiring rule (no release, no restart).
15
+ */
16
+ const fs_1 = require("fs");
17
+ const path_1 = require("path");
18
+ const config_js_1 = require("./config.js");
19
+ const agent_state_js_1 = require("./agent-state.js");
20
+ const agent_rules_default_js_1 = require("./agent-rules.default.js");
21
+ exports.RULES_CACHE_FILE = (0, path_1.join)(config_js_1.CONFIG_DIR, 'agent-rules.json');
22
+ exports.RULES_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000;
23
+ // Fallback only — the manifest path normally derives from the resolved
24
+ // API base so the stage prefix (prod /v1, dev /d1) follows the user's
25
+ // config instead of being hardcoded here.
26
+ const DEFAULT_API_BASE = 'https://api.zeph.to/v1';
27
+ // Matches the server-side serving cap; anything bigger is not a manifest.
28
+ const MAX_MANIFEST_BYTES = 256 * 1024;
29
+ const FETCH_TIMEOUT_MS = 10_000;
30
+ const VALID_STATES = new Set(['working', 'blocked', 'idle', 'unknown']);
31
+ const isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
32
+ const isCondition = (v) => {
33
+ if (typeof v !== 'object' || v === null)
34
+ return false;
35
+ const c = v;
36
+ if (c.contains !== undefined && !isStringArray(c.contains))
37
+ return false;
38
+ if (c.regex !== undefined && !isStringArray(c.regex))
39
+ return false;
40
+ return true;
41
+ };
42
+ const isRule = (v) => {
43
+ if (typeof v !== 'object' || v === null)
44
+ return false;
45
+ const r = v;
46
+ if (typeof r.id !== 'string' || r.id.length === 0)
47
+ return false;
48
+ if (typeof r.state !== 'string' || !VALID_STATES.has(r.state))
49
+ return false;
50
+ if (typeof r.priority !== 'number' || !Number.isFinite(r.priority))
51
+ return false;
52
+ if (r.region !== undefined && r.region !== 'tail' && r.region !== 'whole')
53
+ return false;
54
+ if (r.tailLines !== undefined && (typeof r.tailLines !== 'number' || r.tailLines < 1 || r.tailLines > 200))
55
+ return false;
56
+ if (r.contains !== undefined && !isStringArray(r.contains))
57
+ return false;
58
+ if (r.regex !== undefined && !isStringArray(r.regex))
59
+ return false;
60
+ if (r.any !== undefined && (!Array.isArray(r.any) || !r.any.every(isCondition)))
61
+ return false;
62
+ if (r.not !== undefined && (!Array.isArray(r.not) || !r.not.every(isCondition)))
63
+ return false;
64
+ if (r.skipStateUpdate !== undefined && typeof r.skipStateUpdate !== 'boolean')
65
+ return false;
66
+ return true;
67
+ };
68
+ /**
69
+ * Structural validation of an untrusted manifest. Engine-version gate
70
+ * included: a manifest authored for a future engine is rejected whole —
71
+ * partial interpretation of unknown semantics is worse than falling
72
+ * back to bundled rules.
73
+ */
74
+ const validateManifest = (value) => {
75
+ if (typeof value !== 'object' || value === null)
76
+ return null;
77
+ const m = value;
78
+ if (m.engineVersion !== agent_state_js_1.ENGINE_VERSION)
79
+ return null;
80
+ if (typeof m.version !== 'string' || m.version.length === 0)
81
+ return null;
82
+ if (m.disabledRuleIds !== undefined && !isStringArray(m.disabledRuleIds))
83
+ return null;
84
+ if (typeof m.agents !== 'object' || m.agents === null)
85
+ return null;
86
+ for (const rules of Object.values(m.agents)) {
87
+ if (!Array.isArray(rules) || !rules.every(isRule))
88
+ return null;
89
+ }
90
+ return value;
91
+ };
92
+ exports.validateManifest = validateManifest;
93
+ let activeManifest = agent_rules_default_js_1.DEFAULT_MANIFEST;
94
+ let activeSource = 'bundled';
95
+ const getActiveManifest = () => activeManifest;
96
+ exports.getActiveManifest = getActiveManifest;
97
+ const getActiveManifestSource = () => activeSource;
98
+ exports.getActiveManifestSource = getActiveManifestSource;
99
+ const activateManifest = (manifest, source) => {
100
+ activeManifest = manifest;
101
+ activeSource = source;
102
+ // Old manifest's compiled patterns must not pin memory forever.
103
+ (0, agent_state_js_1.clearRegexCache)();
104
+ };
105
+ /** Test hook. */
106
+ const resetActiveManifest = () => {
107
+ activateManifest(agent_rules_default_js_1.DEFAULT_MANIFEST, 'bundled');
108
+ };
109
+ exports.resetActiveManifest = resetActiveManifest;
110
+ /**
111
+ * Startup path (synchronous): promote the disk cache if it validates,
112
+ * else stay on bundled. Never throws — detection must work offline.
113
+ */
114
+ const loadManifestFromCache = () => {
115
+ try {
116
+ const cache = JSON.parse((0, fs_1.readFileSync)(exports.RULES_CACHE_FILE, 'utf-8'));
117
+ const manifest = (0, exports.validateManifest)(cache.manifest);
118
+ if (manifest) {
119
+ activateManifest(manifest, 'cache');
120
+ }
121
+ }
122
+ catch {
123
+ // Missing/corrupt cache — bundled rules carry the session.
124
+ }
125
+ return activeSource;
126
+ };
127
+ exports.loadManifestFromCache = loadManifestFromCache;
128
+ const readCachedEtag = () => {
129
+ try {
130
+ const cache = JSON.parse((0, fs_1.readFileSync)(exports.RULES_CACHE_FILE, 'utf-8'));
131
+ return typeof cache.etag === 'string' ? cache.etag : undefined;
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ };
137
+ const rulesUrl = () => {
138
+ const override = (0, config_js_1.resolvedEnv)('ZEPH_AGENT_RULES_URL');
139
+ if (override)
140
+ return override;
141
+ const base = (0, config_js_1.resolvedEnv)('ZEPH_BASE_URL') ?? (0, config_js_1.loadConfig)().baseUrl ?? DEFAULT_API_BASE;
142
+ return `${base.replace(/\/$/, '')}/agent-detection/manifest`;
143
+ };
144
+ exports.rulesUrl = rulesUrl;
145
+ /**
146
+ * One refresh attempt: conditional GET, validate, persist, activate.
147
+ * All failure modes degrade to the current active manifest.
148
+ */
149
+ const refreshManifest = async (fetchImpl = fetch) => {
150
+ try {
151
+ const etag = readCachedEtag();
152
+ const res = await fetchImpl((0, exports.rulesUrl)(), {
153
+ headers: etag ? { 'If-None-Match': etag } : {},
154
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
155
+ });
156
+ if (res.status === 304) {
157
+ return { source: activeSource, outcome: 'not-modified', version: activeManifest.version };
158
+ }
159
+ if (!res.ok) {
160
+ return { source: activeSource, outcome: 'error' };
161
+ }
162
+ const body = await res.text();
163
+ if (Buffer.byteLength(body, 'utf-8') > MAX_MANIFEST_BYTES) {
164
+ return { source: activeSource, outcome: 'invalid' };
165
+ }
166
+ const manifest = (0, exports.validateManifest)(JSON.parse(body));
167
+ if (!manifest) {
168
+ return { source: activeSource, outcome: 'invalid' };
169
+ }
170
+ const cache = {
171
+ etag: res.headers.get('etag') ?? undefined,
172
+ fetchedAt: new Date().toISOString(),
173
+ manifest,
174
+ };
175
+ try {
176
+ (0, fs_1.mkdirSync)(config_js_1.CONFIG_DIR, { recursive: true });
177
+ (0, fs_1.writeFileSync)(exports.RULES_CACHE_FILE, JSON.stringify(cache, null, 2) + '\n');
178
+ }
179
+ catch {
180
+ // Cache write failure is non-fatal: the manifest still
181
+ // activates for this process lifetime.
182
+ }
183
+ activateManifest(manifest, 'remote');
184
+ return { source: 'remote', outcome: 'updated', version: manifest.version };
185
+ }
186
+ catch {
187
+ return { source: activeSource, outcome: 'error' };
188
+ }
189
+ };
190
+ exports.refreshManifest = refreshManifest;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Bundled default detection rules — the fallback when no OTA manifest
3
+ * has been fetched (or the fetched one fails validation, §S7).
4
+ *
5
+ * Rule content is authored HERE, from observation of each agent's UI.
6
+ * Do not import or transcribe rules from third-party projects: the
7
+ * bundled set is original work under this repo's license.
8
+ *
9
+ * Claude Code observations behind these rules (UI as of mid-2026):
10
+ * - While running, the status line shows an "esc to interrupt" hint
11
+ * next to the spinner/progress text.
12
+ * - Blocking dialogs (permission requests, plan approval, question
13
+ * forms) render a numbered/arrow-key option list with "esc" as the
14
+ * cancel affordance and Enter as the confirm affordance.
15
+ * - At rest, the input box renders a `❯` prompt and the footer offers
16
+ * "? for shortcuts".
17
+ * - The transcript viewer (ctrl+o) overlays the pane; the agent may
18
+ * still be working underneath, so it must not change the state.
19
+ *
20
+ * codex/gemini start with no rules: they report `unknown` until a
21
+ * vetted rule set ships via OTA. Honest ignorance beats guessed state.
22
+ */
23
+ import { type DetectionManifest } from './agent-state.js';
24
+ export declare const DEFAULT_MANIFEST: DetectionManifest;
25
+ //# sourceMappingURL=agent-rules.default.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-rules.default.d.ts","sourceRoot":"","sources":["../src/agent-rules.default.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAkB,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1E,eAAO,MAAM,gBAAgB,EAAE,iBA8D9B,CAAC"}
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_MANIFEST = void 0;
4
+ /**
5
+ * Bundled default detection rules — the fallback when no OTA manifest
6
+ * has been fetched (or the fetched one fails validation, §S7).
7
+ *
8
+ * Rule content is authored HERE, from observation of each agent's UI.
9
+ * Do not import or transcribe rules from third-party projects: the
10
+ * bundled set is original work under this repo's license.
11
+ *
12
+ * Claude Code observations behind these rules (UI as of mid-2026):
13
+ * - While running, the status line shows an "esc to interrupt" hint
14
+ * next to the spinner/progress text.
15
+ * - Blocking dialogs (permission requests, plan approval, question
16
+ * forms) render a numbered/arrow-key option list with "esc" as the
17
+ * cancel affordance and Enter as the confirm affordance.
18
+ * - At rest, the input box renders a `❯` prompt and the footer offers
19
+ * "? for shortcuts".
20
+ * - The transcript viewer (ctrl+o) overlays the pane; the agent may
21
+ * still be working underneath, so it must not change the state.
22
+ *
23
+ * codex/gemini start with no rules: they report `unknown` until a
24
+ * vetted rule set ships via OTA. Honest ignorance beats guessed state.
25
+ */
26
+ const agent_state_js_1 = require("./agent-state.js");
27
+ exports.DEFAULT_MANIFEST = {
28
+ engineVersion: agent_state_js_1.ENGINE_VERSION,
29
+ version: '2026.07.04.1',
30
+ agents: {
31
+ claude: [
32
+ {
33
+ // Transcript / verbose-output overlay: freeze state.
34
+ id: 'claude-transcript-overlay',
35
+ state: 'unknown',
36
+ priority: 1000,
37
+ skipStateUpdate: true,
38
+ any: [
39
+ { contains: ['showing detailed transcript'] },
40
+ { contains: ['ctrl+o to toggle'] },
41
+ ],
42
+ },
43
+ {
44
+ // Blocking dialog: an option list waiting on the user.
45
+ // "esc" alone is ambiguous (working shows "esc to
46
+ // interrupt"), so require a selection affordance too.
47
+ id: 'claude-blocked-dialog',
48
+ state: 'blocked',
49
+ priority: 900,
50
+ contains: ['esc'],
51
+ any: [
52
+ { contains: ['do you want'] },
53
+ { contains: ['enter to select'] },
54
+ { contains: ['enter to confirm'] },
55
+ { contains: ['to navigate'] },
56
+ ],
57
+ not: [{ contains: ['esc to interrupt'] }],
58
+ },
59
+ {
60
+ id: 'claude-working-interrupt-hint',
61
+ state: 'working',
62
+ priority: 800,
63
+ contains: ['esc to interrupt'],
64
+ },
65
+ {
66
+ // Idle prompt: `❯` at line start in the tail, with no
67
+ // dialog affordances around it.
68
+ id: 'claude-idle-prompt',
69
+ state: 'idle',
70
+ priority: 600,
71
+ regex: ['^\\s*❯'],
72
+ not: [
73
+ { contains: ['do you want'] },
74
+ { contains: ['enter to select'] },
75
+ ],
76
+ },
77
+ {
78
+ // Fallback idle signal when the prompt glyph is themed
79
+ // away: the shortcuts footer only renders at rest.
80
+ id: 'claude-idle-shortcuts-footer',
81
+ state: 'idle',
82
+ priority: 500,
83
+ contains: ['? for shortcuts'],
84
+ },
85
+ ],
86
+ codex: [],
87
+ gemini: [],
88
+ },
89
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Agent state detection engine — classifies a tmux pane's visible text
3
+ * into `working | blocked | idle | unknown` using declarative rules.
4
+ *
5
+ * Design constraints (see zeph/docs/SPEC-AGENT-AWARENESS.md §S1):
6
+ * - Rules are DATA, not code: they ship bundled but are replaceable
7
+ * over the air (§S7), so agent UI changes never require a daemon
8
+ * release. Everything here must therefore survive hostile or
9
+ * malformed manifests: pattern-length caps, input-size caps, and
10
+ * per-rule isolation of regex compile failures.
11
+ * - Pure functions only. The listener owns timing, tmux, and I/O;
12
+ * this module owns classification and flap suppression so both are
13
+ * unit-testable without a terminal.
14
+ * - `done` is deliberately NOT a wire state: "finished but unseen" is
15
+ * per-user view state, derived client-side. The daemon reports only
16
+ * what it can observe.
17
+ */
18
+ import type { AgentKind } from './remote-agents.js';
19
+ export type AgentState = 'working' | 'blocked' | 'idle' | 'unknown';
20
+ export interface RuleCondition {
21
+ /** All strings must appear (case-insensitive). */
22
+ contains?: string[];
23
+ /** All patterns must match. */
24
+ regex?: string[];
25
+ }
26
+ export interface DetectionRule {
27
+ id: string;
28
+ state: AgentState;
29
+ /** Higher wins; first match ends evaluation. */
30
+ priority: number;
31
+ /** 'tail' = last N non-empty lines (default), 'whole' = full capture. */
32
+ region?: 'tail' | 'whole';
33
+ /** Lines for region 'tail'. Default 10. */
34
+ tailLines?: number;
35
+ contains?: string[];
36
+ regex?: string[];
37
+ /** At least one condition group must match (OR). */
38
+ any?: RuleCondition[];
39
+ /** No condition group may match (exclusion). */
40
+ not?: RuleCondition[];
41
+ /**
42
+ * On match, keep the previous state instead of this rule's state.
43
+ * For overlay screens (transcript viewer, menus) that would
44
+ * otherwise pollute the state while the agent is still working.
45
+ */
46
+ skipStateUpdate?: boolean;
47
+ }
48
+ export interface DetectionManifest {
49
+ engineVersion: number;
50
+ /** Date.revision, e.g. "2026.07.04.1" — for OTA freshness compare. */
51
+ version: string;
52
+ /** Kill-switch: rule ids to ignore without shipping a new manifest shape. */
53
+ disabledRuleIds?: string[];
54
+ agents: Partial<Record<AgentKind, DetectionRule[]>>;
55
+ }
56
+ export declare const ENGINE_VERSION = 1;
57
+ export interface EvaluationResult {
58
+ state: AgentState;
59
+ /** Rule that decided the state — for verbose logs and rule debugging. */
60
+ ruleId?: string;
61
+ }
62
+ /** Test hook: manifest swaps call this so stale patterns don't pin memory. */
63
+ export declare const clearRegexCache: () => void;
64
+ /**
65
+ * Classify one pane capture. `prev` feeds skipStateUpdate rules — an
66
+ * overlay match returns the previous confirmed state unchanged.
67
+ */
68
+ export declare const evaluateState: (paneText: string, agentKind: AgentKind, manifest: DetectionManifest, prev?: AgentState) => EvaluationResult;
69
+ /**
70
+ * Safe one-pattern probe for output-match watches (§S5 v2). Same caps
71
+ * as rule evaluation — user-authored watch patterns are exactly as
72
+ * untrusted as OTA rules. Returns the matched line for the push body.
73
+ */
74
+ export declare const findPatternMatch: (pattern: string, paneText: string) => {
75
+ line: string;
76
+ } | null;
77
+ export interface StateTracker {
78
+ /** Last CONFIRMED state — what gets reported to the server. */
79
+ confirmed: AgentState;
80
+ confirmedAt: number;
81
+ ruleId?: string;
82
+ /** Pending different observation awaiting its second sighting. */
83
+ candidate?: AgentState;
84
+ candidateRuleId?: string;
85
+ /** Hash of the pane text behind the last evaluation (skip re-eval). */
86
+ contentHash?: string;
87
+ }
88
+ /**
89
+ * Consecutive-confirmation debounce: a NEW state must be observed on
90
+ * two consecutive cycles (~10 s at the 5 s report interval) before it
91
+ * replaces the confirmed one. Menus flashed open, mid-render frames,
92
+ * and scroll artifacts all last one cycle and die as candidates.
93
+ *
94
+ * The very first observation confirms immediately — a fresh tracker
95
+ * has no baseline to protect, and the server treats a session's first
96
+ * reported state as baseline, not as a transition (§S2).
97
+ */
98
+ export declare const advanceState: (tracker: StateTracker | undefined, observed: EvaluationResult, now: number) => StateTracker;
99
+ //# sourceMappingURL=agent-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-state.d.ts","sourceRoot":"","sources":["../src/agent-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAEpD,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAEpE,MAAM,WAAW,aAAa;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,UAAU,CAAC;IAClB,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC1B,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,oDAAoD;IACpD,GAAG,CAAC,EAAE,aAAa,EAAE,CAAC;IACtB,gDAAgD;IAChD,GAAG,CAAC,EAAE,aAAa,EAAE,CAAC;IACtB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC;CACvD;AAED,eAAO,MAAM,cAAc,IAAI,CAAC;AAUhC,MAAM,WAAW,gBAAgB;IAC7B,KAAK,EAAE,UAAU,CAAC;IAClB,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAqBD,8EAA8E;AAC9E,eAAO,MAAM,eAAe,QAAO,IAElC,CAAC;AA8BF;;;GAGG;AACH,eAAO,MAAM,aAAa,GACtB,UAAU,MAAM,EAChB,WAAW,SAAS,EACpB,UAAU,iBAAiB,EAC3B,OAAM,UAAsB,KAC7B,gBAuBF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAavF,CAAC;AAIF,MAAM,WAAW,YAAY;IACzB,+DAA+D;IAC/D,SAAS,EAAE,UAAU,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,GACrB,SAAS,YAAY,GAAG,SAAS,EACjC,UAAU,gBAAgB,EAC1B,KAAK,MAAM,KACZ,YAaF,CAAC"}
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.advanceState = exports.findPatternMatch = exports.evaluateState = exports.clearRegexCache = exports.ENGINE_VERSION = void 0;
4
+ exports.ENGINE_VERSION = 1;
5
+ // OTA manifests are semi-trusted: schema-validated but authored by
6
+ // humans and fetched over the network. Caps bound the worst case of a
7
+ // pathological regex (ReDoS) — a 200-char pattern over ≤8 KiB input
8
+ // keeps even catastrophic backtracking in the low milliseconds.
9
+ const MAX_PATTERN_LENGTH = 200;
10
+ const MAX_INPUT_BYTES = 8 * 1024;
11
+ const DEFAULT_TAIL_LINES = 10;
12
+ // Compiled-regex cache. Rules are static between manifest swaps, so
13
+ // compiling once per (pattern) is enough; a failed compile is cached as
14
+ // null so a bad OTA pattern logs once, not every 5-second cycle.
15
+ const regexCache = new Map();
16
+ const compilePattern = (pattern) => {
17
+ if (regexCache.has(pattern))
18
+ return regexCache.get(pattern) ?? null;
19
+ let compiled = null;
20
+ if (pattern.length <= MAX_PATTERN_LENGTH) {
21
+ try {
22
+ compiled = new RegExp(pattern, 'im');
23
+ }
24
+ catch {
25
+ compiled = null;
26
+ }
27
+ }
28
+ regexCache.set(pattern, compiled);
29
+ return compiled;
30
+ };
31
+ /** Test hook: manifest swaps call this so stale patterns don't pin memory. */
32
+ const clearRegexCache = () => {
33
+ regexCache.clear();
34
+ };
35
+ exports.clearRegexCache = clearRegexCache;
36
+ const conditionMatches = (cond, text, lowerText) => {
37
+ for (const needle of cond.contains ?? []) {
38
+ if (!lowerText.includes(needle.toLowerCase()))
39
+ return false;
40
+ }
41
+ for (const pattern of cond.regex ?? []) {
42
+ const compiled = compilePattern(pattern);
43
+ // A pattern that failed to compile can never be satisfied —
44
+ // fail the condition rather than silently passing it, so a
45
+ // broken OTA rule becomes inert instead of over-matching.
46
+ if (!compiled || !compiled.test(text))
47
+ return false;
48
+ }
49
+ return true;
50
+ };
51
+ const ruleMatches = (rule, text, lowerText) => {
52
+ if (!conditionMatches({ contains: rule.contains, regex: rule.regex }, text, lowerText))
53
+ return false;
54
+ if (rule.any && rule.any.length > 0) {
55
+ if (!rule.any.some((c) => conditionMatches(c, text, lowerText)))
56
+ return false;
57
+ }
58
+ for (const excluded of rule.not ?? []) {
59
+ if (conditionMatches(excluded, text, lowerText))
60
+ return false;
61
+ }
62
+ return true;
63
+ };
64
+ const tailOf = (lines, count) => lines.filter((l) => l.trim().length > 0).slice(-count).join('\n');
65
+ /**
66
+ * Classify one pane capture. `prev` feeds skipStateUpdate rules — an
67
+ * overlay match returns the previous confirmed state unchanged.
68
+ */
69
+ const evaluateState = (paneText, agentKind, manifest, prev = 'unknown') => {
70
+ // Truncate from the FRONT: the bottom of the pane is where every
71
+ // agent renders its live status, so the tail is the signal.
72
+ let text = paneText;
73
+ if (Buffer.byteLength(text, 'utf-8') > MAX_INPUT_BYTES) {
74
+ text = text.slice(-MAX_INPUT_BYTES);
75
+ }
76
+ const lines = text.split('\n');
77
+ const disabled = new Set(manifest.disabledRuleIds ?? []);
78
+ const rules = (manifest.agents[agentKind] ?? [])
79
+ .filter((r) => !disabled.has(r.id))
80
+ .sort((a, b) => b.priority - a.priority);
81
+ for (const rule of rules) {
82
+ const scope = rule.region === 'whole'
83
+ ? text
84
+ : tailOf(lines, rule.tailLines ?? DEFAULT_TAIL_LINES);
85
+ if (ruleMatches(rule, scope, scope.toLowerCase())) {
86
+ if (rule.skipStateUpdate)
87
+ return { state: prev, ruleId: rule.id };
88
+ return { state: rule.state, ruleId: rule.id };
89
+ }
90
+ }
91
+ return { state: 'unknown' };
92
+ };
93
+ exports.evaluateState = evaluateState;
94
+ /**
95
+ * Safe one-pattern probe for output-match watches (§S5 v2). Same caps
96
+ * as rule evaluation — user-authored watch patterns are exactly as
97
+ * untrusted as OTA rules. Returns the matched line for the push body.
98
+ */
99
+ const findPatternMatch = (pattern, paneText) => {
100
+ let text = paneText;
101
+ if (Buffer.byteLength(text, 'utf-8') > MAX_INPUT_BYTES) {
102
+ text = text.slice(-MAX_INPUT_BYTES);
103
+ }
104
+ const compiled = compilePattern(pattern);
105
+ if (!compiled)
106
+ return null;
107
+ const match = compiled.exec(text);
108
+ if (!match)
109
+ return null;
110
+ const start = text.lastIndexOf('\n', match.index) + 1;
111
+ const endIdx = text.indexOf('\n', match.index);
112
+ const line = text.slice(start, endIdx === -1 ? undefined : endIdx).trim();
113
+ return { line };
114
+ };
115
+ exports.findPatternMatch = findPatternMatch;
116
+ /**
117
+ * Consecutive-confirmation debounce: a NEW state must be observed on
118
+ * two consecutive cycles (~10 s at the 5 s report interval) before it
119
+ * replaces the confirmed one. Menus flashed open, mid-render frames,
120
+ * and scroll artifacts all last one cycle and die as candidates.
121
+ *
122
+ * The very first observation confirms immediately — a fresh tracker
123
+ * has no baseline to protect, and the server treats a session's first
124
+ * reported state as baseline, not as a transition (§S2).
125
+ */
126
+ const advanceState = (tracker, observed, now) => {
127
+ if (!tracker) {
128
+ return { confirmed: observed.state, confirmedAt: now, ruleId: observed.ruleId };
129
+ }
130
+ if (observed.state === tracker.confirmed) {
131
+ // Re-confirmation clears any pending candidate.
132
+ return { ...tracker, candidate: undefined, candidateRuleId: undefined, ruleId: observed.ruleId ?? tracker.ruleId };
133
+ }
134
+ if (observed.state === tracker.candidate) {
135
+ // Second consecutive sighting — promote.
136
+ return { confirmed: observed.state, confirmedAt: now, ruleId: observed.ruleId };
137
+ }
138
+ return { ...tracker, candidate: observed.state, candidateRuleId: observed.ruleId };
139
+ };
140
+ exports.advanceState = advanceState;
@@ -21,6 +21,7 @@
21
21
  * backoff on transient failures; gives up on auth failures (4001/4002/4003).
22
22
  */
23
23
  import { type AgentKind, type RegisteredRemoteAgent } from './remote-agents.js';
24
+ import { type AgentState } from './agent-state.js';
24
25
  interface AgentSession {
25
26
  name: string;
26
27
  attached: boolean;
@@ -30,6 +31,15 @@ interface AgentSession {
30
31
  label?: string | null;
31
32
  createdAt?: string;
32
33
  lastActivityAt?: string;
34
+ /**
35
+ * Detected agent state (wire contract — flows to the server and the
36
+ * phone's Agents tab). Absent when the pane can't be captured or
37
+ * detection is unavailable. `done` never appears here: it's derived
38
+ * client-side (SPEC-AGENT-AWARENESS §S1/§S6).
39
+ */
40
+ state?: AgentState;
41
+ stateChangedAt?: string;
42
+ stateRuleId?: string;
33
43
  }
34
44
  export declare const AUTH_FAILURE_CODES: ReadonlySet<number>;
35
45
  export declare const checkRateLimit: (session: string, now?: number) => boolean;
@@ -71,6 +81,62 @@ interface PaneInfo {
71
81
  * remote-agents.ts are accepted as a match.
72
82
  */
73
83
  export declare const detectRemoteAgent: (info: PaneInfo) => RegisteredRemoteAgent | null;
84
+ /** Test hook. */
85
+ export declare const resetSessionStates: () => void;
86
+ /**
87
+ * Fold one pane capture into the per-session state machine and return
88
+ * the wire fields for this report cycle. Exported (with `paneText`
89
+ * injected) so tests can drive it without tmux.
90
+ *
91
+ * Hash short-circuit: an unchanged screen skips rule evaluation but
92
+ * still replays the previous observation through the debounce — cheap
93
+ * cycles stay cheap without wedging candidate promotion.
94
+ */
95
+ export declare const deriveSessionState: (name: string, agentKind: AgentKind, paneText: string | null, now?: number) => Pick<AgentSession, "state" | "stateChangedAt" | "stateRuleId">;
96
+ export interface PatternWatch {
97
+ sessionName: string;
98
+ pattern: string;
99
+ }
100
+ /** Ack payload → active watch list. Prunes fired-markers for dead watches. */
101
+ export declare const setPatternWatches: (raw: unknown) => void;
102
+ /** Test hook. */
103
+ export declare const resetPatternWatches: () => void;
104
+ export interface WatchHit {
105
+ sessionName: string;
106
+ pattern: string;
107
+ matchedLine: string;
108
+ }
109
+ /**
110
+ * Probe every un-fired watch whose session is live. `capture` is
111
+ * injectable for tests; production passes capturePaneText.
112
+ */
113
+ export declare const collectWatchHits: (liveNames: ReadonlySet<string>, capture?: (session: string) => string | null) => WatchHit[];
114
+ export interface ScreenRequest {
115
+ subtype?: string;
116
+ targetDeviceId?: string;
117
+ sessionName?: string;
118
+ requestId?: string;
119
+ }
120
+ export interface ScreenSnapshot {
121
+ subtype: 'agent.screen.snapshot';
122
+ requestId: string;
123
+ sessionName: string;
124
+ content?: string;
125
+ truncated?: boolean;
126
+ error?: string;
127
+ capturedAt: string;
128
+ }
129
+ /**
130
+ * Answer a phone's screen-peek request for one of OUR sessions.
131
+ * Returns null when the message isn't addressed to this machine (other
132
+ * ephemeral traffic — clipboard, mirrors — flows through constantly).
133
+ *
134
+ * Security posture: only sessions the inventory already exposes are
135
+ * readable — the phone can see exactly the panes it can already send
136
+ * commands into, nothing else. Rate-limited with the same per-session
137
+ * token bucket as command injection.
138
+ */
139
+ export declare const handleScreenRequest: (req: ScreenRequest) => ScreenSnapshot | null;
74
140
  export interface CollectResult {
75
141
  sessions: AgentSession[];
76
142
  /** Diagnostic notes per rejected session — surfaced under `--verbose`. */
@@ -1 +1 @@
1
- {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"}
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.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.detectRemoteAgent = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = void 0;
28
+ exports.handleListener = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.handleScreenRequest = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.deriveSessionState = exports.resetSessionStates = exports.detectRemoteAgent = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = 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");
@@ -34,6 +34,8 @@ const path_1 = require("path");
34
34
  const ws_1 = __importDefault(require("ws"));
35
35
  const config_js_1 = require("./config.js");
36
36
  const remote_agents_js_1 = require("./remote-agents.js");
37
+ const agent_state_js_1 = require("./agent-state.js");
38
+ const agent_rules_fetch_js_1 = require("./agent-rules-fetch.js");
37
39
  const PING_INTERVAL_MS = 25_000;
38
40
  const PONG_TIMEOUT_MS = 10_000;
39
41
  const RECONNECT_BASE_MS = 1_000;
@@ -466,6 +468,166 @@ const epochToIso = (epoch) => {
466
468
  return undefined;
467
469
  return new Date(n * 1000).toISOString();
468
470
  };
471
+ // ─── Agent state detection (SPEC-AGENT-AWARENESS §S1) ──────────────
472
+ // How many pane lines the state rules see. The live status area every
473
+ // agent renders sits in the last handful of lines; 40 leaves margin for
474
+ // tall dialogs without hauling whole scrollbacks through regex.
475
+ const STATE_CAPTURE_LINES = 40;
476
+ const sessionStates = new Map();
477
+ /** Test hook. */
478
+ const resetSessionStates = () => {
479
+ sessionStates.clear();
480
+ };
481
+ exports.resetSessionStates = resetSessionStates;
482
+ const capturePaneText = (session) => {
483
+ const r = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['capture-pane', '-p', '-t', session, '-S', `-${STATE_CAPTURE_LINES}`]), {
484
+ encoding: 'utf-8',
485
+ stdio: ['ignore', 'pipe', 'pipe'],
486
+ });
487
+ if (r.status !== 0)
488
+ return null;
489
+ return r.stdout ?? '';
490
+ };
491
+ /**
492
+ * Fold one pane capture into the per-session state machine and return
493
+ * the wire fields for this report cycle. Exported (with `paneText`
494
+ * injected) so tests can drive it without tmux.
495
+ *
496
+ * Hash short-circuit: an unchanged screen skips rule evaluation but
497
+ * still replays the previous observation through the debounce — cheap
498
+ * cycles stay cheap without wedging candidate promotion.
499
+ */
500
+ const deriveSessionState = (name, agentKind, paneText, now = Date.now()) => {
501
+ if (paneText === null) {
502
+ // Pane unreadable this cycle (session racing shutdown, tmux
503
+ // hiccup) — report nothing rather than a stale confident state.
504
+ sessionStates.delete(name);
505
+ return {};
506
+ }
507
+ const contentHash = (0, crypto_1.createHash)('sha1').update(paneText).digest('hex');
508
+ const entry = sessionStates.get(name);
509
+ const observed = entry && entry.contentHash === contentHash
510
+ ? entry.lastObserved
511
+ : (0, agent_state_js_1.evaluateState)(paneText, agentKind, (0, agent_rules_fetch_js_1.getActiveManifest)(), entry?.tracker.confirmed ?? 'unknown');
512
+ const tracker = (0, agent_state_js_1.advanceState)(entry?.tracker, observed, now);
513
+ sessionStates.set(name, { tracker, lastObserved: observed, contentHash });
514
+ return {
515
+ state: tracker.confirmed,
516
+ stateChangedAt: new Date(tracker.confirmedAt).toISOString(),
517
+ stateRuleId: tracker.ruleId,
518
+ };
519
+ };
520
+ exports.deriveSessionState = deriveSessionState;
521
+ /** Drop trackers for sessions gone from the inventory. */
522
+ const pruneSessionStates = (liveNames) => {
523
+ for (const name of sessionStates.keys()) {
524
+ if (!liveNames.has(name))
525
+ sessionStates.delete(name);
526
+ }
527
+ };
528
+ let patternWatches = [];
529
+ // One-shot: after a hit we stop re-evaluating that watch locally. The
530
+ // server deletes the record on hit, and the next ack prunes it here —
531
+ // the fired-set only bridges the gap between hit and ack.
532
+ const firedWatchKeys = new Set();
533
+ const patternWatchKey = (w) => `${w.sessionName}${w.pattern}`;
534
+ /** Ack payload → active watch list. Prunes fired-markers for dead watches. */
535
+ const setPatternWatches = (raw) => {
536
+ patternWatches = Array.isArray(raw)
537
+ ? raw.filter((w) => typeof w === 'object' && w !== null
538
+ && typeof w.sessionName === 'string'
539
+ && typeof w.pattern === 'string')
540
+ : [];
541
+ const live = new Set(patternWatches.map(patternWatchKey));
542
+ for (const key of firedWatchKeys) {
543
+ if (!live.has(key))
544
+ firedWatchKeys.delete(key);
545
+ }
546
+ };
547
+ exports.setPatternWatches = setPatternWatches;
548
+ /** Test hook. */
549
+ const resetPatternWatches = () => {
550
+ patternWatches = [];
551
+ firedWatchKeys.clear();
552
+ };
553
+ exports.resetPatternWatches = resetPatternWatches;
554
+ /**
555
+ * Probe every un-fired watch whose session is live. `capture` is
556
+ * injectable for tests; production passes capturePaneText.
557
+ */
558
+ const collectWatchHits = (liveNames, capture = capturePaneText) => {
559
+ const hits = [];
560
+ const textCache = new Map();
561
+ for (const w of patternWatches) {
562
+ if (!liveNames.has(w.sessionName))
563
+ continue;
564
+ const key = patternWatchKey(w);
565
+ if (firedWatchKeys.has(key))
566
+ continue;
567
+ if (!textCache.has(w.sessionName))
568
+ textCache.set(w.sessionName, capture(w.sessionName));
569
+ const text = textCache.get(w.sessionName);
570
+ if (text === null || text === undefined)
571
+ continue;
572
+ const match = (0, agent_state_js_1.findPatternMatch)(w.pattern, text);
573
+ if (!match)
574
+ continue;
575
+ firedWatchKeys.add(key);
576
+ hits.push({ sessionName: w.sessionName, pattern: w.pattern, matchedLine: match.line });
577
+ }
578
+ return hits;
579
+ };
580
+ exports.collectWatchHits = collectWatchHits;
581
+ // ─── Screen peek (SPEC-AGENT-AWARENESS §S4) ────────────────────────
582
+ // Pane lines returned to the phone. Taller than the state-detection
583
+ // capture — the user wants to READ this, not regex it.
584
+ const SCREEN_PEEK_LINES = 60;
585
+ // Ephemeral frames ride API Gateway WS (32KB frame limit); stay well
586
+ // under it after JSON envelope overhead.
587
+ const SCREEN_PEEK_MAX_BYTES = 24 * 1024;
588
+ /**
589
+ * Answer a phone's screen-peek request for one of OUR sessions.
590
+ * Returns null when the message isn't addressed to this machine (other
591
+ * ephemeral traffic — clipboard, mirrors — flows through constantly).
592
+ *
593
+ * Security posture: only sessions the inventory already exposes are
594
+ * readable — the phone can see exactly the panes it can already send
595
+ * commands into, nothing else. Rate-limited with the same per-session
596
+ * token bucket as command injection.
597
+ */
598
+ const handleScreenRequest = (req) => {
599
+ if (req.subtype !== 'agent.screen.request')
600
+ return null;
601
+ if (!req.requestId || !req.sessionName)
602
+ return null;
603
+ if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
604
+ return null;
605
+ const capturedAt = new Date().toISOString();
606
+ const base = { subtype: 'agent.screen.snapshot', requestId: req.requestId, sessionName: req.sessionName, capturedAt };
607
+ if (!(0, exports.checkRateLimit)(`screen:${req.sessionName}`)) {
608
+ return { ...base, error: 'rate_limited' };
609
+ }
610
+ if (!(0, exports.collectSessions)().some((s) => s.name === req.sessionName)) {
611
+ return { ...base, error: 'unknown_session' };
612
+ }
613
+ const r = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['capture-pane', '-p', '-t', req.sessionName, '-S', `-${SCREEN_PEEK_LINES}`]), {
614
+ encoding: 'utf-8',
615
+ stdio: ['ignore', 'pipe', 'pipe'],
616
+ });
617
+ if (r.status !== 0) {
618
+ return { ...base, error: 'capture_failed' };
619
+ }
620
+ let content = r.stdout ?? '';
621
+ let truncated = false;
622
+ while (Buffer.byteLength(content, 'utf-8') > SCREEN_PEEK_MAX_BYTES) {
623
+ // Cut from the top — the bottom of the pane is what the user needs.
624
+ const cut = content.indexOf('\n', Math.floor(content.length / 8));
625
+ content = cut > 0 ? content.slice(cut + 1) : content.slice(-SCREEN_PEEK_MAX_BYTES);
626
+ truncated = true;
627
+ }
628
+ return { ...base, content, truncated };
629
+ };
630
+ exports.handleScreenRequest = handleScreenRequest;
469
631
  /**
470
632
  * Inventory pass that also records *why* each `zeph-*` session was
471
633
  * skipped. The verbose log uses the rejection notes to explain empty
@@ -530,8 +692,10 @@ const collectSessionsVerbose = () => {
530
692
  label: parsed.label,
531
693
  createdAt: epochToIso(created),
532
694
  lastActivityAt: epochToIso(activity),
695
+ ...(0, exports.deriveSessionState)(name, agent.kind, capturePaneText(name)),
533
696
  });
534
697
  }
698
+ pruneSessionStates(new Set(sessions.map((s) => s.name)));
535
699
  return { sessions, rejected };
536
700
  };
537
701
  exports.collectSessionsVerbose = collectSessionsVerbose;
@@ -822,11 +986,20 @@ const streamSession = (wsUrl, apiKey) => {
822
986
  return;
823
987
  const { sessions, rejected } = (0, exports.collectSessionsVerbose)();
824
988
  sock.send(JSON.stringify({ type: 'listener.sessions', data: { sessions } }));
989
+ // Output-match watches (§S5 v2): probe watched panes and
990
+ // report hits. One-shot per watch; the server consumes the
991
+ // record and the next ack prunes it locally.
992
+ for (const hit of (0, exports.collectWatchHits)(new Set(sessions.map((s) => s.name)))) {
993
+ sock.send(JSON.stringify({ type: 'listener.watch.hit', data: hit }));
994
+ log(`🔔 watch hit: ${hit.sessionName} ~ /${hit.pattern}/`);
995
+ }
825
996
  // One line per cycle gives the user immediate feedback on
826
997
  // what the phone picker will see — particularly important
827
998
  // during setup, when an empty picker has no other observable
828
999
  // cause.
829
- const names = sessions.map((s) => s.name).join(', ') || '∅';
1000
+ const names = sessions
1001
+ .map((s) => (s.state ? `${s.name}[${s.state}]` : s.name))
1002
+ .join(', ') || '∅';
830
1003
  log(`reported ${sessions.length} session(s): ${names}`);
831
1004
  // Explain skipped zeph-* sessions so the most common
832
1005
  // confusion (pane lost its claude start_command after a
@@ -916,6 +1089,15 @@ const streamSession = (wsUrl, apiKey) => {
916
1089
  // logged, never thrown into the socket handler.
917
1090
  void (0, exports.handlePush)(m.data).catch((err) => log(`! handlePush: ${err.message}`));
918
1091
  }
1092
+ if (m.type === 'ephemeral' && m.data) {
1093
+ // Screen peek (§S4): the phone asks for this machine's live
1094
+ // pane content over the ephemeral relay; the reply rides the
1095
+ // same channel. Nothing is persisted server-side.
1096
+ const reply = (0, exports.handleScreenRequest)(m.data);
1097
+ if (reply && sock.readyState === ws_1.default.OPEN) {
1098
+ sock.send(JSON.stringify({ type: 'ephemeral', data: reply }));
1099
+ }
1100
+ }
919
1101
  // Surface server-side errors from listener.sessions reports.
920
1102
  // Without this the daemon happily logs "reported N session(s)"
921
1103
  // even when the server is silently dropping every message —
@@ -926,6 +1108,8 @@ const streamSession = (wsUrl, apiKey) => {
926
1108
  if (m.type === 'listener.sessions.ack') {
927
1109
  const d = m.data;
928
1110
  log(`✓ server persisted ${d?.count ?? '?'} session(s)`);
1111
+ // Pattern watches ride the ack (§S5 v2) — refresh ours.
1112
+ (0, exports.setPatternWatches)(d?.watches);
929
1113
  }
930
1114
  // `push.sync` (offline batch on $connect) and other types ignored.
931
1115
  });
@@ -1050,6 +1234,17 @@ const handleListener = async (args) => {
1050
1234
  sweepAttachments();
1051
1235
  const gcTimer = setInterval(sweepAttachments, 60 * 60 * 1000);
1052
1236
  gcTimer.unref();
1237
+ // Agent detection rules: disk cache immediately (offline-safe),
1238
+ // then a background OTA refresh now and every 6 h (§S7).
1239
+ (0, agent_rules_fetch_js_1.loadManifestFromCache)();
1240
+ const refreshRules = () => {
1241
+ void (0, agent_rules_fetch_js_1.refreshManifest)().then((r) => {
1242
+ log(`rules: source=${r.source} outcome=${r.outcome}${r.version ? ` version=${r.version}` : ''}`);
1243
+ });
1244
+ };
1245
+ refreshRules();
1246
+ const rulesTimer = setInterval(refreshRules, agent_rules_fetch_js_1.RULES_REFRESH_INTERVAL_MS);
1247
+ rulesTimer.unref();
1053
1248
  let shuttingDown = false;
1054
1249
  let activeHandle = null;
1055
1250
  const stop = (sig) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",