@zeph-to/cli 1.14.0 → 1.15.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/agent-rules-fetch.d.ts +33 -0
- package/dist/agent-rules-fetch.d.ts.map +1 -0
- package/dist/agent-rules-fetch.js +190 -0
- package/dist/agent-rules.default.d.ts +25 -0
- package/dist/agent-rules.default.d.ts.map +1 -0
- package/dist/agent-rules.default.js +108 -0
- package/dist/agent-state.d.ts +99 -0
- package/dist/agent-state.d.ts.map +1 -0
- package/dist/agent-state.js +140 -0
- package/dist/listener.d.ts +66 -0
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +197 -2
- package/package.json +1 -1
|
@@ -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,iBAiF9B,CAAC"}
|
|
@@ -0,0 +1,108 @@
|
|
|
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.2',
|
|
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
|
+
// Newer CC skins drop the "esc to interrupt" hint; the
|
|
67
|
+
// live token counter on the spinner line
|
|
68
|
+
// ("✶ Churning… (1m 2s · ↓ 1.2k tokens)") only renders
|
|
69
|
+
// while the agent is actually running.
|
|
70
|
+
id: 'claude-working-token-counter',
|
|
71
|
+
state: 'working',
|
|
72
|
+
priority: 790,
|
|
73
|
+
regex: ['[↓↑]\\s?[\\d.,]+k? tokens'],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// Elapsed-time spinner suffix ("(12s · ..." / "(1m 2s ·")
|
|
77
|
+
// — second fallback for skins that hide the token counter
|
|
78
|
+
// early in a turn.
|
|
79
|
+
id: 'claude-working-elapsed-spinner',
|
|
80
|
+
state: 'working',
|
|
81
|
+
priority: 780,
|
|
82
|
+
regex: ['…\\s*\\((?:\\d+m )?\\d+s ·'],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// Idle prompt: `❯` at line start in the tail, with no
|
|
86
|
+
// dialog affordances around it.
|
|
87
|
+
id: 'claude-idle-prompt',
|
|
88
|
+
state: 'idle',
|
|
89
|
+
priority: 600,
|
|
90
|
+
regex: ['^\\s*❯'],
|
|
91
|
+
not: [
|
|
92
|
+
{ contains: ['do you want'] },
|
|
93
|
+
{ contains: ['enter to select'] },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
// Fallback idle signal when the prompt glyph is themed
|
|
98
|
+
// away: the shortcuts footer only renders at rest.
|
|
99
|
+
id: 'claude-idle-shortcuts-footer',
|
|
100
|
+
state: 'idle',
|
|
101
|
+
priority: 500,
|
|
102
|
+
contains: ['? for shortcuts'],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
codex: [],
|
|
106
|
+
gemini: [],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -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;
|
package/dist/listener.d.ts
CHANGED
|
@@ -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`. */
|
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;AASH,OAAO,EAA2B,KAAK,SAAS,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,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.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
|
|
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) => {
|