@wenrwa/marketplace-cli 0.8.5 → 0.8.7

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.
Files changed (42) hide show
  1. package/dist/__tests__/bridge-changes-report.test.js +25 -0
  2. package/dist/__tests__/bridge-process-cpu.test.d.ts +1 -0
  3. package/dist/__tests__/bridge-process-cpu.test.js +135 -0
  4. package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
  5. package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
  6. package/dist/__tests__/bridge-save-env.test.js +343 -0
  7. package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
  8. package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
  9. package/dist/__tests__/bridge-server.test.js +9 -11
  10. package/dist/__tests__/secrets-egress.test.d.ts +1 -0
  11. package/dist/__tests__/secrets-egress.test.js +237 -0
  12. package/dist/__tests__/secrets-redact.test.d.ts +1 -0
  13. package/dist/__tests__/secrets-redact.test.js +318 -0
  14. package/dist/bridge/changesReport.js +10 -3
  15. package/dist/bridge/contextWriter.js +5 -1
  16. package/dist/bridge/envSaver.d.ts +63 -0
  17. package/dist/bridge/envSaver.js +256 -0
  18. package/dist/bridge/hiveopSettings.js +5 -0
  19. package/dist/bridge/launchSupervisor.js +4 -1
  20. package/dist/bridge/localContextWriter.js +6 -2
  21. package/dist/bridge/paneClient.d.ts +32 -0
  22. package/dist/bridge/paneClient.js +30 -0
  23. package/dist/bridge/processCpu.d.ts +29 -0
  24. package/dist/bridge/processCpu.js +160 -0
  25. package/dist/bridge/realPty.js +6 -1
  26. package/dist/bridge/relayClient.d.ts +9 -18
  27. package/dist/bridge/relayClient.js +32 -7
  28. package/dist/bridge/repoEnv.d.ts +34 -0
  29. package/dist/bridge/repoEnv.js +186 -0
  30. package/dist/bridge/server.js +69 -26
  31. package/dist/bridge/sessionManager.d.ts +10 -0
  32. package/dist/bridge/sessionManager.js +33 -8
  33. package/dist/bridge/workGraphPointer.d.ts +25 -0
  34. package/dist/bridge/workGraphPointer.js +36 -1
  35. package/dist/commands/bridge.js +8 -0
  36. package/dist/secrets/console.d.ts +4 -0
  37. package/dist/secrets/console.js +34 -0
  38. package/dist/secrets/redact.d.ts +79 -0
  39. package/dist/secrets/redact.js +637 -0
  40. package/hooks/opencode-plugin.mjs +55 -2
  41. package/hooks/pre-tool-use.js +96 -0
  42. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ export declare const SAVE_ENV_NAME_RE: RegExp;
2
+ export declare const MAX_SAVE_ENV_VALUE_BYTES = 4096;
3
+ export declare const MAX_SAVE_ENV_ENTRIES = 32;
4
+ export interface EnvEntry {
5
+ name: string;
6
+ value: string;
7
+ }
8
+ export type SaveEnvValidation = {
9
+ ok: true;
10
+ entries: EnvEntry[];
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ /** Validate the frame's entries. Error text is user-facing and never quotes a value. */
16
+ export declare function validateEnvEntries(raw: unknown): SaveEnvValidation;
17
+ /** Serialize one value so repoEnv.parseDotenv reads back exactly the same string. */
18
+ export declare function formatDotenvValue(value: string): string;
19
+ /**
20
+ * Upsert entries into dotenv text: the first line for a NAME is replaced in
21
+ * place, later duplicates of it are dropped, new names are appended. Every
22
+ * other line (comments, unrelated keys) is preserved byte for byte.
23
+ */
24
+ export declare function upsertDotenv(existing: string, entries: EnvEntry[]): string;
25
+ export interface EnvSaverDeps {
26
+ realpath: (p: string) => string;
27
+ /** null when the path does not exist. */
28
+ lstat: (p: string) => {
29
+ isFile(): boolean;
30
+ isDirectory(): boolean;
31
+ isSymbolicLink(): boolean;
32
+ } | null;
33
+ readFile: (p: string) => string;
34
+ /** Temp file (mode 0600) + rename, then chmod 0600. */
35
+ writeFileAtomic0600: (p: string, data: string) => void;
36
+ appendFile: (p: string, data: string) => void;
37
+ /** Exit code of `git <args>` run in `cwd`; 128 (or any non 0/1) when git is unusable. */
38
+ git: (args: string[], cwd: string) => Promise<number>;
39
+ }
40
+ export declare const realEnvSaverDeps: EnvSaverDeps;
41
+ export type SaveEnvResult = {
42
+ ok: true;
43
+ names: string[];
44
+ gitignoreAdded: boolean;
45
+ } | {
46
+ ok: false;
47
+ error: string;
48
+ };
49
+ /** Write entries into `<root>/.env`. `root` is the session's cwd. */
50
+ export declare function saveEnvEntries(root: string, entries: EnvEntry[], deps?: EnvSaverDeps): Promise<SaveEnvResult>;
51
+ /**
52
+ * The whole `save-env` frame → `save-env-result` reply, shared verbatim by the
53
+ * local server and the relay client so the two lanes cannot drift. The reply
54
+ * carries names only — never a value.
55
+ */
56
+ export declare function handleSaveEnvFrame(frame: {
57
+ rid?: unknown;
58
+ sessionId?: unknown;
59
+ entries?: unknown;
60
+ }, sessions: {
61
+ list(): string[];
62
+ cwdOf(id: string): string | undefined;
63
+ }, deps?: EnvSaverDeps): Promise<Record<string, unknown>>;
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.realEnvSaverDeps = exports.MAX_SAVE_ENV_ENTRIES = exports.MAX_SAVE_ENV_VALUE_BYTES = exports.SAVE_ENV_NAME_RE = void 0;
37
+ exports.validateEnvEntries = validateEnvEntries;
38
+ exports.formatDotenvValue = formatDotenvValue;
39
+ exports.upsertDotenv = upsertDotenv;
40
+ exports.saveEnvEntries = saveEnvEntries;
41
+ exports.handleSaveEnvFrame = handleSaveEnvFrame;
42
+ /**
43
+ * Paste a key → saved to .env (2026-10-17, owner-approved) — the WRITE half.
44
+ *
45
+ * The pane intercepts a paste that is a key, asks "Save to .env?", and on Save
46
+ * sends `{t:'save-env', rid, sessionId, entries:[{name,value}]}` over its own
47
+ * socket (local loopback, or the relay into the hosted container — same frame,
48
+ * same handler, both lanes, like `attach-file`). This module writes
49
+ * `<session cwd>/.env`:
50
+ *
51
+ * - The folder comes from the SESSION (cwdOf), never the frame. A session
52
+ * with no project folder is refused — never falls back to $HOME.
53
+ * - `.env` that git TRACKS is refused (writing a key into it would commit it).
54
+ * - `.env` is added to `.gitignore` first when git does not already ignore it.
55
+ * - NAME=value replaces an existing NAME line (no duplicates); others are kept.
56
+ * - File mode 0600, written via temp file + rename.
57
+ * - A symlinked `.env`/`.gitignore` is refused (no writes outside the root).
58
+ *
59
+ * Values are NEVER logged, echoed in a reply, or kept in memory past the write.
60
+ */
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ const crypto = __importStar(require("crypto"));
64
+ const child_process_1 = require("child_process");
65
+ const repoEnv_1 = require("./repoEnv");
66
+ exports.SAVE_ENV_NAME_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
67
+ exports.MAX_SAVE_ENV_VALUE_BYTES = 4096;
68
+ exports.MAX_SAVE_ENV_ENTRIES = 32;
69
+ /** Validate the frame's entries. Error text is user-facing and never quotes a value. */
70
+ function validateEnvEntries(raw) {
71
+ if (!Array.isArray(raw) || raw.length === 0)
72
+ return { ok: false, error: 'Nothing to save' };
73
+ if (raw.length > exports.MAX_SAVE_ENV_ENTRIES)
74
+ return { ok: false, error: 'Too many keys' };
75
+ const byName = new Map();
76
+ for (const e of raw) {
77
+ const name = e?.name;
78
+ const value = e?.value;
79
+ if (typeof name !== 'string' || !exports.SAVE_ENV_NAME_RE.test(name))
80
+ return { ok: false, error: 'Invalid name' };
81
+ if ((0, repoEnv_1.isHiveOpOwnedEnvName)(name))
82
+ return { ok: false, error: `${name} is reserved` };
83
+ if (typeof value !== 'string' || value.length === 0)
84
+ return { ok: false, error: `${name}: empty value` };
85
+ if (/[\r\n\0]/.test(value))
86
+ return { ok: false, error: `${name}: value has a line break` };
87
+ if (Buffer.byteLength(value, 'utf8') > exports.MAX_SAVE_ENV_VALUE_BYTES)
88
+ return { ok: false, error: `${name}: value too long` };
89
+ byName.set(name, value);
90
+ }
91
+ return { ok: true, entries: [...byName].map(([name, value]) => ({ name, value })) };
92
+ }
93
+ /** Serialize one value so repoEnv.parseDotenv reads back exactly the same string. */
94
+ function formatDotenvValue(value) {
95
+ if (/^[A-Za-z0-9_\-./:+=@,~%^]+$/.test(value))
96
+ return value;
97
+ if (!value.includes("'"))
98
+ return `'${value}'`;
99
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
100
+ }
101
+ function lineName(line) {
102
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
103
+ return m ? m[1] : null;
104
+ }
105
+ /**
106
+ * Upsert entries into dotenv text: the first line for a NAME is replaced in
107
+ * place, later duplicates of it are dropped, new names are appended. Every
108
+ * other line (comments, unrelated keys) is preserved byte for byte.
109
+ */
110
+ function upsertDotenv(existing, entries) {
111
+ const pending = new Map(entries.map((e) => [e.name, e.value]));
112
+ const written = new Set();
113
+ const lines = existing.length > 0 ? existing.split('\n') : [];
114
+ // A trailing newline yields one empty final element — keep it out of the loop.
115
+ const hadTrailingNewline = existing.endsWith('\n');
116
+ if (hadTrailingNewline)
117
+ lines.pop();
118
+ const out = [];
119
+ for (const line of lines) {
120
+ const name = lineName(line);
121
+ if (name && pending.has(name)) {
122
+ if (written.has(name))
123
+ continue; // a duplicate — never keep two
124
+ out.push(`${name}=${formatDotenvValue(pending.get(name))}`);
125
+ written.add(name);
126
+ continue;
127
+ }
128
+ out.push(line);
129
+ }
130
+ for (const [name, value] of pending) {
131
+ if (!written.has(name))
132
+ out.push(`${name}=${formatDotenvValue(value)}`);
133
+ }
134
+ return out.join('\n') + '\n';
135
+ }
136
+ exports.realEnvSaverDeps = {
137
+ realpath: (p) => fs.realpathSync(p),
138
+ lstat: (p) => {
139
+ try {
140
+ return fs.lstatSync(p);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ },
146
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
147
+ writeFileAtomic0600: (p, data) => {
148
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
149
+ fs.writeFileSync(tmp, data, { mode: 0o600 });
150
+ try {
151
+ fs.chmodSync(tmp, 0o600);
152
+ fs.renameSync(tmp, p);
153
+ }
154
+ catch (err) {
155
+ try {
156
+ fs.unlinkSync(tmp);
157
+ }
158
+ catch { /* best effort */ }
159
+ throw err;
160
+ }
161
+ fs.chmodSync(p, 0o600);
162
+ },
163
+ appendFile: (p, data) => fs.appendFileSync(p, data),
164
+ git: (args, cwd) => new Promise((resolve) => {
165
+ (0, child_process_1.execFile)('git', args, { cwd, timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err) => {
166
+ if (!err)
167
+ return resolve(0);
168
+ const code = err.code;
169
+ resolve(typeof code === 'number' ? code : 128);
170
+ });
171
+ }),
172
+ };
173
+ /** Write entries into `<root>/.env`. `root` is the session's cwd. */
174
+ async function saveEnvEntries(root, entries, deps = exports.realEnvSaverDeps) {
175
+ let realRoot;
176
+ try {
177
+ realRoot = deps.realpath(root);
178
+ }
179
+ catch {
180
+ return { ok: false, error: 'Project folder not found' };
181
+ }
182
+ const rootStat = deps.lstat(realRoot);
183
+ if (!rootStat || !rootStat.isDirectory())
184
+ return { ok: false, error: 'Project folder not found' };
185
+ const envPath = path.join(realRoot, repoEnv_1.DOTENV_FILENAME);
186
+ const ignorePath = path.join(realRoot, '.gitignore');
187
+ // Containment: both files are direct children of the resolved root, and
188
+ // neither may be a symlink (which could point anywhere).
189
+ if (path.dirname(envPath) !== realRoot || path.dirname(ignorePath) !== realRoot) {
190
+ return { ok: false, error: 'Bad project folder' };
191
+ }
192
+ const envStat = deps.lstat(envPath);
193
+ if (envStat && (envStat.isSymbolicLink() || !envStat.isFile()))
194
+ return { ok: false, error: '.env is not a plain file' };
195
+ const ignoreStat = deps.lstat(ignorePath);
196
+ if (ignoreStat && (ignoreStat.isSymbolicLink() || !ignoreStat.isFile()))
197
+ return { ok: false, error: '.gitignore is not a plain file' };
198
+ // Tracked .env → refuse: the key would be committed with the next commit.
199
+ const tracked = await deps.git(['ls-files', '--error-unmatch', '--', repoEnv_1.DOTENV_FILENAME], realRoot);
200
+ if (tracked === 0)
201
+ return { ok: false, error: '.env is tracked by git' };
202
+ // Ignore it BEFORE the key lands, so it is never briefly committable.
203
+ // check-ignore: 0 = ignored, 1 = not ignored, 128 = not a repo / error.
204
+ let gitignoreAdded = false;
205
+ try {
206
+ const ignored = await deps.git(['check-ignore', '-q', '--', repoEnv_1.DOTENV_FILENAME], realRoot);
207
+ if (ignored !== 0) {
208
+ const current = ignoreStat ? deps.readFile(ignorePath) : '';
209
+ const sep = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
210
+ deps.appendFile(ignorePath, `${sep}${repoEnv_1.DOTENV_FILENAME}\n`);
211
+ gitignoreAdded = true;
212
+ }
213
+ }
214
+ catch {
215
+ return { ok: false, error: "Couldn't update .gitignore" };
216
+ }
217
+ try {
218
+ const existing = envStat ? deps.readFile(envPath) : '';
219
+ deps.writeFileAtomic0600(envPath, upsertDotenv(existing, entries));
220
+ }
221
+ catch {
222
+ return { ok: false, error: "Couldn't write .env" };
223
+ }
224
+ return { ok: true, names: entries.map((e) => e.name), gitignoreAdded };
225
+ }
226
+ /**
227
+ * The whole `save-env` frame → `save-env-result` reply, shared verbatim by the
228
+ * local server and the relay client so the two lanes cannot drift. The reply
229
+ * carries names only — never a value.
230
+ */
231
+ async function handleSaveEnvFrame(frame, sessions, deps = exports.realEnvSaverDeps) {
232
+ const head = {
233
+ t: 'save-env-result',
234
+ ...(typeof frame.rid === 'string' ? { rid: frame.rid } : {}),
235
+ ...(typeof frame.sessionId === 'string' ? { sessionId: frame.sessionId } : {}),
236
+ };
237
+ const sessionId = frame.sessionId;
238
+ if (typeof sessionId !== 'string' || !sessions.list().includes(sessionId)) {
239
+ return { ...head, ok: false, error: 'Terminal session not found' };
240
+ }
241
+ const root = sessions.cwdOf(sessionId);
242
+ if (!root)
243
+ return { ...head, ok: false, error: 'No project folder' };
244
+ const valid = validateEnvEntries(frame.entries);
245
+ if (!valid.ok)
246
+ return { ...head, ok: false, error: valid.error };
247
+ try {
248
+ const result = await saveEnvEntries(root, valid.entries, deps);
249
+ return result.ok
250
+ ? { ...head, ok: true, names: result.names, gitignoreAdded: result.gitignoreAdded }
251
+ : { ...head, ok: false, error: result.error };
252
+ }
253
+ catch {
254
+ return { ...head, ok: false, error: "Couldn't save key" };
255
+ }
256
+ }
@@ -81,6 +81,11 @@ function hookCommand(hooksDir, file) {
81
81
  function buildHiveOpSettingsContent(hooksDir) {
82
82
  const obj = {
83
83
  hooks: {
84
+ // Secret guard (2026-10-17): blocks a Bash command carrying a literal
85
+ // secret and tells the agent to reference it as $NAME instead.
86
+ PreToolUse: [
87
+ { matcher: 'Bash', hooks: [{ type: 'command', command: hookCommand(hooksDir, 'pre-tool-use.js') }] },
88
+ ],
84
89
  PostToolUse: [
85
90
  {
86
91
  matcher: 'Read|Edit|Write|Update|Create|MultiEdit|NotebookEdit|Bash',
@@ -6,6 +6,7 @@ exports.firstMeaningfulLine = firstMeaningfulLine;
6
6
  exports.retryArgsFor = retryArgsFor;
7
7
  exports.friendlyStartFailure = friendlyStartFailure;
8
8
  exports.superviseLaunch = superviseLaunch;
9
+ const redact_1 = require("../secrets/redact");
9
10
  /** How long after launch an exit still counts as "couldn't start". */
10
11
  exports.DEFAULT_LAUNCH_WINDOW_MS = 10_000;
11
12
  /** Cap on how much early output is kept for matching/logging. */
@@ -127,7 +128,9 @@ function superviseLaunch(factory, spawnOpts, opts = {}) {
127
128
  return;
128
129
  }
129
130
  const raw = stripAnsi(captured).trim();
130
- log(`[bridge] claude exited at startup (code ${code}, args: ${args.join(' ')}): ${raw || '(no output)'}`);
131
+ // The captured startup output can hold anything the shell printed, so it
132
+ // is masked before it reaches bridge.log (secret masking, 2026-10-17).
133
+ log((0, redact_1.redact)(`[bridge] claude exited at startup (code ${code}, args: ${args.join(' ')}): ${raw || '(no output)'}`));
131
134
  const nextArgs = retried ? null : retryArgsFor(args, captured);
132
135
  if (nextArgs) {
133
136
  retried = true;
@@ -97,14 +97,18 @@ function writeLocalContext(input, deps) {
97
97
  const browser = (0, workGraphPointer_1.browserSection)(input);
98
98
  if (browser)
99
99
  pointers.push(browser);
100
+ const keys = (0, workGraphPointer_1.keysSection)(input);
101
+ if (keys)
102
+ pointers.push(keys);
103
+ const sections = (0, workGraphPointer_1.withSecretsSection)(pointers);
100
104
  // wmr-07 — the whole local file is bridge-owned, so "preserve the pushed
101
105
  // content" means keeping what is already on disk below the pointer fence.
102
106
  const existingBody = existed ? deps.readFile(contextPath) : '';
103
107
  const fileContent = input.pointersOnly
104
108
  ? splitPointers(existingBody).rest
105
109
  : (input.content ?? '').trim();
106
- const block = pointers.length > 0
107
- ? `${contextWriter_1.POINTERS_START_MARKER}\n${pointers.join('\n\n')}\n${contextWriter_1.POINTERS_END_MARKER}`
110
+ const block = sections.length > 0
111
+ ? `${contextWriter_1.POINTERS_START_MARKER}\n${sections.join('\n\n')}\n${contextWriter_1.POINTERS_END_MARKER}`
108
112
  : '';
109
113
  const content = [block, fileContent].filter((part) => part.length > 0).join('\n\n');
110
114
  const isEmpty = content.trim().length === 0;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Who is attaching a terminal pane — so last-attach-wins can tell a real
3
+ * second viewer from the same pane coming back.
4
+ *
5
+ * Prod 2026-10-17: panes showed "Session attached elsewhere" with nobody
6
+ * else attached. On this (loopback) lane a second mount of the same agent in
7
+ * one page, or a pane re-attaching on its own socket, got the same takeover a
8
+ * second device does. The arena sends three ids with each open/attach (the
9
+ * backend relay's pane-client.ts is the hosted twin of this file):
10
+ *
11
+ * browser — one per browser profile (localStorage)
12
+ * tab — one per page load
13
+ * mount — one per terminal pane instance, stable across its reconnects
14
+ *
15
+ * All optional: an old client sends none and gets today's plain takeover.
16
+ */
17
+ export interface PaneClient {
18
+ browser?: string;
19
+ tab?: string;
20
+ mount?: string;
21
+ }
22
+ /** Where the NEW attachment is, from the superseded one's point of view.
23
+ * 'self' = the same pane instance redialing: evict the ghost silently. */
24
+ export type TakeoverWhere = 'self' | 'page' | 'tab' | 'device';
25
+ /** Accept only short opaque ids; anything else is treated as absent. */
26
+ export declare function parsePaneClient(raw: unknown): PaneClient | undefined;
27
+ /**
28
+ * Compare the superseded pane's identity with the new one's. Undefined when
29
+ * either side did not say who it is (old client) — the caller keeps the
30
+ * plain takeover it always sent.
31
+ */
32
+ export declare function takeoverWhere(prev: PaneClient | undefined, next: PaneClient | undefined): TakeoverWhere | undefined;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parsePaneClient = parsePaneClient;
4
+ exports.takeoverWhere = takeoverWhere;
5
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
6
+ /** Accept only short opaque ids; anything else is treated as absent. */
7
+ function parsePaneClient(raw) {
8
+ if (!raw || typeof raw !== 'object')
9
+ return undefined;
10
+ const r = raw;
11
+ const pick = (v) => (typeof v === 'string' && ID.test(v) ? v : undefined);
12
+ const out = { browser: pick(r.browser), tab: pick(r.tab), mount: pick(r.mount) };
13
+ return out.browser || out.tab || out.mount ? out : undefined;
14
+ }
15
+ /**
16
+ * Compare the superseded pane's identity with the new one's. Undefined when
17
+ * either side did not say who it is (old client) — the caller keeps the
18
+ * plain takeover it always sent.
19
+ */
20
+ function takeoverWhere(prev, next) {
21
+ if (!prev || !next)
22
+ return undefined;
23
+ if (prev.mount && next.mount && prev.mount === next.mount)
24
+ return 'self';
25
+ if (prev.tab && next.tab && prev.tab === next.tab)
26
+ return 'page';
27
+ if (prev.browser && next.browser)
28
+ return prev.browser === next.browser ? 'tab' : 'device';
29
+ return undefined;
30
+ }
@@ -0,0 +1,29 @@
1
+ export interface ProcEntry {
2
+ ppid: number;
3
+ /** utime + stime + cutime + cstime, in clock ticks. */
4
+ ticks: number;
5
+ }
6
+ /** Parse one /proc/<pid>/stat line. The command name sits in parentheses and
7
+ * may itself contain spaces or parentheses, so split after the LAST ')'. */
8
+ export declare function parseProcStat(line: string): ProcEntry | null;
9
+ export declare function readProcTable(procRoot?: string): Map<number, ProcEntry>;
10
+ /** Total ticks of `root` and every live descendant. */
11
+ export declare function treeTicks(table: Map<number, ProcEntry>, root: number): number;
12
+ export interface CpuBusyTrackerOptions {
13
+ readTable?: () => Map<number, ProcEntry>;
14
+ now?: () => number;
15
+ /** Clock ticks per second (Linux USER_HZ — 100 on every mainstream kernel). */
16
+ clkTck?: number;
17
+ /** Busy at or above this share of one core, averaged since the last sample. */
18
+ thresholdPct?: number;
19
+ }
20
+ export interface CpuBusyTracker {
21
+ /** Session ids whose tree used ≥ threshold CPU since the previous sample.
22
+ * A session seen for the first time only sets its baseline. */
23
+ sample(sessions: Array<{
24
+ id: string;
25
+ pid: number | undefined;
26
+ }>): string[];
27
+ }
28
+ export declare const DEFAULT_BUSY_CPU_PCT = 20;
29
+ export declare function createCpuBusyTracker(opts?: CpuBusyTrackerOptions): CpuBusyTracker | null;
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_BUSY_CPU_PCT = void 0;
37
+ exports.parseProcStat = parseProcStat;
38
+ exports.readProcTable = readProcTable;
39
+ exports.treeTicks = treeTicks;
40
+ exports.createCpuBusyTracker = createCpuBusyTracker;
41
+ /**
42
+ * Activity-based sleep — which PTY sessions are doing work nobody can see?
43
+ *
44
+ * The backend suspends an idle box (HT_ACTIVITY_IDLE=1). Output and keystrokes
45
+ * already count as activity; this covers the silent rest: a build, a test run
46
+ * or an install a backgrounded shell runs while the agent sits at its prompt.
47
+ * We measure CPU, not "is a child process alive", on purpose: an idle dev
48
+ * server is alive forever and must not hold a box awake, but one actually
49
+ * serving requests burns CPU and does.
50
+ *
51
+ * Linux only (reads /proc). Elsewhere `createCpuBusyTracker` returns null and
52
+ * the `sessions` frame carries no `busy` field, which the backend reads as
53
+ * unknown — never as busy.
54
+ *
55
+ * A session's cost is its whole process tree: utime+stime of every live
56
+ * process under the PTY, plus cutime+cstime, which already holds the CPU of
57
+ * children that have exited and been reaped (an install's short-lived compiler
58
+ * runs). A reaped intermediate parent takes its children's time with it; the
59
+ * delta is clamped at 0, so that can only under-report.
60
+ */
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ /** Parse one /proc/<pid>/stat line. The command name sits in parentheses and
64
+ * may itself contain spaces or parentheses, so split after the LAST ')'. */
65
+ function parseProcStat(line) {
66
+ const close = line.lastIndexOf(')');
67
+ if (close < 0)
68
+ return null;
69
+ const f = line.slice(close + 2).trim().split(/\s+/);
70
+ // f[0]=state f[1]=ppid … f[11]=utime f[12]=stime f[13]=cutime f[14]=cstime
71
+ if (f.length < 15)
72
+ return null;
73
+ const nums = [f[1], f[11], f[12], f[13], f[14]].map(Number);
74
+ if (nums.some((n) => !Number.isFinite(n)))
75
+ return null;
76
+ return { ppid: nums[0], ticks: nums[1] + nums[2] + nums[3] + nums[4] };
77
+ }
78
+ function readProcTable(procRoot = '/proc') {
79
+ const table = new Map();
80
+ let names;
81
+ try {
82
+ names = fs.readdirSync(procRoot);
83
+ }
84
+ catch {
85
+ return table;
86
+ }
87
+ for (const name of names) {
88
+ if (!/^\d+$/.test(name))
89
+ continue;
90
+ try {
91
+ const e = parseProcStat(fs.readFileSync(path.join(procRoot, name, 'stat'), 'utf8'));
92
+ if (e)
93
+ table.set(Number(name), e);
94
+ }
95
+ catch { /* exited between readdir and read */ }
96
+ }
97
+ return table;
98
+ }
99
+ /** Total ticks of `root` and every live descendant. */
100
+ function treeTicks(table, root) {
101
+ const children = new Map();
102
+ for (const [pid, e] of table) {
103
+ const list = children.get(e.ppid);
104
+ if (list)
105
+ list.push(pid);
106
+ else
107
+ children.set(e.ppid, [pid]);
108
+ }
109
+ let total = 0;
110
+ const stack = [root];
111
+ const seen = new Set();
112
+ while (stack.length) {
113
+ const pid = stack.pop();
114
+ if (seen.has(pid))
115
+ continue;
116
+ seen.add(pid);
117
+ const e = table.get(pid);
118
+ if (e)
119
+ total += e.ticks;
120
+ for (const c of children.get(pid) ?? [])
121
+ stack.push(c);
122
+ }
123
+ return total;
124
+ }
125
+ exports.DEFAULT_BUSY_CPU_PCT = 20;
126
+ function createCpuBusyTracker(opts = {}) {
127
+ const readTable = opts.readTable ?? (process.platform === 'linux' && fs.existsSync('/proc/self/stat') ? () => readProcTable() : null);
128
+ if (!readTable)
129
+ return null;
130
+ const now = opts.now ?? Date.now;
131
+ const clkTck = opts.clkTck ?? 100;
132
+ const thresholdPct = opts.thresholdPct ?? (Number(process.env.HIVEOP_BUSY_CPU_PCT) || exports.DEFAULT_BUSY_CPU_PCT);
133
+ const last = new Map();
134
+ return {
135
+ sample(sessions) {
136
+ const table = readTable();
137
+ const at = now();
138
+ const busy = [];
139
+ const live = new Set();
140
+ for (const { id, pid } of sessions) {
141
+ if (typeof pid !== 'number' || !table.has(pid))
142
+ continue;
143
+ live.add(id);
144
+ const ticks = treeTicks(table, pid);
145
+ const prev = last.get(id);
146
+ last.set(id, { pid, ticks, at });
147
+ if (!prev || prev.pid !== pid || at <= prev.at)
148
+ continue;
149
+ const cpuSeconds = Math.max(0, ticks - prev.ticks) / clkTck;
150
+ const pct = (cpuSeconds / ((at - prev.at) / 1000)) * 100;
151
+ if (pct >= thresholdPct)
152
+ busy.push(id);
153
+ }
154
+ for (const id of [...last.keys()])
155
+ if (!live.has(id))
156
+ last.delete(id);
157
+ return busy;
158
+ },
159
+ };
160
+ }
@@ -41,6 +41,7 @@ exports.realPtyFactory = realPtyFactory;
41
41
  */
42
42
  const os = __importStar(require("os"));
43
43
  const envSanitize_1 = require("./envSanitize");
44
+ const repoEnv_1 = require("./repoEnv");
44
45
  const fs = __importStar(require("fs"));
45
46
  const path = __importStar(require("path"));
46
47
  /**
@@ -86,7 +87,10 @@ function realPtyFactory() {
86
87
  // Sanitized: the launcher's Claude-session markers must not leak into
87
88
  // the agent (see envSanitize.ts — a bridge started from inside a Claude
88
89
  // Code session was silently disabling transcript saving in every agent).
89
- env: { ...(0, envSanitize_1.sanitizeInheritedEnv)(process.env), ...(env ?? {}) },
90
+ // Paste a key → .env: the session folder's `.env` is loaded too —
91
+ // above the inherited env, below the session's own (WENRWA_* etc.),
92
+ // with reserved names never taken from the file (repoEnv.ts).
93
+ env: (0, repoEnv_1.mergeSpawnEnv)((0, envSanitize_1.sanitizeInheritedEnv)(process.env), (0, repoEnv_1.loadRepoEnv)(cwd), env),
90
94
  });
91
95
  return {
92
96
  write: (data) => proc.write(data),
@@ -94,6 +98,7 @@ function realPtyFactory() {
94
98
  kill: () => proc.kill(),
95
99
  onData: (cb) => proc.onData(cb),
96
100
  onExit: (cb) => proc.onExit(({ exitCode }) => cb(exitCode)),
101
+ pid: typeof proc.pid === 'number' ? proc.pid : undefined,
97
102
  };
98
103
  },
99
104
  };