@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
@@ -1,24 +1,7 @@
1
- /**
2
- * ht-05 — Terminal Bridge relay mode (dial-out).
3
- *
4
- * Instead of listening on localhost for browser connections (server.ts),
5
- * this DIALS OUT to the platform relay over a single outbound wss
6
- * connection, authenticates with a per-container relay token, and serves
7
- * the same v1 frame protocol (see PROTOCOL.md) through the existing
8
- * SessionManager. Multi-session multiplexing is unchanged: every frame
9
- * already carries a sessionId, so one outbound socket serves every session
10
- * exactly like the local server serves them over N inbound sockets.
11
- *
12
- * PTYs live in the SessionManager, not the socket — a relay drop never
13
- * kills a session. This module owns only: dialing out, the auth frame,
14
- * frame routing through SessionManager, and reconnect-with-backoff. It
15
- * does no scrollback bookkeeping of its own (SessionManager already does
16
- * that) — on re-pair the pane sends `attach` again and gets a fresh
17
- * `replay`, exactly as it would against the local server.
18
- */
19
1
  import { type OpencodeLaunchInput } from './agentLaunch';
20
2
  import type { PreparedLaunch } from './opencodeBinary';
21
3
  import { SessionManager } from './sessionManager';
4
+ import type { CpuBusyTracker } from './processCpu';
22
5
  import { MemInfo } from './containerMemory';
23
6
  import { BrowserDriver } from './browserDriver';
24
7
  import { BrowserIdleTracker } from './browserIdle';
@@ -422,6 +405,14 @@ export interface RelayClientOpts {
422
405
  * — see cli/src/commands/bridge.ts's relay branch.
423
406
  */
424
407
  reportSessions?: boolean;
408
+ /**
409
+ * Activity-based sleep — add `busy` (ids whose process tree burned CPU
410
+ * since the previous report) to every `sessions` frame, so the backend does
411
+ * not suspend a box mid silent build. Rides the inventory report, so it
412
+ * needs reportSessions too. Absent/null (the default, and any non-Linux
413
+ * host) → the frame is byte-identical to before. See processCpu.ts.
414
+ */
415
+ cpuBusyTracker?: CpuBusyTracker | null;
425
416
  /**
426
417
  * tchp-02 — opt IN to per-session working-tree change detection (`changes`
427
418
  * frame). Mirrors reportSessions exactly: absent (the default) → no
@@ -56,6 +56,7 @@ exports.startRelayClient = startRelayClient;
56
56
  * that) — on re-pair the pane sends `attach` again and gets a fresh
57
57
  * `replay`, exactly as it would against the local server.
58
58
  */
59
+ const redact_1 = require("../secrets/redact");
59
60
  const agentLaunch_1 = require("./agentLaunch");
60
61
  const resumeDetector_1 = require("./resumeDetector");
61
62
  const fs = __importStar(require("fs"));
@@ -112,6 +113,7 @@ const diskPreflight_1 = require("./diskPreflight");
112
113
  // checkoutParkAndRemove.ts's doc comment for the full safety design.
113
114
  const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
114
115
  const attachmentWriter_1 = require("./attachmentWriter");
116
+ const envSaver_1 = require("./envSaver");
115
117
  const PROTOCOL_VERSION = 1;
116
118
  /**
117
119
  * fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
@@ -137,6 +139,8 @@ exports.SESSION_ADDRESSED_FRAMES = new Set([
137
139
  'browser-input', 'browser-frame-ack', 'browser-resize', 'browser-sleep',
138
140
  // Terminal attach writes next to a session — same consent boundary.
139
141
  'attach-file',
142
+ // Paste a key → .env writes into a session's folder — same boundary.
143
+ 'save-env',
140
144
  'browser-close', 'browser-reload',
141
145
  ]);
142
146
  /** cvt-07: what this bridge advertises in its auth frame. `browser-tabs` = a
@@ -592,7 +596,17 @@ function startRelayClient(opts) {
592
596
  // machine the platform is not supposed to see.
593
597
  if (opts.onlyRelayOwnedSessions)
594
598
  ids = ids.filter((id) => relayOwned.has(id));
595
- send(ws, { t: 'sessions', ids });
599
+ let busy;
600
+ if (opts.cpuBusyTracker) {
601
+ try {
602
+ const pidOf = opts.sessions.pidOf?.bind(opts.sessions);
603
+ busy = opts.cpuBusyTracker.sample(ids.map((id) => ({ id, pid: pidOf?.(id) })));
604
+ }
605
+ catch {
606
+ busy = undefined; // a failed sample must never drop the inventory
607
+ }
608
+ }
609
+ send(ws, busy ? { t: 'sessions', ids, busy } : { t: 'sessions', ids });
596
610
  }
597
611
  /**
598
612
  * tchp-02 — the ACTIVE sessions worth polling for working-tree changes:
@@ -1566,6 +1580,13 @@ function startRelayClient(opts) {
1566
1580
  // Terminal attach — shared verbatim with the local server.
1567
1581
  send(socket, (0, attachmentWriter_1.handleAttachFrame)(frame, opts.sessions));
1568
1582
  break;
1583
+ case 'save-env':
1584
+ // Paste a key → .env — shared verbatim with the local server.
1585
+ // Never logged: the frame carries the key itself.
1586
+ void (0, envSaver_1.handleSaveEnvFrame)(frame, opts.sessions)
1587
+ .then((reply) => send(socket, reply))
1588
+ .catch(() => send(socket, { t: 'save-env-result', rid: frame.rid, sessionId: frame.sessionId, ok: false, error: "Couldn't save key" }));
1589
+ break;
1569
1590
  case 'read-transcript': {
1570
1591
  // wte-10 (docs/prd/work-this-epic.md D8): read ONE Claude Code
1571
1592
  // agent's session transcript, live. Server-initiated (no pane,
@@ -1591,12 +1612,16 @@ function startRelayClient(opts) {
1591
1612
  break;
1592
1613
  }
1593
1614
  const result = opts.readTranscript({ workspaceId, agentId: frame.agentId, engine: frame.engine, opencodeSessionId: frame.opencodeSessionId });
1594
- send(socket, {
1595
- t: 'transcript-content',
1596
- requestId,
1597
- workspaceId,
1598
- ...(result.ok ? { ok: true, content: result.content } : { ok: false, reason: result.reason }),
1599
- });
1615
+ if (!result.ok) {
1616
+ send(socket, { t: 'transcript-content', requestId, workspaceId, ok: false, reason: result.reason });
1617
+ break;
1618
+ }
1619
+ // Secret masking (2026-10-17): the transcript holds every Bash
1620
+ // command and tool result verbatim — mask before it leaves. Sliced
1621
+ // so a multi-MB transcript never stalls live terminal frames.
1622
+ void (0, redact_1.redactInSlices)(result.content)
1623
+ .then((content) => send(socket, { t: 'transcript-content', requestId, workspaceId, ok: true, content }))
1624
+ .catch(() => send(socket, { t: 'transcript-content', requestId, workspaceId, ok: false, reason: 'transcript-unavailable' }));
1600
1625
  break;
1601
1626
  }
1602
1627
  case 'browser-open': {
@@ -0,0 +1,34 @@
1
+ export declare const DOTENV_FILENAME = ".env";
2
+ /** A larger file is not a hand-kept key file; refuse to read it into env. */
3
+ export declare const MAX_DOTENV_BYTES: number;
4
+ /**
5
+ * HiveOp's own plumbing — refused on SAVE as well (a pasted key can never be
6
+ * filed under a name the bridge owns).
7
+ */
8
+ export declare function isHiveOpOwnedEnvName(name: string): boolean;
9
+ /**
10
+ * True when a `.env` entry with this name is never LOADED into a spawn env.
11
+ * Saving one is still allowed unless HiveOp owns it (isHiveOpOwnedEnvName):
12
+ * e.g. ANTHROPIC_API_KEY may live in the user's .env for their own app, but
13
+ * it must not reach `claude` (it would switch it off the subscription).
14
+ */
15
+ export declare function isReservedEnvName(name: string): boolean;
16
+ /** Parse dotenv text. Later lines win. Malformed lines are skipped. */
17
+ export declare function parseDotenv(text: string): Record<string, string>;
18
+ /** Drop reserved names (and anything with a NUL — env can't carry one). */
19
+ export declare function filterRepoEnv(env: Record<string, string>): Record<string, string>;
20
+ export interface RepoEnvDeps {
21
+ lstat: (p: string) => {
22
+ isFile(): boolean;
23
+ size: number;
24
+ } | null;
25
+ readFile: (p: string) => string;
26
+ }
27
+ export declare const realRepoEnvDeps: RepoEnvDeps;
28
+ /**
29
+ * The usable env from `<cwd>/.env`, reserved names removed. `{}` when there is
30
+ * no cwd, no file, the file is a symlink/dir/too large, or it can't be read.
31
+ */
32
+ export declare function loadRepoEnv(cwd: string | undefined, deps?: RepoEnvDeps): Record<string, string>;
33
+ /** Spawn env: inherited < repo .env < session env. */
34
+ export declare function mergeSpawnEnv(inherited: Record<string, string | undefined>, repoEnv: Record<string, string>, sessionEnv: Record<string, string> | undefined): Record<string, string | undefined>;
@@ -0,0 +1,186 @@
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.realRepoEnvDeps = exports.MAX_DOTENV_BYTES = exports.DOTENV_FILENAME = void 0;
37
+ exports.isHiveOpOwnedEnvName = isHiveOpOwnedEnvName;
38
+ exports.isReservedEnvName = isReservedEnvName;
39
+ exports.parseDotenv = parseDotenv;
40
+ exports.filterRepoEnv = filterRepoEnv;
41
+ exports.loadRepoEnv = loadRepoEnv;
42
+ exports.mergeSpawnEnv = mergeSpawnEnv;
43
+ /**
44
+ * Paste a key → saved to .env (2026-10-17, owner-approved) — the READ half.
45
+ *
46
+ * The repo's own `.env` is the one place a user's keys live (a June decision
47
+ * removed server-side workspace secrets; nothing here may add one). When the
48
+ * bridge spawns a PTY it loads `<session cwd>/.env` into that PTY's env, so an
49
+ * agent started after a key was saved can use it as an ordinary env var.
50
+ *
51
+ * Precedence, lowest first:
52
+ * inherited process env < repo .env < the session's own env
53
+ * The session env carries HiveOp's WENRWA_* / HT_* / git credential stamps, so
54
+ * it always wins. On top of that, RESERVED names are never taken from `.env`
55
+ * at all — a repo file must not be able to steer the bridge's own plumbing
56
+ * (WENRWA_*), Claude Code's auth (ANTHROPIC_*, CLAUDE_* — a key saved as
57
+ * ANTHROPIC_API_KEY would otherwise switch `claude` off the subscription), or
58
+ * the process loader (PATH, NODE_OPTIONS, LD_PRELOAD, …).
59
+ *
60
+ * Deliberately a SIMPLE dotenv: `NAME=value` lines, optional `export `,
61
+ * single/double quotes, `#` comments. No `${VAR}` expansion, no multi-line
62
+ * values — what is in the file is exactly what the agent gets.
63
+ *
64
+ * Never logs a value. Never throws: any read problem means "no repo env".
65
+ */
66
+ const fs = __importStar(require("fs"));
67
+ const path = __importStar(require("path"));
68
+ exports.DOTENV_FILENAME = '.env';
69
+ /** A larger file is not a hand-kept key file; refuse to read it into env. */
70
+ exports.MAX_DOTENV_BYTES = 64 * 1024;
71
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
72
+ /** Exact names `.env` may never set. */
73
+ const RESERVED_NAMES = new Set([
74
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR',
75
+ 'NODE_OPTIONS', 'NODE_PATH', 'BASH_ENV', 'ENV', 'PROMPT_COMMAND', 'ZDOTDIR', 'IFS',
76
+ 'HT_AUTONOMOUS', 'MARKETPLACE_API_URL', 'SSH_AUTH_SOCK',
77
+ ]);
78
+ /** Name prefixes `.env` may never set. */
79
+ const RESERVED_PREFIXES = ['WENRWA_', 'HIVEOP_', 'ANTHROPIC_', 'CLAUDE_', 'LD_', 'DYLD_', 'GIT_', 'NPM_CONFIG_', 'XDG_'];
80
+ /**
81
+ * HiveOp's own plumbing — refused on SAVE as well (a pasted key can never be
82
+ * filed under a name the bridge owns).
83
+ */
84
+ function isHiveOpOwnedEnvName(name) {
85
+ const upper = name.toUpperCase();
86
+ return upper.startsWith('WENRWA_') || upper.startsWith('HIVEOP_') || upper === 'HT_AUTONOMOUS' || upper === 'MARKETPLACE_API_URL';
87
+ }
88
+ /**
89
+ * True when a `.env` entry with this name is never LOADED into a spawn env.
90
+ * Saving one is still allowed unless HiveOp owns it (isHiveOpOwnedEnvName):
91
+ * e.g. ANTHROPIC_API_KEY may live in the user's .env for their own app, but
92
+ * it must not reach `claude` (it would switch it off the subscription).
93
+ */
94
+ function isReservedEnvName(name) {
95
+ const upper = name.toUpperCase();
96
+ if (RESERVED_NAMES.has(upper))
97
+ return true;
98
+ return RESERVED_PREFIXES.some((p) => upper.startsWith(p));
99
+ }
100
+ function unquote(raw) {
101
+ const v = raw.trim();
102
+ if (v.length >= 2 && v[0] === "'") {
103
+ const end = v.indexOf("'", 1);
104
+ return end === -1 ? v.slice(1) : v.slice(1, end);
105
+ }
106
+ if (v.length >= 2 && v[0] === '"') {
107
+ let out = '';
108
+ for (let i = 1; i < v.length; i++) {
109
+ const c = v[i];
110
+ if (c === '\\' && i + 1 < v.length) {
111
+ const n = v[++i];
112
+ out += n === 'n' ? '\n' : n === 'r' ? '\r' : n === 't' ? '\t' : n;
113
+ continue;
114
+ }
115
+ if (c === '"')
116
+ return out;
117
+ out += c;
118
+ }
119
+ return out;
120
+ }
121
+ // Unquoted: an inline comment starts at ` #`.
122
+ const hash = v.search(/\s#/);
123
+ return (hash === -1 ? v : v.slice(0, hash)).trim();
124
+ }
125
+ /** Parse dotenv text. Later lines win. Malformed lines are skipped. */
126
+ function parseDotenv(text) {
127
+ const out = {};
128
+ for (const rawLine of text.split(/\r?\n/)) {
129
+ const line = rawLine.replace(/^/, '').trim();
130
+ if (!line || line.startsWith('#'))
131
+ continue;
132
+ const body = line.startsWith('export ') ? line.slice(7).trimStart() : line;
133
+ const eq = body.indexOf('=');
134
+ if (eq <= 0)
135
+ continue;
136
+ const name = body.slice(0, eq).trim();
137
+ if (!NAME_RE.test(name))
138
+ continue;
139
+ out[name] = unquote(body.slice(eq + 1));
140
+ }
141
+ return out;
142
+ }
143
+ /** Drop reserved names (and anything with a NUL — env can't carry one). */
144
+ function filterRepoEnv(env) {
145
+ const out = {};
146
+ for (const [k, v] of Object.entries(env)) {
147
+ if (isReservedEnvName(k) || v.includes('\0'))
148
+ continue;
149
+ out[k] = v;
150
+ }
151
+ return out;
152
+ }
153
+ exports.realRepoEnvDeps = {
154
+ lstat: (p) => {
155
+ try {
156
+ return fs.lstatSync(p);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ },
162
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
163
+ };
164
+ /**
165
+ * The usable env from `<cwd>/.env`, reserved names removed. `{}` when there is
166
+ * no cwd, no file, the file is a symlink/dir/too large, or it can't be read.
167
+ */
168
+ function loadRepoEnv(cwd, deps = exports.realRepoEnvDeps) {
169
+ if (!cwd)
170
+ return {};
171
+ try {
172
+ const p = path.join(cwd, exports.DOTENV_FILENAME);
173
+ const st = deps.lstat(p);
174
+ // lstat: a symlinked .env is not followed.
175
+ if (!st || !st.isFile() || st.size > exports.MAX_DOTENV_BYTES)
176
+ return {};
177
+ return filterRepoEnv(parseDotenv(deps.readFile(p)));
178
+ }
179
+ catch {
180
+ return {};
181
+ }
182
+ }
183
+ /** Spawn env: inherited < repo .env < session env. */
184
+ function mergeSpawnEnv(inherited, repoEnv, sessionEnv) {
185
+ return { ...inherited, ...filterRepoEnv(repoEnv), ...(sessionEnv ?? {}) };
186
+ }
@@ -63,7 +63,9 @@ const sessionAnchor_1 = require("./sessionAnchor");
63
63
  // the SAME activity records fcct-24's tail already parses below.
64
64
  const sessionTouchTracker_1 = require("./sessionTouchTracker");
65
65
  const attachmentWriter_1 = require("./attachmentWriter");
66
+ const envSaver_1 = require("./envSaver");
66
67
  const screenshotAttach_1 = require("./screenshotAttach");
68
+ const paneClient_1 = require("./paneClient");
67
69
  const PROTOCOL_VERSION = 1;
68
70
  function send(ws, frame) {
69
71
  if (ws.readyState === ws_1.WebSocket.OPEN)
@@ -393,6 +395,8 @@ function startBridgeServer(opts) {
393
395
  ...(opts.writeLocalFile ? ['local-file-write'] : []),
394
396
  // Terminal attach — this bridge answers `attach-file` frames.
395
397
  'terminal-attach',
398
+ // Paste a key → .env — this bridge answers `save-env` frames.
399
+ 'save-env',
396
400
  // Screenshot drop — answers `attach-screenshot` (macOS only).
397
401
  ...((opts.screenshotDeps ?? screenshotAttach_1.realScreenshotDeps).platform === 'darwin' ? ['attach-screenshot'] : []),
398
402
  ],
@@ -586,6 +590,11 @@ function startBridgeServer(opts) {
586
590
  });
587
591
  }, opts.changesDetectIntervalMs ?? 5_000);
588
592
  }
593
+ // Who holds each session right now (socket + the pane's own ids). Read by
594
+ // the SUPERSEDED attachment's sink when SessionManager fires `takeover`
595
+ // at it, so the takeover can say where the new attach is — and stay
596
+ // silent when it is this same socket or this same pane redialing.
597
+ const attachOwner = new Map();
589
598
  wss.on('connection', (ws, req) => {
590
599
  if (req.headers.origin !== opts.allowedOrigin) {
591
600
  ws.close(4403, 'origin not allowed');
@@ -605,29 +614,52 @@ function startBridgeServer(opts) {
605
614
  // SessionManager#attach before its first internal await, so takeover
606
615
  // ordering is unaffected — only the point at which `replay` is
607
616
  // available moves to a .then().
608
- const attachTo = (sessionId, size) => opts.sessions
609
- .attach(sessionId, (frame) => {
610
- if (frame.t === 'data') {
611
- // lws-08 — scan this session's live output for a dev-server-ready
612
- // banner BEFORE forwarding the data frame (order is cosmetic, but
613
- // this keeps the dev-server frame visible even if a caller stops
614
- // reading after the first message in a test).
615
- if (!devServerWatchers.has(sessionId))
616
- devServerWatchers.set(sessionId, (0, devServerDetect_1.createDevServerWatcher)());
617
- const matches = devServerWatchers.get(sessionId).feed(frame.data);
618
- for (const m of matches) {
619
- send(ws, { t: 'dev-server', sessionId, framework: m.framework, port: m.port, url: m.url });
617
+ const attachTo = (sessionId, size, rawClient) => {
618
+ const me = { ws, client: (0, paneClient_1.parsePaneClient)(rawClient) };
619
+ const prevOwner = attachOwner.get(sessionId);
620
+ // Set BEFORE attach: SessionManager fires the previous sink's takeover
621
+ // synchronously inside attach(), and that sink reads the new owner here.
622
+ attachOwner.set(sessionId, me);
623
+ return opts.sessions
624
+ .attach(sessionId, (frame) => {
625
+ if (frame.t === 'data') {
626
+ // lws-08 — scan this session's live output for a dev-server-ready
627
+ // banner BEFORE forwarding the data frame (order is cosmetic, but
628
+ // this keeps the dev-server frame visible even if a caller stops
629
+ // reading after the first message in a test).
630
+ if (!devServerWatchers.has(sessionId))
631
+ devServerWatchers.set(sessionId, (0, devServerDetect_1.createDevServerWatcher)());
632
+ const matches = devServerWatchers.get(sessionId).feed(frame.data);
633
+ for (const m of matches) {
634
+ send(ws, { t: 'dev-server', sessionId, framework: m.framework, port: m.port, url: m.url });
635
+ }
636
+ send(ws, { t: 'data', sessionId, data: frame.data });
620
637
  }
621
- send(ws, { t: 'data', sessionId, data: frame.data });
622
- }
623
- else if (frame.t === 'takeover') {
624
- send(ws, { t: 'takeover', sessionId });
625
- }
626
- }, size)
627
- .then((handle) => {
628
- handles.set(sessionId, handle);
629
- return handle;
630
- });
638
+ else if (frame.t === 'takeover') {
639
+ const next = attachOwner.get(sessionId);
640
+ // The same socket re-attaching is not a takeover of itself.
641
+ if (next && next !== me && next.ws === ws)
642
+ return;
643
+ const where = next && next !== me ? (0, paneClient_1.takeoverWhere)(me.client, next.client) : undefined;
644
+ // The same pane redialing: this socket is its ghost — no notice.
645
+ if (where === 'self')
646
+ return;
647
+ send(ws, where ? { t: 'takeover', sessionId, by: where } : { t: 'takeover', sessionId });
648
+ }
649
+ }, size)
650
+ .then((handle) => {
651
+ handles.set(sessionId, handle);
652
+ return handle;
653
+ }, (err) => {
654
+ if (attachOwner.get(sessionId) === me) {
655
+ if (prevOwner)
656
+ attachOwner.set(sessionId, prevOwner);
657
+ else
658
+ attachOwner.delete(sessionId);
659
+ }
660
+ throw err;
661
+ });
662
+ };
631
663
  ws.on('message', (raw) => {
632
664
  let frame;
633
665
  try {
@@ -774,7 +806,7 @@ function startBridgeServer(opts) {
774
806
  send(ws, { t: 'error', message });
775
807
  break;
776
808
  }
777
- attachTo(sessionId).then(() => {
809
+ attachTo(sessionId, undefined, frame.client).then(() => {
778
810
  send(ws, { t: 'opened', sessionId, ...(cwd ? { cwd } : {}) });
779
811
  send(ws, {
780
812
  t: 'data',
@@ -821,7 +853,7 @@ function startBridgeServer(opts) {
821
853
  console.error('[bridge] local context pointer refresh failed (non-fatal):', err);
822
854
  });
823
855
  }
824
- attachTo(sessionId).then(() => {
856
+ attachTo(sessionId, undefined, frame.client).then(() => {
825
857
  send(ws, {
826
858
  t: 'opened',
827
859
  sessionId,
@@ -853,7 +885,7 @@ function startBridgeServer(opts) {
853
885
  send(ws, { t: 'error', message });
854
886
  return;
855
887
  }
856
- attachTo(sessionId)
888
+ attachTo(sessionId, undefined, frame.client)
857
889
  .then(() => {
858
890
  send(ws, { t: 'opened', sessionId, ...(cwd ? { cwd } : {}), ...(note ? { note } : {}) });
859
891
  })
@@ -875,7 +907,7 @@ function startBridgeServer(opts) {
875
907
  case 'attach': {
876
908
  // lws-07 (ADR-0033 port): every replay this bridge produces is a
877
909
  // serialized TermSnapshot — the flag is unconditional here.
878
- attachTo(frame.sessionId, (0, sessionManager_1.validSize)(frame.cols, frame.rows))
910
+ attachTo(frame.sessionId, (0, sessionManager_1.validSize)(frame.cols, frame.rows), frame.client)
879
911
  .then((handle) => {
880
912
  // Carry the session's cwd on REATTACH. `opened` announces it
881
913
  // only at creation, so a pane that reloads came back with no
@@ -915,6 +947,7 @@ function startBridgeServer(opts) {
915
947
  // once closed, cwdOf() may no longer resolve it.
916
948
  const closedCwd = opts.sessions.cwdOf(frame.sessionId);
917
949
  handles.delete(frame.sessionId);
950
+ attachOwner.delete(frame.sessionId);
918
951
  opts.sessions.close(frame.sessionId);
919
952
  devServerWatchers.delete(frame.sessionId);
920
953
  changesWorkspaceOf.delete(frame.sessionId); // tchp-12 — no orphan polling for a closed session
@@ -966,6 +999,13 @@ function startBridgeServer(opts) {
966
999
  .then((reply) => send(ws, reply))
967
1000
  .catch(() => send(ws, { t: 'file-attached', rid: frame.rid, sessionId: frame.sessionId, ok: false, reason: 'error' }));
968
1001
  break;
1002
+ case 'save-env':
1003
+ // Paste a key → .env — a FILE write in the session's folder, never
1004
+ // PTY input. Shared handler with relayClient.ts (both lanes).
1005
+ void (0, envSaver_1.handleSaveEnvFrame)(frame, opts.sessions)
1006
+ .then((reply) => send(ws, reply))
1007
+ .catch(() => send(ws, { t: 'save-env-result', rid: frame.rid, sessionId: frame.sessionId, ok: false, error: "Couldn't save key" }));
1008
+ break;
969
1009
  default:
970
1010
  send(ws, { t: 'error', message: 'unknown frame type' });
971
1011
  }
@@ -978,6 +1018,9 @@ function startBridgeServer(opts) {
978
1018
  for (const handle of handles.values())
979
1019
  handle.detach();
980
1020
  handles.clear();
1021
+ for (const [sid, o] of attachOwner)
1022
+ if (o.ws === ws)
1023
+ attachOwner.delete(sid);
981
1024
  });
982
1025
  });
983
1026
  return new Promise((resolve, reject) => {
@@ -7,6 +7,9 @@ export interface PtyLike {
7
7
  kill(): void;
8
8
  onData(cb: (data: string) => void): void;
9
9
  onExit(cb: (code: number) => void): void;
10
+ /** OS process id of the PTY's child, when the factory knows it (node-pty
11
+ * does). Read by the CPU-busy report (processCpu.ts). */
12
+ readonly pid?: number;
10
13
  }
11
14
  export interface PtyFactory {
12
15
  /**
@@ -98,6 +101,9 @@ export interface SessionManagerOptions {
98
101
  * gate. Off (laptop) → only an explicit `command: 'claude'` is gated and
99
102
  * plain shells are never delayed. */
100
103
  everyOpenIsAgentLaunch?: boolean;
104
+ /** Secret masking: idle time before a held output tail is released
105
+ * (redact.ts). Default 30ms; tests pass 0. */
106
+ redactIdleMs?: number;
101
107
  }
102
108
  /**
103
109
  * lws-07 (ADR-0033 port): validates a client-supplied size pair for the
@@ -124,6 +130,7 @@ export declare class SessionManager {
124
130
  private launchSupervisor;
125
131
  private launchGate;
126
132
  private everyOpenIsAgentLaunch;
133
+ private redactIdleMs;
127
134
  /** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
128
135
  * or a serialize-wedge whose recovery retry ALSO timed out) since this
129
136
  * SessionManager was created. Surfaced via attachFailures() into the
@@ -194,6 +201,9 @@ export declare class SessionManager {
194
201
  private recoverSerializeWedge;
195
202
  close(sessionId: string): void;
196
203
  list(): string[];
204
+ /** The PTY child's pid, or undefined (unknown session, or a factory that
205
+ * does not expose one). */
206
+ pidOf(sessionId: string): number | undefined;
197
207
  /**
198
208
  * fcct-34 — READ-ONLY look at what a session currently shows.
199
209
  *
@@ -23,6 +23,7 @@ const crypto_1 = __importDefault(require("crypto"));
23
23
  const termSnapshot_1 = require("./termSnapshot");
24
24
  const launchSupervisor_1 = require("./launchSupervisor");
25
25
  const launchGate_1 = require("./launchGate");
26
+ const redact_1 = require("../secrets/redact");
26
27
  /** Default settle window (lws-07): after a resize-on-attach, how long we give
27
28
  * the PTY's SIGWINCH-triggered repaint to land in the snapshot before
28
29
  * serializing. Small — this only needs to cover one render tick, not a
@@ -78,6 +79,7 @@ class SessionManager {
78
79
  launchSupervisor;
79
80
  launchGate;
80
81
  everyOpenIsAgentLaunch;
82
+ redactIdleMs;
81
83
  /** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
82
84
  * or a serialize-wedge whose recovery retry ALSO timed out) since this
83
85
  * SessionManager was created. Surfaced via attachFailures() into the
@@ -97,6 +99,7 @@ class SessionManager {
97
99
  this.launchSupervisor = opts.launchSupervisor ?? {};
98
100
  this.launchGate = opts.launchGate === false ? null : new launchGate_1.LaunchGate(opts.launchGate ?? {});
99
101
  this.everyOpenIsAgentLaunch = opts.everyOpenIsAgentLaunch ?? false;
102
+ this.redactIdleMs = opts.redactIdleMs;
100
103
  }
101
104
  /** twr-16: see attachFailureCount. */
102
105
  attachFailures() {
@@ -140,6 +143,7 @@ class SessionManager {
140
143
  const id = opts.id ?? crypto_1.default.randomBytes(8).toString('hex');
141
144
  const prior = this.sessions.get(id);
142
145
  if (prior) {
146
+ prior.redactor.dispose();
143
147
  prior.pty.kill();
144
148
  prior.snapshot.dispose();
145
149
  this.sessions.delete(id);
@@ -164,7 +168,20 @@ class SessionManager {
164
168
  ? this.launchGate.launch(spawnOne, { cols: opts.cols, rows: opts.rows })
165
169
  : spawnOne({ cols: opts.cols, rows: opts.rows });
166
170
  const snapshot = this.snapshotFactory(opts.cols, opts.rows, { scrollback: this.snapshotScrollback });
167
- const session = {
171
+ // eslint-disable-next-line prefer-const
172
+ let session;
173
+ // Masked output fans out to the snapshot and the live sink. `session` is
174
+ // read at emit time, so a rebuilt snapshot (twr-16) or a new sink is used.
175
+ const redactor = (0, redact_1.createStreamRedactor)({
176
+ idleMs: this.redactIdleMs,
177
+ emit: (data) => {
178
+ // Fire-and-forget: serialize() flushes the snapshot's internal write
179
+ // queue before reading it back, so no caller needs to await this.
180
+ session.snapshot.write(data).catch(() => { });
181
+ session.sink?.({ t: 'data', data });
182
+ },
183
+ });
184
+ session = {
168
185
  pty,
169
186
  snapshot,
170
187
  cols: opts.cols,
@@ -174,17 +191,19 @@ class SessionManager {
174
191
  exited: false,
175
192
  cwd: opts.cwd,
176
193
  engine: isOpencode ? 'opencode' : isClaude ? 'claude' : undefined,
194
+ redactor,
177
195
  };
178
196
  pty.onData((data) => {
179
- // Single write path to the PTY itself is untouched (ADR-0028) — this is
180
- // purely a second, read-only consumer of the same output bytes the live
181
- // sink already gets. Fire-and-forget: serialize() flushes the snapshot's
182
- // internal write queue before reading it back, so no caller needs to
183
- // await this.
184
- session.snapshot.write(data).catch(() => { });
185
- session.sink?.({ t: 'data', data });
197
+ // Single write path to the PTY itself is untouched (ADR-0028) — output
198
+ // is only READ here, masked, and fanned out by the redactor above.
199
+ session.redactor.push(data);
186
200
  });
187
201
  pty.onExit((code) => {
202
+ // Release any held tail before the session stops listening.
203
+ try {
204
+ session.redactor.dispose();
205
+ }
206
+ catch { /* never break teardown */ }
188
207
  session.exited = true;
189
208
  session.sink = null;
190
209
  session.snapshot.dispose();
@@ -340,6 +359,7 @@ class SessionManager {
340
359
  const session = this.sessions.get(sessionId);
341
360
  if (!session)
342
361
  return;
362
+ session.redactor.dispose();
343
363
  session.pty.kill();
344
364
  session.snapshot.dispose();
345
365
  this.sessions.delete(sessionId);
@@ -347,6 +367,11 @@ class SessionManager {
347
367
  list() {
348
368
  return [...this.sessions.keys()];
349
369
  }
370
+ /** The PTY child's pid, or undefined (unknown session, or a factory that
371
+ * does not expose one). */
372
+ pidOf(sessionId) {
373
+ return this.sessions.get(sessionId)?.pty.pid;
374
+ }
350
375
  /**
351
376
  * fcct-34 — READ-ONLY look at what a session currently shows.
352
377
  *