@4xeoz/re-entry 0.2.5 → 0.2.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.
package/README.md CHANGED
@@ -16,6 +16,16 @@ Codex may read and write.
16
16
  npx @4xeoz/re-entry install
17
17
  ```
18
18
 
19
+ `npx` runs a temporary copy, so it does not leave a permanent shell command behind. Install the
20
+ small CLI globally once if you want to use `re-entry` from any folder afterward:
21
+
22
+ ```sh
23
+ npm install --global @4xeoz/re-entry
24
+ ```
25
+
26
+ The package installs both `re-entry` and the older `reentry` spelling; `re-entry` is the documented
27
+ command.
28
+
19
29
  This uses the deployed Re-entry Cloud Receiver by default:
20
30
  `https://reentry-cloud.vercel.app`.
21
31
 
@@ -74,11 +84,15 @@ preview.
74
84
 
75
85
  ```sh
76
86
  re-entry status
87
+ re-entry listen
77
88
  re-entry --help
78
89
  ```
79
90
 
80
91
  The status view checks the local authorization, background job, Receiver reachability, Node, and
81
- Codex. Useful development commands are:
92
+ Codex. If the Receiver rejects a previously saved device credential, the Connector pauses instead
93
+ of retrying forever, and `status`/`listen` tell you to run `re-entry connect` again. `listen` watches
94
+ the already-running background Connector and displays new activity until you press Ctrl+C; it does
95
+ not start a competing second poller. Useful development commands are:
82
96
 
83
97
  ```sh
84
98
  re-entry doctor --codex-cd /absolute/path/to/project
@@ -87,6 +101,15 @@ re-entry claim-once --codex-cd /absolute/path/to/project
87
101
  re-entry start --codex-cd /absolute/path/to/project
88
102
  ```
89
103
 
104
+ Test the local Codex handoff without waiting for Cloud work:
105
+
106
+ ```sh
107
+ re-entry test "Reply with: Re-entry is working."
108
+ ```
109
+
110
+ This starts one fresh local Codex process through the same adapter seam used by real deliveries. It
111
+ does not create a Grant, claim Receiver work, or prove the browser/WebMCP return path.
112
+
90
113
  To pause or remove the local Connector:
91
114
 
92
115
  ```sh
@@ -42,7 +42,7 @@ export function createCloudReceiverHttpHandler(options) {
42
42
  requireReceiver(options.receiver);
43
43
 
44
44
  return function cloudReceiverHttpHandler(request, response) {
45
- handleRequest(options.receiver, request, response).catch((error) => {
45
+ return handleRequest(options.receiver, request, response).catch((error) => {
46
46
  if (response.destroyed) return;
47
47
  if (response.headersSent) {
48
48
  response.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4xeoz/re-entry",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Outbound Local Connector process for the Re-entry Core local preview.",
6
6
  "publishConfig": {
@@ -10,7 +10,10 @@
10
10
  "README.md",
11
11
  "src"
12
12
  ],
13
- "bin": "./src/main.mjs",
13
+ "bin": {
14
+ "re-entry": "./src/main.mjs",
15
+ "reentry": "./src/main.mjs"
16
+ },
14
17
  "exports": {
15
18
  ".": "./src/index.mjs",
16
19
  "./connector": "./src/local-connector.mjs",
@@ -0,0 +1,107 @@
1
+ import { open, stat } from "node:fs/promises";
2
+
3
+ const MAX_LINE_BYTES = 16 * 1_024;
4
+ const MAX_READ_BYTES = 64 * 1_024;
5
+
6
+ /**
7
+ * Follow new JSON events written by the installed background Connector.
8
+ * The monitor starts at the end of each file and never starts another Connector poller.
9
+ */
10
+ export async function followConnectorActivity(options) {
11
+ requireOptions(options);
12
+ const cursors = await Promise.all(options.paths.map(initializeCursor));
13
+ const pollIntervalMs = options.pollIntervalMs ?? 350;
14
+ if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 10 || pollIntervalMs > 5_000) {
15
+ throw new TypeError("Activity monitor poll interval is invalid");
16
+ }
17
+
18
+ while (!options.signal.aborted) {
19
+ for (const cursor of cursors) {
20
+ await readNewEvents(cursor, options.onEvent);
21
+ }
22
+ await waitForNextRead(pollIntervalMs, options.signal);
23
+ }
24
+ }
25
+
26
+ async function initializeCursor(path) {
27
+ try {
28
+ const metadata = await stat(path);
29
+ return { path, offset: metadata.size, pending: "" };
30
+ } catch (error) {
31
+ if (error?.code === "ENOENT") return { path, offset: 0, pending: "" };
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ async function readNewEvents(cursor, onEvent) {
37
+ let handle;
38
+ try {
39
+ handle = await open(cursor.path, "r");
40
+ const metadata = await handle.stat();
41
+ if (metadata.size < cursor.offset) {
42
+ cursor.offset = 0;
43
+ cursor.pending = "";
44
+ }
45
+ if (metadata.size === cursor.offset) return;
46
+ const length = Math.min(metadata.size - cursor.offset, MAX_READ_BYTES);
47
+ const buffer = Buffer.alloc(length);
48
+ const { bytesRead } = await handle.read(buffer, 0, length, cursor.offset);
49
+ cursor.offset += bytesRead;
50
+ const lines = `${cursor.pending}${buffer.subarray(0, bytesRead).toString("utf8")}`.split("\n");
51
+ cursor.pending = lines.pop() ?? "";
52
+ if (Buffer.byteLength(cursor.pending, "utf8") > MAX_LINE_BYTES) cursor.pending = "";
53
+ for (const line of lines) {
54
+ const event = parseEvent(line);
55
+ if (event) await onEvent(event);
56
+ }
57
+ } catch (error) {
58
+ if (error?.code !== "ENOENT") throw error;
59
+ } finally {
60
+ await handle?.close();
61
+ }
62
+ }
63
+
64
+ function parseEvent(line) {
65
+ if (line.length === 0 || Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) return null;
66
+ try {
67
+ const value = JSON.parse(line);
68
+ if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.event !== "string") {
69
+ return null;
70
+ }
71
+ return value;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function waitForNextRead(milliseconds, signal) {
78
+ if (signal.aborted) return Promise.resolve();
79
+ return new Promise((resolve) => {
80
+ const timer = setTimeout(done, milliseconds);
81
+ function done() {
82
+ clearTimeout(timer);
83
+ signal.removeEventListener("abort", done);
84
+ resolve();
85
+ }
86
+ signal.addEventListener("abort", done, { once: true });
87
+ });
88
+ }
89
+
90
+ function requireOptions(options) {
91
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
92
+ throw new TypeError("Activity monitor options are required");
93
+ }
94
+ const fields = Object.keys(options);
95
+ if (fields.some((field) => !["paths", "signal", "onEvent", "pollIntervalMs"].includes(field))) {
96
+ throw new TypeError("Activity monitor options contain an unsupported field");
97
+ }
98
+ if (
99
+ !Array.isArray(options.paths) ||
100
+ options.paths.length !== 2 ||
101
+ options.paths.some((path) => typeof path !== "string" || path.length === 0) ||
102
+ !(options.signal instanceof AbortSignal) ||
103
+ typeof options.onEvent !== "function"
104
+ ) {
105
+ throw new TypeError("Activity monitor options are invalid");
106
+ }
107
+ }
@@ -22,6 +22,7 @@ const MIN_COMMAND_TIMEOUT_MS = 100;
22
22
  const MAX_COMMAND_TIMEOUT_MS = 60_000;
23
23
  const MAX_REFERENCE_BYTES = 4 * 1_024;
24
24
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
25
+ const PROMPT_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f]/;
25
26
 
26
27
  /**
27
28
  * Create the fresh-session Codex adapter that lives inside the Local Connector process.
@@ -54,11 +55,11 @@ export function createCodexExecAdapter(options) {
54
55
  return activationResult(activation, "rejected", "activation_rejected", null);
55
56
  }
56
57
 
57
- await runCodexExec({
58
+ await runCodexPrompt({
58
59
  executable,
59
60
  workingDirectory,
60
61
  prompt: buildContinuationPrompt(activation),
61
- timeoutMs: commandTimeoutMs,
62
+ commandTimeoutMs,
62
63
  spawnCommand,
63
64
  });
64
65
  return activationResult(
@@ -71,6 +72,30 @@ export function createCodexExecAdapter(options) {
71
72
  });
72
73
  }
73
74
 
75
+ /**
76
+ * Run one local prompt through the same fresh Codex process seam used by real activations.
77
+ * This helper carries no Receiver authority and is intended only for an explicit local smoke test.
78
+ */
79
+ export async function runCodexPrompt(options) {
80
+ requireExactRecord(
81
+ options,
82
+ ["workingDirectory", "prompt", "executable", "commandTimeoutMs", "spawnCommand"],
83
+ ["workingDirectory", "prompt"],
84
+ "Codex prompt options",
85
+ );
86
+ const workingDirectory = validateCodexWorkingDirectory(options.workingDirectory);
87
+ const executable = options.executable === undefined
88
+ ? discoverCodexExecutable()
89
+ : requireReference(options.executable, "Codex executable");
90
+ const prompt = requirePrompt(options.prompt);
91
+ const timeoutMs = requireTimeout(options.commandTimeoutMs ?? 60_000);
92
+ const spawnCommand = options.spawnCommand ?? spawn;
93
+ if (typeof spawnCommand !== "function") {
94
+ throw new TypeError("Codex prompt spawnCommand must be a function");
95
+ }
96
+ await runCodexExec({ executable, workingDirectory, prompt, timeoutMs, spawnCommand });
97
+ }
98
+
74
99
  function buildContinuationPrompt(activation) {
75
100
  return [
76
101
  "You are a Re-entry continuation agent.",
@@ -183,6 +208,19 @@ function requireReference(value, label) {
183
208
  return value;
184
209
  }
185
210
 
211
+ function requirePrompt(value) {
212
+ if (
213
+ typeof value !== "string" ||
214
+ value.length === 0 ||
215
+ value.trim() !== value ||
216
+ Buffer.byteLength(value, "utf8") > MAX_REFERENCE_BYTES ||
217
+ PROMPT_CONTROL_CHARACTER_PATTERN.test(value)
218
+ ) {
219
+ throw new TypeError("Codex prompt is invalid");
220
+ }
221
+ return value;
222
+ }
223
+
186
224
  function requireExactRecord(value, allowedFields, requiredFields, label) {
187
225
  if (!value || typeof value !== "object" || Array.isArray(value)) {
188
226
  throw new TypeError(`${label} must be an object`);
@@ -49,6 +49,48 @@ export class LocalConnectorCredentialStore {
49
49
  }
50
50
  }
51
51
 
52
+ export function reauthorizationMarkerPath(filename) {
53
+ if (typeof filename !== "string" || filename.length === 0) {
54
+ throw new TypeError("Credential filename is required");
55
+ }
56
+ return `${filename}.reauthorization-required.json`;
57
+ }
58
+
59
+ export async function markConnectorReauthorizationRequired(filename, value = {}) {
60
+ const marker = reauthorizationMarkerPath(filename);
61
+ await mkdir(dirname(marker), { recursive: true, mode: 0o700 });
62
+ await writeFile(marker, `${JSON.stringify({
63
+ receiver_origin: value.receiver_origin ?? null,
64
+ reason: "connector_identity_invalid",
65
+ observed_at: new Date().toISOString(),
66
+ })}\n`, { encoding: "utf8", mode: 0o600 });
67
+ await chmod(marker, 0o600);
68
+ }
69
+
70
+ export async function hasConnectorReauthorizationRequired(filename) {
71
+ try {
72
+ await readFile(reauthorizationMarkerPath(filename), "utf8");
73
+ return true;
74
+ } catch (error) {
75
+ if (error?.code === "ENOENT") return false;
76
+ throw credentialFailure(
77
+ "connector_status_unreadable",
78
+ "Connector authorization status could not be read",
79
+ error,
80
+ );
81
+ }
82
+ }
83
+
84
+ export async function clearConnectorReauthorizationRequired(filename) {
85
+ await unlink(reauthorizationMarkerPath(filename)).catch((error) => {
86
+ if (error?.code !== "ENOENT") throw credentialFailure(
87
+ "connector_status_unwritable",
88
+ "Connector authorization status could not be cleared",
89
+ error,
90
+ );
91
+ });
92
+ }
93
+
52
94
  function normalizeCredentials(value) {
53
95
  requireExactRecord(value, CREDENTIAL_FIELDS, CREDENTIAL_FIELDS, "Connector credentials");
54
96
  if (value.version !== 1) throw credentialFailure("connector_credentials_invalid", "Connector credential version is unsupported");
@@ -91,7 +91,7 @@ export async function inspectMacConnectorService(options = {}) {
91
91
  return Object.freeze({
92
92
  supported: true,
93
93
  installed: true,
94
- running: result.code === 0,
94
+ running: result.code === 0 && isLoadedAndRunning(result.stdout),
95
95
  plistPath,
96
96
  });
97
97
  }
@@ -127,6 +127,7 @@ export async function uninstallMacConnectorService(options = {}) {
127
127
  const paths = [...new Set([
128
128
  service.plistPath,
129
129
  credentialFile,
130
+ `${credentialFile}.reauthorization-required.json`,
130
131
  join(stateDirectory, "connector.log"),
131
132
  join(stateDirectory, "connector-error.log"),
132
133
  ])];
@@ -192,6 +193,7 @@ ${argumentsXml}
192
193
  function runLaunchctl(argumentsList) {
193
194
  return new Promise((resolve) => {
194
195
  const child = spawn("launchctl", argumentsList, { stdio: ["ignore", "pipe", "pipe"] });
196
+ let stdout = "";
195
197
  let stderr = "";
196
198
  let settled = false;
197
199
  const finish = (result) => {
@@ -199,15 +201,29 @@ function runLaunchctl(argumentsList) {
199
201
  settled = true;
200
202
  resolve(result);
201
203
  };
204
+ child.stdout.setEncoding("utf8");
205
+ child.stdout.on("data", (chunk) => {
206
+ if (stdout.length < 16_384) stdout += chunk;
207
+ });
202
208
  child.stderr.setEncoding("utf8");
203
209
  child.stderr.on("data", (chunk) => {
204
210
  if (stderr.length < 4_096) stderr += chunk;
205
211
  });
206
- child.once("error", (error) => finish({ code: -1, stderr: error.message }));
207
- child.once("close", (code) => finish({ code: code ?? -1, stderr }));
212
+ child.once("error", (error) => finish({ code: -1, stdout, stderr: error.message }));
213
+ child.once("close", (code) => finish({ code: code ?? -1, stdout, stderr }));
208
214
  });
209
215
  }
210
216
 
217
+ function isLoadedAndRunning(stdout) {
218
+ // Test doubles historically return only { code: 0 }; preserve that contract while
219
+ // treating launchctl's explicit not-running state as stopped in production.
220
+ if (stdout === undefined) return true;
221
+ const state = stdout.match(/^[\t ]*state\s*=\s*([^\r\n]+)$/m)?.[1]?.trim();
222
+ if (state) return state === "running";
223
+ const activeCount = stdout.match(/^[\t ]*active count\s*=\s*(\d+)$/m)?.[1];
224
+ return activeCount !== undefined && Number(activeCount) > 0;
225
+ }
226
+
211
227
  function requireServiceConfiguration(options, operation) {
212
228
  if (!options || typeof options !== "object" || Array.isArray(options)) {
213
229
  throw serviceFailure("connector_service_input_invalid", `Service ${operation} options are invalid`);
package/src/main.mjs CHANGED
@@ -9,7 +9,8 @@ import { randomBytes } from "node:crypto";
9
9
  import process from "node:process";
10
10
 
11
11
  import { LocalConnectorClient } from "@webmcp-challenge/reentry-core/local-connector-client";
12
- import { createCodexExecAdapter } from "./codex-exec-adapter.mjs";
12
+ import { followConnectorActivity } from "./activity-monitor.mjs";
13
+ import { createCodexExecAdapter, runCodexPrompt } from "./codex-exec-adapter.mjs";
13
14
  import {
14
15
  discoverCodexExecutable,
15
16
  requireSupportedNode,
@@ -17,7 +18,12 @@ import {
17
18
  verifyCodexExecutable,
18
19
  } from "./codex-discovery.mjs";
19
20
  import { LocalConnector } from "./local-connector.mjs";
20
- import { LocalConnectorCredentialStore } from "./credentials.mjs";
21
+ import {
22
+ LocalConnectorCredentialStore,
23
+ clearConnectorReauthorizationRequired,
24
+ hasConnectorReauthorizationRequired,
25
+ markConnectorReauthorizationRequired,
26
+ } from "./credentials.mjs";
21
27
  import { LocalConnectorPairingClient } from "./pairing-client.mjs";
22
28
  import { waitForEnterToOpenBrowser } from "./browser-prompt.mjs";
23
29
  import { chooseWorkspaceDirectory } from "./workspace-picker.mjs";
@@ -47,8 +53,8 @@ async function main() {
47
53
  process.stdout.write(`${CONNECTOR_VERSION}\n`);
48
54
  return;
49
55
  }
50
- const { command, flags } = parseArguments(argumentsList);
51
- validateCommandFlags(command, flags);
56
+ const { command, flags, positionals } = parseArguments(argumentsList);
57
+ validateCommandFlags(command, flags, positionals);
52
58
  ui = createTerminalUi({ interactive: flags.json !== true && process.stdout.isTTY === true });
53
59
  requireSupportedNode();
54
60
  if (command === "doctor") {
@@ -67,6 +73,14 @@ async function main() {
67
73
  await status(flags, ui);
68
74
  return;
69
75
  }
76
+ if (command === "listen") {
77
+ await listen(flags, ui);
78
+ return;
79
+ }
80
+ if (command === "test") {
81
+ await testCodex(flags, positionals, ui);
82
+ return;
83
+ }
70
84
  if (command === "stop") {
71
85
  await stop(flags, ui);
72
86
  return;
@@ -111,12 +125,14 @@ Connect this Mac once, then receive approved work in Codex in the background.
111
125
 
112
126
  Usage:
113
127
  re-entry install Recommended first run
114
- re-entry status Check the connection
115
- re-entry start Run in this terminal
128
+ re-entry listen Watch live activity
129
+ re-entry test "Reply with hello" Test Codex locally
116
130
 
117
131
  Commands:
118
132
  install Check this Mac, connect the Re-entry account, and start at login
119
133
  status Show account, Receiver, service, Node.js, and Codex readiness
134
+ listen Watch the background Connector until you press Ctrl+C
135
+ test Start one fresh local Codex session with the supplied prompt
120
136
  stop Stop the background Connector without removing its credential
121
137
  uninstall Stop the Connector and remove its local service data
122
138
  connect Authorize this Mac without installing the background service
@@ -134,6 +150,7 @@ Common options:
134
150
  --help Show this help
135
151
  --version Show the installed version
136
152
 
153
+ Both commands are installed: re-entry and reentry.
137
154
  The Connector opens no inbound port. Host keys never belong on this Mac.
138
155
  `);
139
156
  }
@@ -261,8 +278,9 @@ async function connect(flags, ui, options = {}) {
261
278
  const credentialFile = flags["credential-file"] ?? defaultCredentialFile();
262
279
  const store = new LocalConnectorCredentialStore({ filename: credentialFile });
263
280
  const current = await store.load();
281
+ const reauthorizationRequired = await hasConnectorReauthorizationRequired(credentialFile);
264
282
  const currentIsValid = current && Date.parse(current.connector_expires_at) > Date.now();
265
- if (currentIsValid && current.receiver_origin === receiver) {
283
+ if (currentIsValid && current.receiver_origin === receiver && !reauthorizationRequired) {
266
284
  if (ui.interactive) {
267
285
  ui.success("Account", "already connected");
268
286
  if (!options.guidedInstall) {
@@ -321,6 +339,7 @@ async function connect(flags, ui, options = {}) {
321
339
  connector_expires_at: credentials.connector_expires_at,
322
340
  };
323
341
  await store.save(saved);
342
+ await clearConnectorReauthorizationRequired(credentialFile);
324
343
  if (ui.interactive) {
325
344
  if (!options.guidedInstall) {
326
345
  ui.complete("This Mac is connected", "Re-entry can now route approved work here.");
@@ -339,33 +358,44 @@ async function status(flags, ui) {
339
358
  if (ui.interactive) ui.begin("Status", "A quick check of this Mac and Re-entry.");
340
359
  const credentialFile = flags["credential-file"] ?? defaultCredentialFile();
341
360
  const credentials = await new LocalConnectorCredentialStore({ filename: credentialFile }).load();
361
+ const reauthorizationRequired = await hasConnectorReauthorizationRequired(credentialFile);
342
362
  const readiness = inspectReadiness(flags);
343
363
  const service = await inspectMacConnectorService();
344
364
  const receiverReady = credentials ? await inspectReceiver(credentials.receiver_origin) : false;
345
- const connected = Boolean(credentials && Date.parse(credentials.connector_expires_at) > Date.now());
365
+ const connected = Boolean(
366
+ credentials &&
367
+ Date.parse(credentials.connector_expires_at) > Date.now() &&
368
+ !reauthorizationRequired,
369
+ );
346
370
  if (ui.interactive) {
347
371
  ui.section("SYSTEM", "This Mac");
348
372
  showReadiness(readiness, ui);
349
373
  ui.section("CONNECTION", "Re-entry");
350
- if (connected) ui.success("Account", "connected");
374
+ if (reauthorizationRequired) ui.warning("Account", "reconnect required");
375
+ else if (connected) ui.success("Account", "connected");
351
376
  else ui.warning("Account", "not connected");
352
- if (service.running) ui.success("Background", "running");
377
+ if (reauthorizationRequired && service.running) ui.warning("Background", "paused until this Mac is reconnected");
378
+ else if (service.running) ui.success("Background", "running");
353
379
  else if (service.installed) ui.warning("Background", "stopped");
354
380
  else ui.warning("Background", "not installed");
355
- if (connected && receiverReady) ui.success("Cloud", "online");
356
- else if (connected) ui.warning("Cloud", "unavailable");
381
+ if (receiverReady) ui.success("Cloud", "online");
382
+ else if (credentials) ui.warning("Cloud", "unavailable");
357
383
 
358
- if (!connected || !service.running) {
384
+ if (reauthorizationRequired) {
385
+ ui.next("re-entry connect", "Approve this Mac again in your browser; the old credential was rejected.");
386
+ } else if (!connected || !service.running) {
359
387
  ui.next("re-entry install", "Finish setup and start Re-entry in the background.");
360
388
  } else if (!receiverReady) {
361
389
  ui.next("re-entry status", "Check again when the Re-entry Cloud service is available.");
362
390
  } else {
363
391
  ui.complete("Everything looks good", "Re-entry is ready for approved work.");
392
+ ui.next("re-entry listen", "Watch live activity. Press Ctrl+C when you are done.");
364
393
  }
365
394
  } else {
366
395
  process.stdout.write(`${JSON.stringify({
367
396
  event: "connector_status",
368
397
  connected,
398
+ reauthorization_required: reauthorizationRequired,
369
399
  connector_id: credentials?.connector_id ?? null,
370
400
  receiver_origin: credentials?.receiver_origin ?? null,
371
401
  receiver_ready: receiverReady,
@@ -378,6 +408,119 @@ async function status(flags, ui) {
378
408
  }
379
409
  }
380
410
 
411
+ async function listen(flags, ui) {
412
+ if (ui.interactive) {
413
+ ui.begin("Live activity", "Watch the background Connector. Ctrl+C closes this view.");
414
+ }
415
+ const service = await inspectMacConnectorService();
416
+ const credentials = await new LocalConnectorCredentialStore({
417
+ filename: defaultCredentialFile(),
418
+ }).load();
419
+ const reauthorizationRequired = await hasConnectorReauthorizationRequired(defaultCredentialFile());
420
+ if (reauthorizationRequired || !service.running || !credentials) {
421
+ if (ui.interactive) {
422
+ if (reauthorizationRequired) {
423
+ ui.warning("Account", "reconnect required; the Cloud Receiver rejected this Mac");
424
+ ui.next("re-entry connect", "Approve this Mac again in your browser.");
425
+ }
426
+ if (!credentials) ui.warning("Account", "not connected");
427
+ if (!service.running) ui.warning("Background", "not running");
428
+ ui.next("re-entry install", "Finish setup and start the background Connector.");
429
+ } else {
430
+ process.stdout.write(`${JSON.stringify({
431
+ event: "connector_listener_unavailable",
432
+ connected: Boolean(credentials),
433
+ reauthorization_required: reauthorizationRequired,
434
+ service_running: service.running,
435
+ })}\n`);
436
+ }
437
+ return;
438
+ }
439
+
440
+ if (ui.interactive) {
441
+ ui.success("Background", "running");
442
+ ui.success("Account", "connected");
443
+ ui.wait("Listening for approved work…");
444
+ } else {
445
+ process.stdout.write('{"event":"connector_listener_started"}\n');
446
+ }
447
+
448
+ const stopSignal = createStopSignal();
449
+ try {
450
+ await followConnectorActivity({
451
+ paths: defaultConnectorLogFiles(),
452
+ signal: stopSignal.signal,
453
+ onEvent(event) {
454
+ if (ui.interactive) reportLiveActivity(event, ui);
455
+ else process.stdout.write(`${JSON.stringify(event)}\n`);
456
+ },
457
+ });
458
+ } finally {
459
+ stopSignal.close();
460
+ if (ui.interactive) {
461
+ ui.stopWait("Live view closed", "the background Connector is still running", "info");
462
+ } else {
463
+ process.stdout.write('{"event":"connector_listener_stopped"}\n');
464
+ }
465
+ }
466
+ }
467
+
468
+ async function testCodex(flags, positionals, ui) {
469
+ const prompt = requireTestPrompt(positionals);
470
+ const runtimeFlags = await withWorkspaceDirectory(flags, ui);
471
+ const readiness = inspectReadiness(runtimeFlags);
472
+ if (ui.interactive) {
473
+ ui.begin("Test Codex", "Run one local prompt through the Re-entry Codex adapter.");
474
+ ui.success("Codex", "ready");
475
+ ui.success("Workspace", readiness.workingDirectory);
476
+ ui.info("Prompt", prompt);
477
+ ui.wait("Starting a fresh Codex session…");
478
+ }
479
+ await runCodexPrompt({
480
+ workingDirectory: readiness.workingDirectory,
481
+ executable: readiness.installation.executable,
482
+ prompt,
483
+ commandTimeoutMs: readBoundedNumber(
484
+ flags["activation-timeout"] ?? 60_000,
485
+ 100,
486
+ 60_000,
487
+ "connector_activation_timeout_invalid",
488
+ ),
489
+ });
490
+ if (ui.interactive) {
491
+ ui.stopWait("Codex", "completed the local test");
492
+ ui.complete("Test passed", "The same fresh-session process seam is ready for Re-entry work.");
493
+ ui.next("re-entry listen", "Watch the background Connector for approved work.");
494
+ } else {
495
+ process.stdout.write('{"event":"connector_codex_test_passed"}\n');
496
+ }
497
+ }
498
+
499
+ function reportLiveActivity(event, ui) {
500
+ if (event.event === "connector_waiting") return;
501
+ if (event.event === "connector_activation_result") {
502
+ const accepted = event.outcome === "accepted";
503
+ ui.stopWait(
504
+ accepted ? "Work received" : "Work finished",
505
+ accepted ? "a fresh Codex session was started" : String(event.outcome ?? "unknown"),
506
+ accepted ? "success" : "warning",
507
+ );
508
+ } else if (event.event === "connector_reauthorization_required" || event.code === "connector_identity_invalid") {
509
+ ui.stopWait("Reconnect required", "the Cloud Receiver rejected this Mac's saved connection", "warning");
510
+ ui.next("re-entry connect", "Approve this Mac again in your browser.");
511
+ return;
512
+ } else if (event.event === "connector_poll_failed" || event.event === "local_connector_failed") {
513
+ ui.stopWait("Connection interrupted", "Re-entry is retrying", "warning");
514
+ } else if (event.event === "connector_ready") {
515
+ ui.stopWait("Connector", "ready", "success");
516
+ } else if (event.event === "connector_stopped") {
517
+ ui.stopWait("Background", "stopped", "warning");
518
+ } else {
519
+ ui.stopWait("Activity", event.event.replaceAll("_", " "), "info");
520
+ }
521
+ ui.wait("Listening for approved work…");
522
+ }
523
+
381
524
  async function stop(flags, ui) {
382
525
  if (ui.interactive) {
383
526
  ui.begin("Stop the Connector", "Pause background delivery without removing your account connection");
@@ -442,7 +585,7 @@ async function install(flags, ui) {
442
585
  if (ui.interactive) {
443
586
  ui.stopWait("Background", "running at login");
444
587
  ui.complete("You're all set", "Re-entry is connected and waiting for approved work.");
445
- ui.next("re-entry status", "Check the connection at any time.");
588
+ ui.next("re-entry listen", "Watch live activity. Press Ctrl+C when you are done.");
446
589
  } else {
447
590
  process.stdout.write(`${JSON.stringify({
448
591
  event: "connector_service_installed",
@@ -478,6 +621,14 @@ async function start(flags, ui) {
478
621
  if (!credentials) {
479
622
  if (ui.interactive) ui.info("Re-entry", "first run — browser approval is required once");
480
623
  credentials = await connect(runtimeFlags, ui, { quietHeader: true });
624
+ } else if (await hasConnectorReauthorizationRequired(credentialFile)) {
625
+ if (ui.interactive) {
626
+ ui.warning("Account", "this Mac needs to be connected again");
627
+ ui.next("re-entry connect", "Approve this Mac in your browser, then start the Connector again.");
628
+ } else {
629
+ process.stdout.write('{"event":"connector_reauthorization_required"}\n');
630
+ }
631
+ return;
481
632
  } else if (ui.interactive) {
482
633
  ui.success("Re-entry", "existing account connection loaded");
483
634
  }
@@ -513,6 +664,18 @@ async function start(flags, ui) {
513
664
  if (ui.interactive) ui.wait("Connected. Waiting for approved work…");
514
665
  }
515
666
  } catch (error) {
667
+ if (error?.code === "connector_identity_invalid") {
668
+ await markConnectorReauthorizationRequired(credentialFile, {
669
+ receiver_origin: credentials.receiver_origin,
670
+ });
671
+ if (ui.interactive) {
672
+ ui.stopWait("Reconnect required", "the Cloud Receiver rejected this Mac's saved connection", "warning");
673
+ ui.next("re-entry connect", "Approve this Mac again in your browser.");
674
+ } else {
675
+ process.stdout.write('{"event":"connector_reauthorization_required"}\n');
676
+ }
677
+ return;
678
+ }
516
679
  consecutiveErrors += 1;
517
680
  if (ui.interactive) {
518
681
  ui.stopWait("Receiver unavailable", `${safeErrorMessage(error)} · ${consecutiveErrors}/${maximumErrors}`, "warning");
@@ -668,9 +831,14 @@ function parseArguments(argumentsList) {
668
831
  ...(hasExplicitCommand ? argumentsList.slice(commandIndex + 1) : argumentsList.slice(commandIndex)),
669
832
  ];
670
833
  const flags = {};
834
+ const positionals = [];
671
835
  for (let index = 0; index < rest.length; index += 1) {
672
836
  const value = rest[index];
673
- if (!value.startsWith("--")) throw cliFailure("connector_argument_invalid");
837
+ if (!value.startsWith("--")) {
838
+ if (command !== "test") throw cliFailure("connector_argument_invalid");
839
+ positionals.push(value);
840
+ continue;
841
+ }
674
842
  const name = value.slice(2);
675
843
  if (name === "json" || name === "yes") {
676
844
  if (Object.hasOwn(flags, name)) throw cliFailure("connector_argument_invalid");
@@ -682,15 +850,17 @@ function parseArguments(argumentsList) {
682
850
  flags[name] = next;
683
851
  index += 1;
684
852
  }
685
- return { command, flags };
853
+ return { command, flags, positionals };
686
854
  }
687
855
 
688
- function validateCommandFlags(command, flags) {
856
+ function validateCommandFlags(command, flags, positionals) {
689
857
  const allowedByCommand = {
690
858
  doctor: new Set(["codex-binary", "codex-cd", "json"]),
691
859
  pair: new Set(["receiver", "code", "credential-file", "json"]),
692
860
  connect: new Set(["receiver", "device-name", "credential-file", "json"]),
693
861
  status: new Set(["credential-file", "codex-cd", "codex-binary", "json"]),
862
+ listen: new Set(["json"]),
863
+ test: new Set(["codex-cd", "codex-binary", "activation-timeout", "json"]),
694
864
  stop: new Set(["json"]),
695
865
  uninstall: new Set(["credential-file", "yes", "json"]),
696
866
  install: new Set([
@@ -725,7 +895,11 @@ function validateCommandFlags(command, flags) {
725
895
  ]),
726
896
  };
727
897
  const allowed = allowedByCommand[command];
728
- if (!allowed || Object.keys(flags).some((name) => !allowed.has(name))) {
898
+ if (
899
+ !allowed ||
900
+ Object.keys(flags).some((name) => !allowed.has(name)) ||
901
+ (command !== "test" && positionals.length > 0)
902
+ ) {
729
903
  throw cliFailure("connector_argument_invalid");
730
904
  }
731
905
  }
@@ -740,6 +914,14 @@ function defaultCredentialFile() {
740
914
  return join(homedir(), ".webmcp-connector", "credentials.json");
741
915
  }
742
916
 
917
+ function defaultConnectorLogFiles() {
918
+ const stateDirectory = join(homedir(), ".webmcp-connector");
919
+ return [
920
+ join(stateDirectory, "connector.log"),
921
+ join(stateDirectory, "connector-error.log"),
922
+ ];
923
+ }
924
+
743
925
  function defaultDeviceName() {
744
926
  const value = hostname().trim();
745
927
  return value.length >= 2 && Buffer.byteLength(value, "utf8") <= 80
@@ -755,6 +937,23 @@ function displayReceiver(origin) {
755
937
  }
756
938
  }
757
939
 
940
+ function requireTestPrompt(positionals) {
941
+ if (!Array.isArray(positionals) || positionals.length !== 1) {
942
+ throw cliFailure("connector_test_prompt_missing");
943
+ }
944
+ const value = positionals[0];
945
+ if (
946
+ typeof value !== "string" ||
947
+ value.trim() !== value ||
948
+ value.length === 0 ||
949
+ Buffer.byteLength(value, "utf8") > 4 * 1_024 ||
950
+ /[\u0000-\u001f\u007f]/.test(value)
951
+ ) {
952
+ throw cliFailure("connector_test_prompt_invalid");
953
+ }
954
+ return value;
955
+ }
956
+
758
957
  function readBoundedNumber(value, minimum, maximum, code) {
759
958
  const number = typeof value === "number" ? value : Number(value);
760
959
  if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
@@ -838,6 +1037,8 @@ function errorHint(error) {
838
1037
  connector_node_unsupported: "use Node.js 24 or newer, then run the command again",
839
1038
  connector_credentials_missing: "connect this Mac once with `re-entry connect`",
840
1039
  connector_credentials_expired: "run `re-entry connect` to authorize this Mac again",
1040
+ connector_identity_invalid: "run `re-entry connect` and approve this Mac again",
1041
+ connector_reauthorization_required: "run `re-entry connect` and approve this Mac again",
841
1042
  connector_pairing_code_missing: "ask the Host backend for a new pairing code, then run pair in a terminal",
842
1043
  pairing_code_invalid: "use the 16-character code returned by the Host, for example ABCD-EFGH-IJKL-MNOP",
843
1044
  host_subject_already_paired: "use the existing Connector credential or revoke/reset the preview pairing",
@@ -853,6 +1054,9 @@ function errorHint(error) {
853
1054
  connector_service_load_failed: "run `re-entry install` again; if it still fails, inspect the Connector error log",
854
1055
  connector_uninstall_confirmation_required: "run uninstall interactively and type DELETE, or pass `--yes` in a deliberate script",
855
1056
  connector_service_stop_failed: "check the Connector status and try `re-entry stop` again",
1057
+ connector_test_prompt_missing: "use `re-entry test \"Reply with: Re-entry is working.\"`",
1058
+ connector_test_prompt_invalid: "use one short, single-line prompt inside quotes",
1059
+ connector_activation_timeout_invalid: "use an activation timeout between 100 and 60000 milliseconds",
856
1060
  };
857
1061
  return hints[error?.code] ?? "check the Receiver address and try again";
858
1062
  }
@@ -11,8 +11,11 @@ const SPINNER_FRAMES = ["·", "✦", "✧", "✦"];
11
11
  const RULE = " ─────────────────────────────────────────────";
12
12
 
13
13
  export const REENTRY_WORDMARK = Object.freeze([
14
- " █▀█ █▀▀ █▀▀ █▄░█ ▀█▀ █▀█ █▄█",
15
- " █▀▄ ██▄ ██▄ █░▀█ ░█░ █▀▄ ░█░",
14
+ " ____ _____ _____ _ _ _____ ______ __",
15
+ " | _ \\| ____| | ____| \\ | |_ _| _ \\ \\ / /",
16
+ " | |_) | _| _____| _| | \\| | | | | |_) | \\ V /",
17
+ " | _ <| |__|_____| |___| |\\ | | | | _ < | |",
18
+ " |_| \\_\\_____| |_____|_| \\_| |_| |_| \\_\\ |_|",
16
19
  ]);
17
20
 
18
21
  /**