@karma42/agent 0.1.0-beta.2 → 0.1.0-beta.3

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/README.md CHANGED
@@ -1,14 +1,26 @@
1
1
  # Karma42 agent — current-session MCP connection
2
2
 
3
- Version `0.1.0-beta.2` adds a named project connection to your existing Claude Code or Codex conversation. The npm package bundles the local MCP bridge. It does not launch a coding model or change client approval/sandbox settings.
3
+ Version `0.1.0-beta.3` is the browser-pairing candidate. Publication and real-client acceptance are separate release checks. The bundled bridge adds a named connection to your existing Claude Code or Codex conversation; it does not launch another coding model or change client permissions.
4
4
 
5
- Get the project-specific prompt and a private code in **My agents**. Paste the prompt into your current agent conversation. Run `session-connect --project-url HTTPS_SERVICE/p/PROJECT_ID --provider claude|codex --agent-id AGENT_ID` in the current workspace; supply the code privately when prompted, never in arguments or chat. Review the authoritative project, repository, agent and workspace before saving. This setup does not check GitHub access, clone a repository or record model authentication as verified.
5
+ In **My agents**, choose an agent and copy its project-specific instructions. After your setup approval, your agent runs:
6
+
7
+ ```sh
8
+ npx --ignore-scripts @karma42/agent@0.1.0-beta.3 browser-connect --project-url HTTPS_SERVICE/p/PROJECT_ID --provider claude --agent-id AGENT_ID
9
+ ```
10
+
11
+ Use `--provider codex` in Codex. The current folder is the requested workspace; `--folder /absolute/workspace` selects another. Setup prints a browser link and request check, then exits immediately. Open the link, sign in with Google, match the request check to this session, and review the project, agent, requested folder and access before approving. The browser does not independently verify that local folder. Approval does not grant filesystem/GitHub permissions, start development or edit client settings. Native client trust prompts remain yours.
12
+
13
+ Run `browser-prepare --connection ID` after approval. It checks once; if pending it prints the link again without keeping a process or lock open. Approved requests save the credential privately and return the secret-free named MCP entry. Lost HTTP replies and interrupted local saves are recoverable by retrying this same command; do not delete the private receipt or create another request. `browser-status --connection ID` shows local pending/saved metadata only, never online activity. Cancel a pending request in the signed-in browser. Expired/cancelled requests preserve existing saved credentials; repeat `browser-connect` for a fresh request. `browser-connect --reconnect` requests a replacement after a previously saved setup, with browser confirmation required before any active connection is replaced.
14
+
15
+ The server receives the hash of a locally generated credential, never its plaintext. The polling secret and candidate credential stay in private 0600 files under 0700 directories; neither appears in the URL, command arguments, chat or MCP configuration. Per-project/service/agent locks cover only short local operations and HTTP requests. No lock is held while a human approves in the browser. Recognized browser-operation locks whose PID is confirmed dead recover automatically; live, reused or uncertain PIDs and legacy bridge locks are never stopped or removed. Recovery attempts are serialized and recheck the lock inode/content. An interrupted recovery guard is conservatively refused rather than deleting an uncertain lock. Two different agents/projects can set up independently; duplicate operations for the same identity are refused safely. Pending requests expire on the service; approved receipts remain recoverable.
16
+
17
+ The deployed beta.2 private-code command remains an identified compatibility path: `session-connect` requires an interactive terminal, rejects pipes/redirected input before taking a lock and never runs in the background. Codes belong only in its private prompt. Expected agent IDs lock only that agent, without a whole-home lock. Existing beta.2 session records remain valid and continue to pin their beta.2 bridge rather than silently upgrading a live connection. OPE-43 owns retirement after participant migration and independent acceptance.
6
18
 
7
19
  Setup returns a secret-free MCP entry and instructions for the selected client. Ask your current agent to add only that entry after your approval, preserving all other entries. Claude uses its workspace-local `mcp add --scope local` command; Codex uses the trusted workspace’s `.codex/config.toml`. Do not share machine-specific paths in repository config. Native trust and permission prompts remain under your control. The client may need MCP reload before tools appear; return to the existing conversation and verify `get_project` on the named entry before continuing. macOS is the tested package environment; Linux and Windows acceptance remains pending. Independent real-client reload/continuation acceptance is still pending; configuration generation and the actual stdio process are tested separately.
8
20
 
9
21
  Each service/project/agent combination has its own ID, credentials and MCP name. Local and production services are distinct even when their IDs match. Several catalogs may load without claiming their agents. The first `get_project` call claims only its connection and verifies `you.agentId` and `you.agentName`; other tool calls are refused until this succeeds. Use a separate named agent for concurrent sessions. A second process using one identity is refused without takeover. Workspace scoping is client configuration, not a security sandbox; credentials are scoped by the service to their project agent.
10
22
 
11
- Commands: `session-connect`, `session-prepare --connection ID` (recover interrupted pairing), `session-config --connection ID` (print the secret-free entry), `session-status` (local records only), and `session-serve --connection ID` (stdio transport, started by the MCP client). Session commands use separate private storage at `~/.karma42/sessions/SERVICE_PROJECT_HASH/AGENT_ID`; `--state-dir` selects a private test root. A saved pairing receipt survives cancellation. New setup codes rotate that identity’s service credential, so disconnect its old bridge/runner first. Other identities and their history are preserved.
23
+ Commands: `session-connect`, `session-prepare --connection ID` (recover interrupted pairing), `session-config --connection ID` (print the secret-free entry), `session-status` (local records only), and `session-serve --connection ID` (stdio transport, started by the MCP client). Session commands use separate private storage at `~/.karma42/sessions/SERVICE_PROJECT_HASH/AGENT_ID`; `--state-dir` selects a private test root. Browser setup records survive interruption. Browser approval explicitly confirms credential replacement; pending/cancelled requests do not rotate it. Legacy private codes rotate that identity’s credential, so disconnect its old bridge/runner before private-code setup. Other identities and their history are preserved.
12
24
 
13
25
  The client owns the bridge process. EOF or SIGINT/SIGTERM/SIGHUP reports stopping, closes polling and releases the local identity lock. Closing a chat does not necessarily close the client-owned process; explicitly disconnect the named MCP entry to release it. A crash/SIGKILL cannot send a final report: the backend’s existing lease/fencing and stale-lock recovery still apply. No remote kill of the coding client is promised. Catalog discovery and connection contact are not evidence of model work or measured spending. The bridge retains the canonical bounded deferred-send journal/retries, only while connected. Revocation, stale runs, paused/not-started mutations and urgent runtime barriers remain enforced by the service; the bridge never downloads or executes cloud-selected runner updates to bypass a refusal.
14
26
 
@@ -100,4 +112,4 @@ Legacy `~/.agent-projects` helpers/launchers remain identified active consumers.
100
112
 
101
113
  Focused tests cover preparation/start separation, all provider/mode choices, cancelled acknowledgements, expired/refused responses, interrupted pairing, clone/readiness failures, wrong origin, credential privacy, stale consent, concurrent operations, reconnect preservation, pending recovery, GitHub account visibility, naming/path collisions and environment filtering. The foreground stop test uses the real supervisor with a disposable **stub runner**, not a model. An installed-artifact terminal rehearsal uses synthetic service responses and local tool fixtures. No production credentials, persistent emulator reset, real GitHub mutation or model execution is part of these tests.
102
114
 
103
- Browser OAuth pairing, website activation, signed runtime releases, independent operator/real-model acceptance and deployment remain separate release steps. This beta does not claim independent real-model acceptance. See `docs/onboarding/agent-connection-plan.md` for the product flow and release gates.
115
+ Browser-pairing website activation, signed runtime releases, independent operator/real-model acceptance and deployment remain separate release steps. This beta does not claim independent real-model acceptance. See `docs/onboarding/agent-connection-plan.md` for the product flow and release gates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karma42/agent",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "Karma42 named project MCP connections for your current coding session",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -0,0 +1,142 @@
1
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
2
+ import { constants, openSync, closeSync, writeFileSync, lstatSync, unlinkSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { makePlan, VERSION } from './plan.mjs';
5
+ import { canonicalPath, connectionDir, digest, lstatExists, readPrivate, save, writePrivate } from './state.mjs';
6
+ import { sessionHome, sessionId, sessionConfiguration, validateSession } from './session.mjs';
7
+ import { system } from './system.mjs';
8
+
9
+ const TOKEN = /^[\w-]{43}$/;
10
+ const hash = value => createHash('sha256').update(value).digest('hex');
11
+ // Only locks created by this browser flow can be recovered automatically. Legacy/live bridge locks remain untouched.
12
+ export function withBrowserLock(dir, action, processAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; return true; } }) {
13
+ const path = join(dir, 'operation.lock'), guard = join(dir, 'browser-recovery.lock');
14
+ const create = () => {
15
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
16
+ try { writeFileSync(fd, JSON.stringify({ kind: 'karma42-browser-operation-1', pid: process.pid, nonce: randomUUID() })); }
17
+ finally { closeSync(fd); }
18
+ return lstatSync(path);
19
+ };
20
+ let owned;
21
+ try { owned = create(); }
22
+ catch (error) {
23
+ if (error.code !== 'EEXIST') throw error;
24
+ let guardFd;
25
+ try { guardFd = openSync(guard, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
26
+ catch { throw new Error('Another browser setup operation is checking this connection. Retry shortly; no process was stopped.'); }
27
+ try {
28
+ writeFileSync(guardFd, `${process.pid}\n`);
29
+ const before = lstatSync(path), raw = readPrivate(path);
30
+ let holder;
31
+ try { holder = JSON.parse(raw); } catch {}
32
+ if (holder?.kind !== 'karma42-browser-operation-1' || !Number.isSafeInteger(holder.pid) || holder.pid <= 0 || typeof holder.nonce !== 'string' || processAlive(holder.pid)) {
33
+ throw new Error('Another operation holds this connection. Finish or cancel its setup, or disconnect its active MCP entry, before retrying. No process was stopped.');
34
+ }
35
+ // Guard serializes recovery attempts; reread inode/content before touching the confirmed-dead lock.
36
+ const current = lstatSync(path);
37
+ if (current.dev !== before.dev || current.ino !== before.ino || readPrivate(path) !== raw) throw new Error('The connection lock changed. Retry setup; no process was stopped.');
38
+ unlinkSync(path);
39
+ owned = create();
40
+ } finally { closeSync(guardFd); unlinkSync(guard); }
41
+ }
42
+ return Promise.resolve().then(action).finally(() => {
43
+ if (!lstatExists(path)) return;
44
+ const current = lstatSync(path);
45
+ if (current.dev === owned.dev && current.ino === owned.ino) unlinkSync(path);
46
+ });
47
+ }
48
+ const receiptPath = dir => join(dir, 'pending-browser.json');
49
+ function validReceipt(receipt, id) {
50
+ if (receipt?.schema !== 'browser-pairing-1' || receipt.id !== id || id !== sessionId(receipt.service, receipt.agentId)
51
+ || !TOKEN.test(receipt.pollingSecret ?? '') || !TOKEN.test(receipt.candidateToken ?? '')
52
+ || !/^[\w-]{1,200}$/.test(receipt.agentId ?? '') || !/^[a-f0-9-]{36}$/.test(receipt.requestId ?? '')
53
+ || receipt.packageVersion !== VERSION) throw new Error('Invalid private browser setup receipt. Review My agents.');
54
+ const plan = makePlan({ provider: receipt.provider, mode: 'guarded', projectUrl: receipt.service, repo: 'https://github.com/placeholder/project', folder: receipt.folder });
55
+ if (plan.projectUrl !== receipt.service || canonicalPath(receipt.folder) !== receipt.folder) throw new Error('Browser setup workspace changed. Review My agents.');
56
+ return receipt;
57
+ }
58
+ export function browserApprovalUrl(receipt) {
59
+ const service = new URL(receipt.service);
60
+ const page = new URL('/viewer/', service.origin);
61
+ page.searchParams.set('project', service.pathname.split('/')[2]);
62
+ page.searchParams.set('section', 'your-agent');
63
+ page.searchParams.set('pairing', receipt.requestId);
64
+ return page.href;
65
+ }
66
+ export function createBrowserOperations({ home = sessionHome(), io, sys = system } = {}) {
67
+ home = canonicalPath(home);
68
+ const showPending = receipt => io.say(`Browser approval required. Open this link, sign in and review the project, agent, workspace and requested access:\n${browserApprovalUrl(receipt)}\nRequest check: ${hash(receipt.requestId + ':' + hash(receipt.candidateToken)).slice(0, 8).toUpperCase()} (match this in the browser before approving)\nRequested workspace (not independently verified): ${receipt.folder}\nConnection: ${receipt.id}\nAfter approval run browser-prepare --connection ${receipt.id}${home === sessionHome() ? '' : ` --state-dir '${home.replaceAll("'", "'\\''")}'`}\nNo process is waiting. Native client trust prompts and approval to edit MCP configuration still apply.`);
69
+ const startBody = receipt => ({ requestId: receipt.requestId, agentId: receipt.agentId, provider: receipt.provider, folder: receipt.folder, pollingSecret: receipt.pollingSecret, candidateTokenHash: hash(receipt.candidateToken) });
70
+ async function start(dir, receipt) {
71
+ let result;
72
+ try { result = await sys.request(receipt.service, 'pairings/start', startBody(receipt)); }
73
+ catch { throw new Error(`Browser setup reply unavailable. Your private request is saved. Retry browser-prepare --connection ${receipt.id}; do not create another request or replace credentials.`); }
74
+ if (result?.requestId !== receipt.requestId || !['pending', 'approved', 'cancelled', 'expired'].includes(result.status)) throw new Error('Unexpected browser setup reply. The private request was preserved.');
75
+ receipt = { ...receipt, registered: true, expiresAt: result.expiresAt };
76
+ writePrivate(receiptPath(dir), receipt);
77
+ return receipt;
78
+ }
79
+ function showConfig(record) {
80
+ const config = sessionConfiguration(record, home);
81
+ io.say(`Saved session connection: ${record.id}\nMCP entry: ${config.name}\nBrowser approval completed. No coding model was launched and no client settings were changed.\nAsk before adding this named entry to the current workspace; preserve other entries and permission settings.\n${record.provider === 'claude' ? config.claudeCommand : config.codex}\nReload MCP if needed, return to this conversation, then call get_project on ${config.name} and verify agent ${record.agentName}.`);
82
+ return record;
83
+ }
84
+ return {
85
+ async connect(options) {
86
+ if (!/^[\w-]{1,200}$/.test(options['agent-id'] ?? '')) throw new Error('Browser setup requires the project-specific --agent-id from My agents.');
87
+ const folder = canonicalPath(resolve(options.folder ?? process.cwd()));
88
+ const plan = makePlan({ provider: options.provider, mode: 'guarded', projectUrl: options['project-url'], repo: 'https://github.com/placeholder/project', folder });
89
+ sys.verifyBundle();
90
+ const id = sessionId(plan.projectUrl, options['agent-id']), dir = connectionDir(home, id);
91
+ return withBrowserLock(dir, async () => {
92
+ let receipt;
93
+ if (lstatExists(receiptPath(dir))) {
94
+ receipt = validReceipt(readPrivate(receiptPath(dir), true), id);
95
+ if (receipt.folder !== folder || receipt.provider !== plan.provider) throw new Error('A browser setup is already pending for this agent in another workspace or client. Review or cancel it in My agents before starting another.');
96
+ if (['cancelled', 'expired'].includes(receipt.status) || (receipt.status === 'saved' && options.reconnect)) receipt = null;
97
+ else if (receipt.status === 'saved') return showConfig(validateSession(readPrivate(join(dir, 'connection.json'), true), id));
98
+ }
99
+ if (!receipt) {
100
+ receipt = { schema: 'browser-pairing-1', id, service: plan.projectUrl, agentId: options['agent-id'], provider: plan.provider, folder,
101
+ requestId: randomUUID(), candidateToken: randomBytes(32).toString('base64url'), pollingSecret: randomBytes(32).toString('base64url'), packageVersion: VERSION, createdAt: sys.now(), status: 'pending', registered: false };
102
+ // Persist the candidate BEFORE contacting the server: lost start/approval replies never lose the credential.
103
+ writePrivate(receiptPath(dir), receipt);
104
+ }
105
+ if (!receipt.registered) receipt = await start(dir, receipt);
106
+ showPending(receipt); return { id, status: receipt.status, approvalUrl: browserApprovalUrl(receipt) };
107
+ });
108
+ },
109
+ async prepare(options) {
110
+ const id = options.connection, dir = connectionDir(home, id);
111
+ return withBrowserLock(dir, async () => {
112
+ if (!lstatExists(receiptPath(dir))) throw new Error('No browser setup found. Use browser-connect from My agents.');
113
+ let receipt = validReceipt(readPrivate(receiptPath(dir), true), id);
114
+ if (receipt.status === 'saved') return showConfig(validateSession(readPrivate(join(dir, 'connection.json'), true), id));
115
+ if (!receipt.registered) receipt = await start(dir, receipt);
116
+ let result;
117
+ try { result = await sys.request(receipt.service, `pairings/${receipt.requestId}/poll`, {}, receipt.pollingSecret); }
118
+ catch { throw new Error(`Browser approval reply unavailable. Retry browser-prepare --connection ${id}; your private request and existing connection are preserved.`); }
119
+ if (!['pending', 'approved', 'cancelled', 'expired'].includes(result?.status)) throw new Error('Unexpected browser approval reply. Existing connection preserved.');
120
+ if (result.status === 'pending') { showPending(receipt); return { id, status: 'pending' }; }
121
+ if (result.status !== 'approved') {
122
+ writePrivate(receiptPath(dir), { ...receipt, status: result.status });
123
+ io.say(`Browser setup ${result.status}. Existing saved credentials were not changed. Start a fresh browser-connect when ready.`);
124
+ return { id, status: result.status };
125
+ }
126
+ if (result.agentId !== receipt.agentId || !/^[\w-]{1,64}$/.test(result.agentName ?? '') || !Number.isFinite(result.approvedAt)) throw new Error('Approved identity differs from the requested agent. Existing connection preserved. Review My agents.');
127
+ const plan = makePlan({ provider: receipt.provider, mode: 'guarded', projectUrl: receipt.service, repo: result.repoUrl, folder: receipt.folder });
128
+ const record = { schema: 'client-session-1', id, service: receipt.service, agentId: receipt.agentId, agentName: result.agentName, repo: plan.repo, folder: plan.folder, provider: plan.provider, packageVersion: VERSION, createdAt: sys.now(), approvedAt: result.approvedAt };
129
+ record.consent = digest([id, record.service, record.agentName, record.repo, record.folder, record.provider, VERSION]);
130
+ writePrivate(join(dir, 'token'), receipt.candidateToken); save(dir, record);
131
+ // Keep the private receipt for restart/recovery; never exchange another credential on retry.
132
+ writePrivate(receiptPath(dir), { ...receipt, status: 'saved' });
133
+ return showConfig(record);
134
+ });
135
+ },
136
+ status(options) {
137
+ const dir = connectionDir(home, options.connection);
138
+ const receipt = validReceipt(readPrivate(receiptPath(dir), true), options.connection);
139
+ io.say(JSON.stringify({ id: receipt.id, project: receipt.service, provider: receipt.provider, workspace: receipt.folder, state: receipt.status, approvalUrl: browserApprovalUrl(receipt), expiresAt: receipt.expiresAt, liveActivity: 'not checked' }, null, 2));
140
+ },
141
+ };
142
+ }
package/src/cli.mjs CHANGED
@@ -6,12 +6,16 @@ import { VERSION, makePlan, providerName, renderPlan } from './plan.mjs';
6
6
  import { doctor } from './doctor.mjs';
7
7
  import { createOperations } from './connection.mjs';
8
8
  import { createSessionOperations } from './session.mjs';
9
+ import { createBrowserOperations } from './browser-pairing.mjs';
9
10
  import { serveSession } from './session-stdio.mjs';
10
11
  import { terminal } from './terminal.mjs';
11
12
 
12
13
  const HELP = `Karma42 agent ${VERSION} — beta release
13
14
 
14
15
  Commands:
16
+ browser-connect --project-url HTTPS_SERVICE/p/PROJECT_ID --provider claude|codex --agent-id AGENT_ID [--folder ABSOLUTE_WORKSPACE]
17
+ browser-prepare --connection ID (check approval once; safe to retry)
18
+ browser-status --connection ID (private local state only)
15
19
  session-connect --project-url HTTPS_SERVICE/p/PROJECT_ID --provider claude|codex [--agent-id EXPECTED_AGENT_ID] [--folder ABSOLUTE_WORKSPACE]
16
20
  session-prepare --connection ID (recover interrupted pairing)
17
21
  session-config --connection ID
@@ -39,6 +43,8 @@ Session commands use separate private storage (~/.karma42/sessions), keep your c
39
43
  No automatic personal client configuration edits or background launch.`;
40
44
  const selection = { connection: { type: 'string' }, 'state-dir': { type: 'string' } };
41
45
  const specs = {
46
+ 'browser-connect': { reconnect: { type: 'boolean' }, 'agent-id': { type: 'string' }, provider: { type: 'string' }, 'project-url': { type: 'string' }, folder: { type: 'string' }, 'state-dir': { type: 'string' } },
47
+ 'browser-prepare': selection, 'browser-status': selection,
42
48
  'session-connect': { 'agent-id': { type: 'string' }, provider: { type: 'string' }, 'project-url': { type: 'string' }, folder: { type: 'string' }, 'state-dir': { type: 'string' } },
43
49
  'session-prepare': selection, 'session-config': selection, 'session-serve': selection, 'session-status': { 'state-dir': { type: 'string' } },
44
50
  connect: { provider: { type: 'string' }, mode: { type: 'string' }, 'project-url': { type: 'string' }, folder: { type: 'string' }, 'state-dir': { type: 'string' } },
@@ -69,6 +75,10 @@ export function main(args, out = console.log, err = console.error, runDoctor = d
69
75
  if (v['state-dir'] && (!v['state-dir'].startsWith('/') || /[\x00-\x1f\x7f-\x9f]/.test(v['state-dir']))) throw new Error('Choose an absolute private state directory.');
70
76
  if (command === 'session-serve') return serveSession({ connection: v.connection, home: v['state-dir'] }).then(() => 0).catch(() => { err('MCP session could not start. Review its private connection record.'); return 2; });
71
77
  const io = terminal(); io.say = out;
78
+ if (command.startsWith('browser-')) {
79
+ const browser = createBrowserOperations({ home: v['state-dir'], io });
80
+ return Promise.resolve().then(() => browser[command.slice(8)](v)).then(() => 0).catch(error => { err(error.message); return 2; });
81
+ }
72
82
  if (command.startsWith('session-')) {
73
83
  const sessions = createSessionOperations({ home: v['state-dir'], io });
74
84
  return Promise.resolve().then(() => sessions[command.slice(8)](v)).then(() => 0).catch(error => { err(error.message); return 2; });
package/src/plan.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute } from 'node:path';
2
2
 
3
- export const VERSION = '0.1.0-beta.2';
3
+ export const VERSION = '0.1.0-beta.3';
4
4
  export const COPY_VERSION = '2026-10-09.2';
5
5
  // These descriptions reflect pilot/agent-kit/providers.mjs. They are a proposal
6
6
  // for the onboarding vocabulary, not a change to the current adapter settings.
package/src/session.mjs CHANGED
@@ -11,7 +11,7 @@ const TOKEN = /^[\w-]{43}$/;
11
11
  export const sessionId = (service, agentId) => `${digest(service)}/${agentId}`;
12
12
  export const sessionName = record => `karma42-${digest([record.service, record.agentId]).slice(0, 24)}`;
13
13
  export function validateSession(record, id) {
14
- if (record?.schema !== 'client-session-1' || record.id !== id || record.id !== sessionId(record.service, record.agentId) || record.packageVersion !== VERSION
14
+ if (record?.schema !== 'client-session-1' || record.id !== id || record.id !== sessionId(record.service, record.agentId) || !['0.1.0-beta.2', VERSION].includes(record.packageVersion)
15
15
  || !/^[\w-]{1,200}$/.test(record.agentId ?? '') || !/^[\w-]{1,64}$/.test(record.agentName ?? '')) throw new Error('Invalid session identity. Reconnect using My agents.');
16
16
  const plan = makePlan({ provider: record.provider, mode: 'guarded', projectUrl: record.service, repo: record.repo, folder: record.folder });
17
17
  if (plan.projectUrl !== record.service || canonicalPath(record.folder) !== record.folder) throw new Error('Session workspace changed. Review the connection again.');
@@ -55,12 +55,13 @@ export function createSessionOperations({ home = sessionHome(), io, sys = system
55
55
  }
56
56
  return {
57
57
  async connect(options) {
58
+ if (!io.interactive) throw new Error('Private-code setup needs an interactive terminal. Do not redirect input, pipe output or run it in the background. Prefer browser-connect, or run session-connect in your existing terminal.');
58
59
  const folder = canonicalPath(resolve(options.folder ?? process.cwd()));
59
60
  const plan = makePlan({ provider: options.provider, mode: 'guarded', projectUrl: options['project-url'], repo: 'https://github.com/placeholder/project', folder });
60
61
  if (options['agent-id'] !== undefined && !/^[\w-]{1,200}$/.test(options['agent-id'])) throw new Error('Choose a valid agent ID.');
61
62
  sys.verifyBundle();
62
63
  ensureHome(home);
63
- return withLock(home, async () => {
64
+ {
64
65
  const expected = options['agent-id'];
65
66
  const expectedDir = expected ? connectionDir(home, sessionId(plan.projectUrl, expected)) : null;
66
67
  const pair = async () => {
@@ -77,8 +78,12 @@ export function createSessionOperations({ home = sessionHome(), io, sys = system
77
78
  if (expected && expected !== paired.agentId) throw new Error('This code belongs to a different agent. Its private recovery receipt was saved; the requested connection was preserved. Review My agents before proceeding.');
78
79
  return finish(dir, readPrivate(join(dir, 'pending-session.json'), true), Boolean(expectedDir));
79
80
  };
80
- return expectedDir ? withLock(expectedDir, pair) : pair();
81
- });
81
+ // Identified beta.2 code users retain isolated private-code setup. Unknown identity cannot acquire a per-agent lock until exchange.
82
+ if (expectedDir) return withLock(expectedDir, pair);
83
+ // Published beta.2 allowed an omitted ID. Isolate that legacy interactive path to its project, not the home shared by all agents.
84
+ const legacyDir = join(home, digest(plan.projectUrl)); privateDir(legacyDir);
85
+ return withLock(legacyDir, pair);
86
+ }
82
87
  },
83
88
  prepare(options) {
84
89
  const dir = connectionDir(home, options.connection);
@@ -97,6 +102,14 @@ export function createSessionOperations({ home = sessionHome(), io, sys = system
97
102
  for (const agent of readdirSync(join(home, key))) {
98
103
  if (!/^[\w-]{1,200}$/.test(agent)) continue;
99
104
  const id = `${key}/${agent}`, dir = connectionDir(home, id);
105
+ if (lstatExists(join(dir, 'pending-browser.json'))) {
106
+ const pending = readPrivate(join(dir, 'pending-browser.json'), true);
107
+ if (pending.status !== 'saved') {
108
+ const position = records.findIndex(record => record.id === id);
109
+ const browser = { id, service: pending.service, agentId: pending.agentId, agentName: 'browser approval', folder: pending.folder, provider: pending.provider, pendingBrowser: pending.status };
110
+ if (position >= 0) records[position] = browser; else records.push(browser);
111
+ }
112
+ }
100
113
  if (lstatExists(join(dir, 'pending-session.json'))) {
101
114
  const { token, ...receipt } = readPrivate(join(dir, 'pending-session.json'), true);
102
115
  const position = records.findIndex(record => record.id === id);
@@ -105,6 +118,6 @@ export function createSessionOperations({ home = sessionHome(), io, sys = system
105
118
  }
106
119
  }
107
120
  }
108
- io.say(JSON.stringify(records.map(record => ({ id: record.id, agent: record.agentName, project: record.service, workspace: record.folder, provider: record.provider, mcp: sessionName(record), state: record.pending ? 'interrupted pairing; use session-prepare' : 'saved locally; live activity not checked' })), null, 2)); },
121
+ io.say(JSON.stringify(records.map(record => ({ id: record.id, agent: record.agentName, project: record.service, workspace: record.folder, provider: record.provider, mcp: sessionName(record), state: record.pendingBrowser ? `browser setup ${record.pendingBrowser}; use browser-prepare or browser-status --connection ${record.id}` : record.pending ? 'interrupted pairing; use session-prepare' : 'saved locally; live activity not checked' })), null, 2)); },
109
122
  };
110
123
  }