totemlayer 0.1.40 → 0.1.42

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
@@ -4,9 +4,9 @@ Connect your agent to Totem, exchange messages, coordinate approved work, and ve
4
4
 
5
5
  ## Connect with automatic listener setup
6
6
 
7
- Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.40.tgz`, `totem`, `connect`, `--provider`, and `codex` (or `claude` for Claude Code). The host runs the connection itself; the owner does not run a separate listener command. Node.js 22.14 or newer and the selected signed-in client must be available.
7
+ Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.42.tgz`, `totem`, `connect`, `--provider`, and `codex` (or `claude` for Claude Code). The host runs the connection itself; the owner does not run a separate listener command. Node.js 22.14 or newer and the selected signed-in client must be available.
8
8
 
9
- The connection responds to MCP initialization while browser sign-in is pending. `totem_get_connection_status` reports progress; normal tools become available after the exact approved identity is pinned privately. The listener is installed and starts automatically, preserving explicit pauses and existing permissions. Existing-chat delivery is verified separately; the background executor is not evidence of waking the original chat. Remote HTTP connectors alone cannot install local software.
9
+ The connection responds to MCP initialization while browser sign-in is pending. `totem_get_connection_status` reports progress; normal tools become available after the exact approved identity is pinned privately. Before receiving work, the Codex connection verifies the saved identity, durable chat binding, automatic startup, and an exact-chat command callback. It creates a dedicated saved Totem inbox chat when none is bound. Reconnects reuse that chat; explicit owner-selected chats and pauses are preserved. Missing capabilities leave work queued with a setup blocker. Remote HTTP connectors alone cannot install local software.
10
10
 
11
11
  ## Connect any MCP client
12
12
 
@@ -24,7 +24,7 @@ The hosted connection needs no local installation. Sign in to Totem, select your
24
24
  For a local stdio connection, install the CLI with Node.js 22.14 or newer:
25
25
 
26
26
  ```sh
27
- npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.40.tgz
27
+ npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.42.tgz
28
28
  ```
29
29
 
30
30
  Save your agent's connection key in a private file. Protect it with mode 0600 on macOS/Linux or your user's filesystem ACL on Windows. Configure these fields in your client's MCP settings:
@@ -43,7 +43,7 @@ Reload the client if needed, discover the available tools, and call `totem_get_i
43
43
  Run the guided installer on the host where your agent will execute work:
44
44
 
45
45
  ```sh
46
- npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.40.tgz totem install
46
+ npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.42.tgz totem install
47
47
  ```
48
48
 
49
49
  The installer opens Totem sign-in and saves the approved connection privately. It checks your configured agent runtime, configures supported clients, and starts the listener when an authorized executor is available. The supervised listener is enabled by default for this setup and starts when you sign in to the computer on hosts with a supported user service manager. It recovers after a process exit and retries temporary connection failures. An explicit stop stays paused across restarts.
@@ -53,7 +53,7 @@ For manual setup, use `totem mcp install` with a supported client and the privat
53
53
  For a host without a browser:
54
54
 
55
55
  ```sh
56
- npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.40.tgz totem login --device-login --no-browser
56
+ npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.42.tgz totem login --device-login --no-browser
57
57
  ```
58
58
 
59
59
  Open the printed link on your own device and compare the connection code. Preserve existing MCP entries; inspect conflicts before replacing them. If an executor is unavailable, the saved connection remains available while you complete runtime setup.
@@ -71,6 +71,8 @@ totem diagnose --json
71
71
 
72
72
  Check the observed receive, wake, and reply results. Stop pauses automatic processing until you start it again. The host must stay awake and online. Upgrades retain saved connections and recovery records. Hosted Context processing is temporarily paused.
73
73
 
74
+ Local connections register automatic startup for the signed-in user: launchd on macOS, Task Scheduler on Windows, or a current-user Startup shortcut when task registration is unavailable. The supervisor restarts crashed children with backoff. Startup registration is verified separately from current readiness. Windows/macOS startup policy, deliberate pauses, revoked credentials, sleep, logout and shutdown still apply; no administrator access or new agent permissions are granted.
75
+
74
76
  Connection acceptance and action execution have separate limits. An informed **Accept & start listener** action can enable text replies for that connection; it does not grant access to files, Missions, or arbitrary tools. An explicitly authorized general runtime can process the work its owner permits. Coding work also needs a tool-enabled agent session and approved repository access.
75
77
 
76
78
  Each owner chooses **Ask me** or **Full access** in Totem Permissions. Connection restrictions and exact, single-use dispatch checks still apply. Reading a message or notification grants no execution permission.
@@ -83,7 +85,7 @@ Local and hosted MCP tool arguments may contain at most 10,000 combined object m
83
85
 
84
86
  For pending incoming requests, these tools ask compatible MCP clients to display a native owner-review notification. The notification opens the authenticated receiving-owner review in Totem. Accepting the notification never approves an action; the owner must inspect and approve or deny the exact request. Full access requests that are already approved do not prompt. Clients without MCP elicitation support receive the same logs and an owner-review link.
85
87
 
86
- A hosted HTTP MCP connection needs an active harness turn to read activity; it cannot install a service on the laptop. Supported local client installers also prepare the identity-bound listener by default. Reconnecting local MCP repairs an existing enabled listener, but never changes a saved chat binding, credentials or owner pause. On hosts that support existing-chat delivery, that chat needs an explicitly bound listener profile. Its readiness check queues a bounded challenge and requires a callback from that exact chat using its existing tools and identity. A queued message or app-opening request alone never reports readiness. Closed-app behavior must be verified on the receiving host; an unavailable callback leaves the listener blocked. The host must remain awake and online.
88
+ A hosted HTTP MCP connection needs an active harness turn to read activity; it cannot install a service on the laptop. Supported local client installers also prepare the identity-bound listener by default. Reconnecting local MCP repairs an existing enabled listener, but never changes a saved chat binding, credentials or owner pause. Supported Codex hosts create and verify a dedicated saved chat when no chat is bound. Existing explicit bindings are retained. Codex must support the public app-server session API and queue command. Claude MCP tools remain available, but automatic interactive Claude chat creation is currently unsupported and reports HARNESS_UNSUPPORTED; no text-only responder is substituted. Its readiness check queues a bounded challenge and requires a callback from that exact chat using its existing tools and identity. A queued message or app-opening request alone never reports readiness. Closed-app behavior must be verified on the receiving host; an unavailable callback leaves the listener blocked. The host must remain awake and online.
87
89
 
88
90
  Follow the owner's instructions before sending messages, sharing code, or delegating Missions. For collaboration, agree on the repository, base commit, separate branches, file scopes, and acceptance criteria. Report the resulting commit and validation evidence.
89
91
 
@@ -189,6 +189,7 @@ async function normalizedSettings(input, directory) {
189
189
  "ollamaUrl",
190
190
  "nimUrl",
191
191
  "codexThreadId",
192
+ "interactiveRequired",
192
193
  ]);
193
194
  if (Object.keys(input).some((key) => !allowed.has(key))) fail();
194
195
  const baseUrl = origin(input.baseUrl);
@@ -223,6 +224,7 @@ async function normalizedSettings(input, directory) {
223
224
  const qwenUrl = `${endpoint.origin}/v1`;
224
225
  if (input.client !== undefined && !["codex", "claude"].includes(input.client)) fail();
225
226
  let codexThreadId;
227
+ if (input.interactiveRequired !== undefined && input.interactiveRequired !== true) fail();
226
228
  if (input.codexThreadId !== undefined) {
227
229
  if (
228
230
  provider !== "codex" ||
@@ -281,6 +283,7 @@ async function normalizedSettings(input, directory) {
281
283
  ),
282
284
  ...(input.client === undefined ? {} : { client: input.client }),
283
285
  ...(codexThreadId === undefined ? {} : { codexThreadId }),
286
+ ...(input.interactiveRequired === true ? { interactiveRequired: true } : {}),
284
287
  };
285
288
  }
286
289
 
@@ -404,6 +407,64 @@ export async function withAgentProfileLock(profile, operation) {
404
407
  }
405
408
  }
406
409
 
410
+ /** Narrow startup migration; retain the exact credential generation and all scopes. */
411
+ export async function bindInteractiveProfile(profile, threadId, options = {}) {
412
+ if (threadId !== undefined && (!UUID.test(threadId) || profile.provider !== "codex")) fail();
413
+ return withAgentProfileLock(profile, async () => {
414
+ const binding = profileBindings.get(profile);
415
+ const { readAgentRuntimeStatus, requestStop } = await import("./agent-runner.mjs");
416
+ const readStatus = options.readStatus ?? readAgentRuntimeStatus;
417
+ const status = await readStatus(profile);
418
+ if (status.running) {
419
+ if (!["waiting", "connected", "blocked", "reconnecting"].includes(status.phase))
420
+ throw Object.assign(new Error("Finish the current work before binding this listener."), {
421
+ code: "HARNESS_BUSY",
422
+ });
423
+ await (options.stop ?? requestStop)(profile, status.runId);
424
+ const deadline = Date.now() + (options.stopTimeoutMs ?? 15000);
425
+ while ((await readStatus(profile)).running) {
426
+ if (Date.now() >= deadline)
427
+ throw Object.assign(new Error("The previous listener has not stopped."), {
428
+ code: "HARNESS_BUSY",
429
+ });
430
+ await new Promise((resolve) => setTimeout(resolve, 100));
431
+ }
432
+ }
433
+ const record = JSON.parse(binding.serialized);
434
+ if (record.codexThreadId && threadId && record.codexThreadId !== threadId) fail();
435
+ const input = Object.fromEntries(
436
+ Object.entries(record).filter(
437
+ ([key]) => !["version", "profileName", "credentialGeneration"].includes(key),
438
+ ),
439
+ );
440
+ const settings = await normalizedSettings(
441
+ { ...input, interactiveRequired: true, ...(threadId ? { codexThreadId: threadId } : {}) },
442
+ binding.directory,
443
+ );
444
+ const serialized = JSON.stringify({
445
+ version: 1,
446
+ profileName: profile.profileName,
447
+ ...settings,
448
+ credentialGeneration: record.credentialGeneration,
449
+ });
450
+ const temporary = join(binding.directory, `.harness-${randomUUID()}.tmp`);
451
+ try {
452
+ await writePrivate(temporary, serialized);
453
+ await readAgentCredential(profile);
454
+ await rename(temporary, join(binding.directory, "profile.json"));
455
+ } finally {
456
+ await unlink(temporary).catch(() => {});
457
+ }
458
+ return publicProfile(
459
+ profile.profileName,
460
+ binding.directory,
461
+ settings,
462
+ record.credentialGeneration,
463
+ serialized,
464
+ );
465
+ });
466
+ }
467
+
407
468
  export async function saveAgentProfile(profileName = "default", input, credential, options = {}) {
408
469
  let lock;
409
470
  let lockPath;
@@ -287,6 +287,14 @@ function eventState(event) {
287
287
 
288
288
  /** Only fixed labels and validated IDs leave the local worker; model output never becomes a log. */
289
289
  export async function runAgentSession(profile, options = {}) {
290
+ if (
291
+ profile.interactiveRequired &&
292
+ (!profile.codexThreadId ||
293
+ !(await (await import("./harness-bootstrap.mjs")).harnessStartupReady(profile)))
294
+ )
295
+ throw Object.assign(new Error("The interactive harness has not been bound."), {
296
+ code: "HARNESS_SETUP_REQUIRED",
297
+ });
290
298
  const intervalMs = options.statusIntervalMs ?? 1000;
291
299
  if (!Number.isInteger(intervalMs) || intervalMs < 10 || intervalMs > 60_000)
292
300
  throw new Error("Invalid agent status interval.");
@@ -521,6 +529,15 @@ export async function agentStartMain(args, options = {}) {
521
529
  return 0;
522
530
  }
523
531
  const profile = await readAgentProfile(parsed.profile, { environment });
532
+ if (
533
+ profile.interactiveRequired &&
534
+ (!profile.codexThreadId ||
535
+ !(await (await import("./harness-bootstrap.mjs")).harnessStartupReady(profile)))
536
+ )
537
+ throw Object.assign(
538
+ new Error("This listener is waiting for its owner-bound interactive harness."),
539
+ { code: "HARNESS_SETUP_REQUIRED" },
540
+ );
524
541
  if (!options.automatic) {
525
542
  const { setAutomaticEnabled } = await import("./automatic-runtime.mjs");
526
543
  await setAutomaticEnabled(profile, true);
@@ -462,6 +462,7 @@ export async function agentSetupMain(args, options = {}) {
462
462
  repositoryRoots,
463
463
  ...(["codex", "claude"].includes(mode) ? { client: mode } : {}),
464
464
  ...(codexThreadId === undefined ? {} : { codexThreadId }),
465
+ ...(previous?.interactiveRequired ? { interactiveRequired: true } : {}),
465
466
  },
466
467
  key,
467
468
  {
@@ -266,7 +266,9 @@ export async function acquireSupervisorLock(directory, options = {}) {
266
266
  } else {
267
267
  const actual = await identity(current.owner.pid);
268
268
  if (actual && (!current.owner.identity || current.owner.identity === actual))
269
- throw new Error("The Totem supervisor is already running.");
269
+ throw Object.assign(new Error("The Totem supervisor is already running."), {
270
+ code: "SUPERVISOR_ALREADY_RUNNING",
271
+ });
270
272
  }
271
273
  await removeObserved(current?.file);
272
274
  }
@@ -332,7 +334,8 @@ if (
332
334
  process.argv[1]?.endsWith("/runtime/supervisor.mjs") ||
333
335
  process.argv[1]?.endsWith("\\runtime\\supervisor.mjs")
334
336
  ) {
335
- await automaticRuntimeMain().catch(() => {
336
- process.exitCode = 1;
337
+ await automaticRuntimeMain().catch((error) => {
338
+ // A competing launcher must exit instead of building another retry loop.
339
+ process.exitCode = error.code === "SUPERVISOR_ALREADY_RUNNING" ? 0 : 1;
337
340
  });
338
341
  }
@@ -0,0 +1,344 @@
1
+ import { spawn, execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { readFile } from "node:fs/promises";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ import {
7
+ localClientEnvironment,
8
+ installedClientInvocation,
9
+ streamingClientInvocation,
10
+ } from "./platform-client.mjs";
11
+ import { privateDirectory, privateRead, privateWrite } from "./runtime-installation.mjs";
12
+ import { bindInteractiveProfile, readAgentCredential } from "./agent-profile.mjs";
13
+ import { automaticEnabled, acquireSupervisorLock } from "./automatic-runtime.mjs";
14
+ import { checkCodexQueueSupport } from "./executor-readiness.mjs";
15
+
16
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
17
+ const VERSION = JSON.parse(
18
+ await readFile(new URL("../package.json", import.meta.url), "utf8"),
19
+ ).version;
20
+ const failure = (code) =>
21
+ Object.assign(
22
+ new Error("Interactive harness setup needs attention. Check this connection's setup status."),
23
+ { code },
24
+ );
25
+ const profileFingerprint = async (profile) =>
26
+ createHash("sha256")
27
+ .update(
28
+ JSON.stringify([
29
+ profile.agentId,
30
+ profile.address,
31
+ profile.environment,
32
+ profile.baseUrl,
33
+ profile.provider,
34
+ createHash("sha256")
35
+ .update(await readAgentCredential(profile))
36
+ .digest("hex"),
37
+ ]),
38
+ )
39
+ .digest("hex");
40
+ export async function pendingHarnessMigration(profile) {
41
+ const receipt = await privateRead(join(profile.stateDirectory, "harness-bootstrap.json")).catch(
42
+ (error) => {
43
+ if (error.code !== "ENOENT") throw error;
44
+ },
45
+ );
46
+ return Boolean(
47
+ profile.interactiveRequired &&
48
+ profile.codexThreadId &&
49
+ receipt?.version === 1 &&
50
+ receipt.binding === (await profileFingerprint(profile)) &&
51
+ ["created", "bound"].includes(receipt.state) &&
52
+ receipt.threadId === profile.codexThreadId,
53
+ );
54
+ }
55
+
56
+ // A service can start its supervisor immediately while registration is still in
57
+ // progress. Only a verified registration may release the interactive consumer.
58
+ export async function confirmHarnessStartup(profile) {
59
+ if (!(await pendingHarnessMigration(profile))) throw failure("HARNESS_SETUP_REQUIRED");
60
+ await privateWrite(join(profile.stateDirectory, "harness-startup.json"), {
61
+ version: VERSION,
62
+ binding: await profileFingerprint(profile),
63
+ threadId: profile.codexThreadId,
64
+ });
65
+ }
66
+ export async function harnessStartupReady(profile) {
67
+ const record = await privateRead(join(profile.stateDirectory, "harness-startup.json")).catch(
68
+ (error) => {
69
+ if (error.code !== "ENOENT") throw error;
70
+ },
71
+ );
72
+ return Boolean(
73
+ record?.version === VERSION &&
74
+ record.threadId === profile.codexThreadId &&
75
+ record.binding === (await profileFingerprint(profile)) &&
76
+ (await pendingHarnessMigration(profile)),
77
+ );
78
+ }
79
+
80
+ function hostEnvironment(environment) {
81
+ const env = localClientEnvironment(environment);
82
+ for (const key of Object.keys(env))
83
+ if (
84
+ /^(TOTEM|AGENTTRUST)_|^(OPENAI_API_KEY|ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)$/i.test(key)
85
+ )
86
+ delete env[key];
87
+ return env;
88
+ }
89
+ function hostInvocation(args, env, options) {
90
+ if ((options.platform ?? process.platform) !== "win32") return { command: "codex", args };
91
+ return installedClientInvocation("codex", args, env);
92
+ }
93
+ /** Use the public metadata API; this short-lived process never runs a model turn.
94
+ * The saved chat must separately prove wake/reply through the actual harness. */
95
+ export function connectCodexHarness(environment = process.env, options = {}) {
96
+ const env = hostEnvironment(environment);
97
+ const args = ["app-server", "--stdio"];
98
+ let invocation = hostInvocation(args, env, options);
99
+ if ((options.platform ?? process.platform) === "win32") {
100
+ if (invocation.args.includes("-EncodedCommand"))
101
+ invocation = streamingClientInvocation("codex", args, env);
102
+ }
103
+ const child = (options.spawn ?? spawn)(invocation.command, invocation.args, {
104
+ env,
105
+ stdio: ["pipe", "pipe", "ignore"],
106
+ windowsHide: true,
107
+ });
108
+ let nextId = 0,
109
+ buffer = "",
110
+ closed = false;
111
+ const pending = new Map();
112
+ const decoder = new StringDecoder("utf8");
113
+ let closePromise,
114
+ finishClose,
115
+ closeTimer,
116
+ processClosed = false;
117
+ const close = () => {
118
+ if (closePromise) return closePromise;
119
+ closed = true;
120
+ closePromise = new Promise((resolve) => {
121
+ finishClose = resolve;
122
+ });
123
+ for (const item of pending.values()) {
124
+ clearTimeout(item.timer);
125
+ item.reject(failure("HARNESS_UNAVAILABLE"));
126
+ }
127
+ pending.clear();
128
+ options.signal?.removeEventListener("abort", close);
129
+ // EOF lets npm's Node wrapper wait for its native Codex child to exit.
130
+ // Killing only the wrapper leaks the native child and its Windows DB locks.
131
+ closeTimer = setTimeout(() => {
132
+ if ((options.platform ?? process.platform) === "win32" && Number.isSafeInteger(child.pid)) {
133
+ execFile(
134
+ "taskkill.exe",
135
+ ["/PID", String(child.pid), "/T", "/F"],
136
+ { windowsHide: true, timeout: 3000 },
137
+ () => finishClose(),
138
+ );
139
+ } else {
140
+ child.kill();
141
+ finishClose();
142
+ }
143
+ }, options.closeTimeoutMs ?? 2500);
144
+ child.stdin.end();
145
+ if (processClosed) {
146
+ clearTimeout(closeTimer);
147
+ finishClose();
148
+ }
149
+ return closePromise;
150
+ };
151
+ child.once("error", close);
152
+ child.once("close", () => {
153
+ processClosed = true;
154
+ close();
155
+ clearTimeout(closeTimer);
156
+ finishClose();
157
+ });
158
+ child.stdin.on("error", close);
159
+ options.signal?.addEventListener("abort", close, { once: true });
160
+ if (options.signal?.aborted) close();
161
+ child.stdout.on("data", (data) => {
162
+ buffer += decoder.write(data);
163
+ if (Buffer.byteLength(buffer) > 262144) {
164
+ close();
165
+ return;
166
+ }
167
+ let end;
168
+ while ((end = buffer.indexOf("\n")) >= 0) {
169
+ const line = buffer.slice(0, end);
170
+ buffer = buffer.slice(end + 1);
171
+ if (!line.trim()) continue;
172
+ let message;
173
+ try {
174
+ message = JSON.parse(line);
175
+ } catch {
176
+ close();
177
+ return;
178
+ }
179
+ // Bootstrap performs no model turns or tool operations. Never auto-approve
180
+ // an unsolicited request or copy its payload to an owner-visible channel.
181
+ if (message.method && message.id !== undefined) {
182
+ child.stdin.write(
183
+ JSON.stringify({
184
+ id: message.id,
185
+ error: { code: -32601, message: "Bootstrap does not handle execution requests" },
186
+ }) + "\n",
187
+ );
188
+ continue;
189
+ }
190
+ const item = pending.get(message.id);
191
+ if (!item) continue;
192
+ pending.delete(message.id);
193
+ clearTimeout(item.timer);
194
+ if (message.error) item.reject(failure("HARNESS_UNAVAILABLE"));
195
+ else item.resolve(message.result);
196
+ }
197
+ });
198
+ const request = (method, params = {}) =>
199
+ new Promise((resolve, reject) => {
200
+ if (closed) {
201
+ reject(failure("HARNESS_UNAVAILABLE"));
202
+ return;
203
+ }
204
+ const id = ++nextId,
205
+ timer = setTimeout(() => {
206
+ pending.delete(id);
207
+ reject(failure("HARNESS_UNAVAILABLE"));
208
+ }, options.timeoutMs ?? 15000);
209
+ pending.set(id, { resolve, reject, timer });
210
+ child.stdin.write(JSON.stringify({ id, method, params }) + "\n", (error) => {
211
+ if (error) {
212
+ pending.delete(id);
213
+ clearTimeout(timer);
214
+ reject(failure("HARNESS_UNAVAILABLE"));
215
+ }
216
+ });
217
+ });
218
+ return {
219
+ request,
220
+ close,
221
+ async initialize() {
222
+ await request("initialize", {
223
+ clientInfo: { name: "totem_listener", title: "Totem listener", version: "1" },
224
+ });
225
+ child.stdin.write(JSON.stringify({ method: "initialized", params: {} }) + "\n");
226
+ },
227
+ };
228
+ }
229
+
230
+ /** No receiving begins until a durable, exact host session exists and is checked. */
231
+ export async function ensureInteractiveHarness(profile, environment = process.env, options = {}) {
232
+ if (!(await (options.enabled ?? automaticEnabled)(profile))) return profile;
233
+ let release;
234
+ try {
235
+ release = await acquireSupervisorLock(join(profile.stateDirectory, "harness-setup"));
236
+ } catch {
237
+ throw failure("HARNESS_BUSY");
238
+ }
239
+ try {
240
+ return await prepareHarness(profile, environment, options);
241
+ } finally {
242
+ await release();
243
+ }
244
+ }
245
+
246
+ async function prepareHarness(profile, environment, options) {
247
+ options.signal?.throwIfAborted();
248
+ const update = options.bindProfile ?? bindInteractiveProfile;
249
+ // Persist the requirement first. The normal supervisor must not race setup by
250
+ // starting the old, unbound conversation-only receiver after a crash.
251
+ if (!profile.interactiveRequired) profile = await update(profile, undefined, options);
252
+ if (profile.provider !== "codex") throw failure("HARNESS_UNSUPPORTED");
253
+ await (options.checkQueue ?? checkCodexQueueSupport)(environment);
254
+ const fingerprint = await profileFingerprint(profile);
255
+ const path = join(profile.stateDirectory, "harness-bootstrap.json");
256
+ let receipt = await privateRead(path).catch((error) => {
257
+ if (error.code !== "ENOENT") throw error;
258
+ });
259
+ if (
260
+ receipt &&
261
+ (receipt.version !== 1 ||
262
+ receipt.binding !== fingerprint ||
263
+ !["creating", "created", "bound"].includes(receipt.state) ||
264
+ (receipt.state !== "creating" && !UUID.test(receipt.threadId ?? "")))
265
+ )
266
+ throw failure("HARNESS_SETUP_REQUIRED");
267
+ if (profile.codexThreadId && receipt?.threadId && profile.codexThreadId !== receipt.threadId)
268
+ throw failure("HARNESS_SETUP_REQUIRED");
269
+ const rpc = (options.connect ?? connectCodexHarness)(environment, options);
270
+ try {
271
+ await rpc.initialize();
272
+ const cwd = join(profile.stateDirectory, "harness-workspace");
273
+ if (receipt?.state === "creating") {
274
+ // Recover only metadata in this identity's dedicated workspace. A lost
275
+ // creation reply never permits another thread/start or a global search.
276
+ const list = await rpc.request("thread/list", {
277
+ cwd,
278
+ archived: false,
279
+ sourceKinds: [],
280
+ limit: 2,
281
+ });
282
+ if (
283
+ list?.data?.length !== 1 ||
284
+ list.nextCursor ||
285
+ !UUID.test(list.data[0]?.id ?? "") ||
286
+ list.data[0].cwd !== cwd ||
287
+ list.data[0].ephemeral !== false
288
+ )
289
+ throw failure("HARNESS_CREATE_UNCERTAIN");
290
+ receipt = {
291
+ version: 1,
292
+ binding: fingerprint,
293
+ state: "created",
294
+ threadId: list.data[0].id,
295
+ createdLocally: true,
296
+ };
297
+ await privateWrite(path, receipt);
298
+ }
299
+ let threadId = profile.codexThreadId ?? receipt?.threadId;
300
+ if (!threadId) {
301
+ await privateDirectory(cwd);
302
+ await privateWrite(path, { version: 1, binding: fingerprint, state: "creating" });
303
+ // Omit model, sandbox and approval overrides: the owner's configured
304
+ // policy and instructions remain authoritative for the new host session.
305
+ const result = await rpc.request("thread/start", { cwd });
306
+ if (
307
+ !UUID.test(result?.thread?.id ?? "") ||
308
+ result.thread.ephemeral !== false ||
309
+ result.thread.cwd !== cwd
310
+ )
311
+ throw failure("HARNESS_CREATE_UNCERTAIN");
312
+ threadId = result.thread.id;
313
+ receipt = {
314
+ version: 1,
315
+ binding: fingerprint,
316
+ state: "created",
317
+ threadId,
318
+ createdLocally: true,
319
+ };
320
+ await privateWrite(path, receipt);
321
+ }
322
+ if (receipt?.state === "created")
323
+ await rpc.request("thread/name/set", { threadId, name: "Totem inbox" });
324
+ const checked = await rpc.request("thread/read", { threadId });
325
+ if (
326
+ checked?.thread?.id !== threadId ||
327
+ checked.thread.ephemeral !== false ||
328
+ checked.thread.archived === true ||
329
+ (receipt?.createdLocally && checked.thread.cwd !== cwd)
330
+ )
331
+ throw failure("HARNESS_SETUP_REQUIRED");
332
+ if (!profile.codexThreadId) profile = await update(profile, threadId, options);
333
+ await privateWrite(path, {
334
+ version: 1,
335
+ binding: fingerprint,
336
+ state: "bound",
337
+ threadId,
338
+ createdLocally: receipt?.createdLocally === true,
339
+ });
340
+ return profile;
341
+ } finally {
342
+ await rpc.close();
343
+ }
344
+ }
@@ -22,6 +22,16 @@ export async function activateConnection(profile, env = process.env, options = {
22
22
  ...(profile.agentTokenEnv ? { TOTEM_AGENT_TOKEN_ENV: profile.agentTokenEnv } : {}),
23
23
  });
24
24
  if (!enabled) return { state: "paused", ...service, entry: runtime.cli };
25
+ if (profile.interactiveRequired && !service.persistent)
26
+ return {
27
+ state: "needs_attention",
28
+ ready: false,
29
+ ...service,
30
+ entry: runtime.cli,
31
+ code: "HARNESS_SETUP_REQUIRED",
32
+ message:
33
+ "Automatic startup is not registered. Incoming work remains queued until host setup succeeds.",
34
+ };
25
35
  const { verifyExecutorReady } = await import("./executor-readiness.mjs");
26
36
  try {
27
37
  if (profile.codexThreadId) await (options.verifyExecutor ?? verifyExecutorReady)(profile, env);
@@ -35,6 +45,10 @@ export async function activateConnection(profile, env = process.env, options = {
35
45
  "Your connection is saved, but the selected agent could not complete its startup check. Run totem status to inspect the blocker.",
36
46
  };
37
47
  }
48
+ if (profile.interactiveRequired) {
49
+ const { confirmHarnessStartup } = await import("./harness-bootstrap.mjs");
50
+ await confirmHarnessStartup(profile);
51
+ }
38
52
  const { agentStartMain } = await import("./agent-runner.mjs");
39
53
  const result = await (options.start ?? agentStartMain)(
40
54
  ["--profile", profile.profileName, "--background", "--json"],
@@ -4,6 +4,7 @@ import { Totem } from "./sdk/index.js";
4
4
  import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
5
5
  import { runtimeRoot } from "./runtime-installation.mjs";
6
6
  import { automaticEnabled } from "./automatic-runtime.mjs";
7
+ import { ensureInteractiveHarness } from "./harness-bootstrap.mjs";
7
8
 
8
9
  function listenerBinding(profile) {
9
10
  return {
@@ -81,7 +82,6 @@ export async function ensureMcpListener(config, environment = process.env, optio
81
82
  "MCP tools are connected, but this identity has no saved listener on this computer. The owner must run totem setup with this agent's private key and selected executor once. Hosted MCP cannot install a local listener.",
82
83
  };
83
84
  const setup = options.setup ?? (await import("./agent-setup.mjs")).agentSetupMain;
84
- let output = "";
85
85
  const result = await setup(
86
86
  [
87
87
  "--profile",
@@ -100,11 +100,8 @@ export async function ensureMcpListener(config, environment = process.env, optio
100
100
  ],
101
101
  {
102
102
  env: environment,
103
- stdout: {
104
- write(chunk) {
105
- output = (output + chunk).slice(-65536);
106
- },
107
- },
103
+ deferActivation: true,
104
+ stdout: { write() {} },
108
105
  stderr: { write() {} },
109
106
  },
110
107
  );
@@ -116,9 +113,6 @@ export async function ensureMcpListener(config, environment = process.env, optio
116
113
  "MCP tools are connected. The listener setup did not confirm readiness; inspect totem status. No repository or peer permission was added.",
117
114
  };
118
115
  profile = await readAgentProfile(identity.id, { environment });
119
- const created = JSON.parse(output);
120
- if (!created.automatic) throw new Error("Listener setup did not return its startup status.");
121
- return { ...created.automatic, ...listenerBinding(profile) };
122
116
  }
123
117
  if (!(await automaticEnabled(profile)))
124
118
  return {
@@ -129,6 +123,21 @@ export async function ensureMcpListener(config, environment = process.env, optio
129
123
  "This listener was explicitly stopped. Its opt-out remains in effect; totem start can resume it.",
130
124
  };
131
125
  const activate = options.activate ?? (await import("./installation.mjs")).activateConnection;
126
+ if (["codex", "claude"].includes(profile.client ?? profile.provider)) {
127
+ try {
128
+ profile = await (options.ensureHarness ?? ensureInteractiveHarness)(profile, environment);
129
+ } catch (error) {
130
+ return {
131
+ state: "needs_attention",
132
+ ready: false,
133
+ persistent: false,
134
+ ...listenerBinding(profile),
135
+ code: error.code ?? "HARNESS_SETUP_REQUIRED",
136
+ message:
137
+ "The owner-bound interactive harness has not passed setup. Incoming work remains queued; no text-only receiver was started.",
138
+ };
139
+ }
140
+ }
132
141
  const status = await activate(profile, environment);
133
142
  return { ...status, ...listenerBinding(profile) };
134
143
  }
@@ -149,14 +158,26 @@ export async function readMcpListenerStatus(config, environment, initial, option
149
158
  throw new Error("Listener binding changed");
150
159
  const status = { ...initial, ready: false, checkedAt: new Date().toISOString() };
151
160
  if (!(await automaticEnabled(profile))) return { ...status, state: "paused" };
161
+ if (initial.state === "needs_attention" && initial.code?.startsWith("HARNESS_")) return status;
152
162
  const readStatus =
153
163
  options.readStatus ?? (await import("./agent-runner.mjs")).readAgentRuntimeStatus;
154
164
  const current = await readStatus(profile);
155
165
  return {
156
166
  ...status,
157
167
  ...current,
158
- state: !current.running ? "offline" : current.ready ? "ready" : "needs_attention",
159
- ready: Boolean(current.running && current.ready),
168
+ state: !current.running
169
+ ? "offline"
170
+ : current.ready &&
171
+ (!profile.interactiveRequired || profile.codexThreadId) &&
172
+ initial.persistent
173
+ ? "ready"
174
+ : "needs_attention",
175
+ ready: Boolean(
176
+ current.running &&
177
+ current.ready &&
178
+ initial.persistent &&
179
+ (!profile.interactiveRequired || profile.codexThreadId),
180
+ ),
160
181
  };
161
182
  } catch {
162
183
  return {
package/bin/mcp.mjs CHANGED
@@ -7,7 +7,7 @@ import { Totem } from "./sdk/index.js";
7
7
  import { inspectLocalRepository, verifyLocalRepoClaim } from "./repository.mjs";
8
8
  import { createApprovalReview } from "./mcp-approval-review.mjs";
9
9
  import { loadMcpIdentityBinding, createMcpIdentityGuard } from "./mcp-identity-binding.mjs";
10
- const VERSION = "0.1.40";
10
+ const VERSION = "0.1.42";
11
11
  const MAX_KEY_BYTES = 8 * 1024;
12
12
  const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;
13
13
  const MAX_TOOL_INPUT_ELEMENTS = 10_000;
@@ -12,6 +12,7 @@ import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
12
12
  import { automaticEnabled, acquireSupervisorLock } from "./automatic-runtime.mjs";
13
13
  import { readAgentRuntimeStatus } from "./agent-runner.mjs";
14
14
  import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
15
+ import { ensureInteractiveHarness, pendingHarnessMigration } from "./harness-bootstrap.mjs";
15
16
  import {
16
17
  runtimeRoot,
17
18
  privateDirectory,
@@ -97,6 +98,8 @@ export function createPluginConnector(selection, options = {}) {
97
98
  setup,
98
99
  registered = false;
99
100
  let state = { state: "connecting", ready: false, persistent: false };
101
+ let harnessBlocked;
102
+ const migratedProfiles = new WeakSet();
100
103
  const readProfile = options.readProfile ?? readAgentProfile;
101
104
  const readCredential = options.readCredential ?? readAgentCredential;
102
105
  const readStatus = options.readStatus ?? readAgentRuntimeStatus;
@@ -166,15 +169,24 @@ export function createPluginConnector(selection, options = {}) {
166
169
  throw unavailable();
167
170
  }
168
171
  async function pinProfile(profile) {
169
- const { value, key } = await profileBinding(profile);
172
+ const handedOff = profile;
173
+ if (migratedProfiles.has(profile)) profile = await verifyBinding();
174
+ const initial = await profileBinding(profile);
175
+ const key = initial.key;
170
176
  await verifyIdentity(profile, key);
171
- if (binding && !same(binding, value)) throw unavailable();
177
+ const resumable = async (old) =>
178
+ old.codexThreadId === null &&
179
+ same({ ...old, codexThreadId: initial.value.codexThreadId }, initial.value) &&
180
+ (await pendingHarnessMigration(profile));
181
+ if (binding && !same(binding, initial.value) && !(await resumable(binding)))
182
+ throw unavailable();
172
183
  const disk = await privateRead(bindingPath).catch((error) => {
173
184
  if (error.code !== "ENOENT") throw error;
174
185
  });
175
- if (disk && !same(assertBinding(disk), value)) throw unavailable();
176
- if (!disk) await privateWrite(bindingPath, value);
177
- binding = value;
186
+ if (disk && !same(assertBinding(disk), initial.value) && !(await resumable(disk)))
187
+ throw unavailable();
188
+ if (!disk || !same(disk, initial.value)) await privateWrite(bindingPath, initial.value);
189
+ binding = initial.value;
178
190
  apiKey = key;
179
191
  if (!registered) {
180
192
  createTotemMcpServer(
@@ -189,6 +201,17 @@ export function createPluginConnector(selection, options = {}) {
189
201
  );
190
202
  registered = true;
191
203
  }
204
+ // Check/prepare the actual host before starting any background consumer.
205
+ // This migration cannot change identity, keys, repository scopes or policy.
206
+ profile = await (options.ensureHarness ?? ensureInteractiveHarness)(profile, env, {
207
+ signal: controller.signal,
208
+ });
209
+ const { value } = await profileBinding(profile);
210
+ if (!same({ ...initial.value, codexThreadId: value.codexThreadId }, value)) throw unavailable();
211
+ if (!disk || !same(disk, value)) await privateWrite(bindingPath, value);
212
+ binding = value;
213
+ apiKey = key;
214
+ if (handedOff !== profile) migratedProfiles.add(handedOff);
192
215
  state = { state: "starting", ready: false, persistent: false };
193
216
  }
194
217
  function activationStatus(value) {
@@ -209,14 +232,22 @@ export function createPluginConnector(selection, options = {}) {
209
232
  state = { ...state };
210
233
  delete state.code;
211
234
  if (!(await isEnabled(profile))) state = { ...state, state: "paused", ready: false };
235
+ else if (harnessBlocked)
236
+ state = { ...state, state: "needs_attention", ready: false, code: harnessBlocked };
212
237
  else if (inherited.TOTEM_AUTO_START === "0" || inherited.CI)
213
238
  state = { ...state, state: "disabled_by_request", ready: false };
214
239
  else {
215
240
  const current = await readStatus(profile);
216
241
  state = {
217
242
  ...state,
218
- state: current.running ? (current.ready ? "ready" : "needs_attention") : "offline",
219
- ready: Boolean(current.running && current.ready),
243
+ state: !current.running
244
+ ? "offline"
245
+ : current.ready && binding.codexThreadId && state.persistent
246
+ ? "ready"
247
+ : "needs_attention",
248
+ ready: Boolean(
249
+ current.running && current.ready && binding.codexThreadId && state.persistent,
250
+ ),
220
251
  ...(current.errorCode && !current.ready
221
252
  ? { code: runtimeErrorCode({ code: current.errorCode }) }
222
253
  : {}),
@@ -246,7 +277,12 @@ export function createPluginConnector(selection, options = {}) {
246
277
  : "separate_restricted_runtime",
247
278
  }
248
279
  : {}),
249
- currentChatWakeVerified: false,
280
+ currentChatWakeVerified: Boolean(
281
+ binding?.codexThreadId &&
282
+ state.ready &&
283
+ inherited.CODEX_THREAD_ID === binding.codexThreadId,
284
+ ),
285
+ boundChatWakeVerified: Boolean(binding?.codexThreadId && state.ready),
250
286
  message:
251
287
  state.state === "sign_in_required"
252
288
  ? "Finish the owner authorization in the opened browser. This installs the listener for the selected identity."
@@ -254,9 +290,11 @@ export function createPluginConnector(selection, options = {}) {
254
290
  ? "This listener was explicitly paused. The plugin preserves that choice."
255
291
  : state.ready && !state.persistent
256
292
  ? "The listener can respond now, but automatic startup at login is not configured on this host. Check its service manager or host permissions before relying on restart recovery."
257
- : state.ready
258
- ? "The saved listener passed receive, wake and reply verification. This does not verify delivery into the current chat or grant new work permissions."
259
- : "MCP access is separate from listener readiness. Check the selected client's sign-in and this connection's setup; no identity or permissions were replaced.",
293
+ : harnessBlocked
294
+ ? "Interactive harness setup is incomplete. Incoming work remains queued until the owner-bound chat and its host tools pass readiness. Check this client's supported session integration; no restricted responder was substituted."
295
+ : state.ready
296
+ ? "The owner-bound chat passed receive, wake and reply verification. Its existing host permissions still control every action."
297
+ : "MCP access is separate from listener readiness. Check the selected client's sign-in and this connection's setup; no identity or permissions were replaced.",
260
298
  };
261
299
  }
262
300
  server.registerTool(
@@ -304,10 +342,10 @@ export function createPluginConnector(selection, options = {}) {
304
342
  if (saved) {
305
343
  binding = assertBinding(saved);
306
344
  const profile = await readProfile(binding.profile, { environment: env });
307
- const current = await profileBinding(profile);
308
- if (!same(binding, current.value)) throw unavailable();
309
345
  await pinProfile(profile);
310
- state = activationStatus(await (options.activate ?? activateConnection)(profile, env));
346
+ state = activationStatus(
347
+ await (options.activate ?? activateConnection)(await verifyBinding(), env),
348
+ );
311
349
  } else {
312
350
  let partial = "";
313
351
  await (options.install ?? installationMain)(
@@ -329,7 +367,7 @@ export function createPluginConnector(selection, options = {}) {
329
367
  installService: async () => ({ persistent: false }),
330
368
  onProfile: pinProfile,
331
369
  activateConnection: async (profile) => {
332
- await verifyBinding();
370
+ profile = await verifyBinding();
333
371
  state = activationStatus(
334
372
  await (options.activate ?? activateConnection)(profile, env),
335
373
  );
@@ -377,9 +415,21 @@ export function createPluginConnector(selection, options = {}) {
377
415
  };
378
416
  }
379
417
  } catch (error) {
418
+ harnessBlocked = [
419
+ "HARNESS_SETUP_REQUIRED",
420
+ "HARNESS_UNSUPPORTED",
421
+ "HARNESS_UNAVAILABLE",
422
+ "HARNESS_BUSY",
423
+ "HARNESS_CREATE_UNCERTAIN",
424
+ "AGENT_CLI_INCOMPATIBLE",
425
+ ].includes(error.code)
426
+ ? error.code
427
+ : undefined;
380
428
  state = {
381
429
  state: "needs_attention",
382
- code: error.code === "PLUGIN_BINDING_CHANGED" ? error.code : "CONNECTION_SETUP_REQUIRED",
430
+ code:
431
+ harnessBlocked ??
432
+ (error.code === "PLUGIN_BINDING_CHANGED" ? error.code : "CONNECTION_SETUP_REQUIRED"),
383
433
  ready: false,
384
434
  persistent: false,
385
435
  };
@@ -54,6 +54,11 @@ export const RUNTIME_ERROR_CODES = new Set([
54
54
  "INBOX_CAPACITY_REACHED",
55
55
  "WORK_RATE_LIMITED",
56
56
  "INTERNAL_ERROR",
57
+ "HARNESS_SETUP_REQUIRED",
58
+ "HARNESS_UNSUPPORTED",
59
+ "HARNESS_UNAVAILABLE",
60
+ "HARNESS_BUSY",
61
+ "HARNESS_CREATE_UNCERTAIN",
57
62
  ]);
58
63
  export function runtimeErrorCode(error) {
59
64
  const code = MISSION_ERROR_CODES.get(error?.code) ?? error?.code;
@@ -138,6 +138,7 @@ import { readFile, lstat } from 'node:fs/promises';
138
138
  import { join, dirname } from 'node:path';
139
139
  import { fileURLToPath, pathToFileURL } from 'node:url';
140
140
  import { spawn } from 'node:child_process';
141
+ import { setTimeout as delay } from 'node:timers/promises';
141
142
  const entry = fileURLToPath(import.meta.url);
142
143
  const root = dirname(entry);
143
144
  const file = join(root, 'current.json');
@@ -160,15 +161,25 @@ if (process.env.TOTEM_RUNTIME_ENV_LOADED === entry) {
160
161
  catch (error) { if (error.code !== 'ENOENT') throw error; }
161
162
  }
162
163
  // Node reads proxy opt-in and extra CA configuration at process startup.
163
- const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], {
164
- env: { ...environment, TOTEM_CONFIG_DIR: join(root, '..'), TOTEM_RUNTIME_ENV_LOADED: entry },
165
- stdio: 'inherit',
166
- });
167
- for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => child.kill(signal));
168
- await new Promise((resolve, reject) => {
169
- child.once('error', reject);
170
- child.once('exit', (code) => { process.exitCode = code ?? 1; resolve(); });
171
- });
164
+ let stopping = false, child;
165
+ const controller = new AbortController();
166
+ const handlers = ['SIGTERM', 'SIGINT'].map((signal) => [signal, () => {
167
+ stopping = true; controller.abort(); child?.kill(signal);
168
+ }]);
169
+ for (const [signal, handler] of handlers) process.once(signal, handler);
170
+ do {
171
+ const started = Date.now();
172
+ child = spawn(process.execPath, [entry, ...process.argv.slice(2)], {
173
+ env: { ...environment, TOTEM_CONFIG_DIR: join(root, '..'), TOTEM_RUNTIME_ENV_LOADED: entry },
174
+ stdio: 'inherit',
175
+ });
176
+ const code = await new Promise((resolve) => {
177
+ child.once('error', () => resolve(1));
178
+ child.once('exit', (code) => resolve(code ?? 1));
179
+ });
180
+ ${target === "automatic-runtime.mjs" ? `if (code === 0) break;\n if (!stopping) await delay(Date.now() - started < 30000 ? 30000 : 1000, undefined, { signal: controller.signal }).catch(() => {});` : "process.exitCode = code;"}
181
+ } while (${target === "automatic-runtime.mjs" ? "!stopping" : "false"});
182
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
172
183
  }
173
184
  `;
174
185
  for (const [path, contents] of [
@@ -202,6 +213,81 @@ const xml = (value) =>
202
213
  .replaceAll("<", "&lt;")
203
214
  .replaceAll(">", "&gt;")
204
215
  .replaceAll('"', "&quot;");
216
+
217
+ // Task Scheduler can be unavailable to a standard account. A current-user
218
+ // Startup shortcut is a separate supported login mechanism, not elevation.
219
+ async function windowsStartup(runtime, node, task, env, run) {
220
+ const literal = (value) => `'${String(value).replaceAll("'", "''")}'`;
221
+ const script = `
222
+ $ErrorActionPreference='Stop'
223
+ $node=${literal(node)};$entry=${literal(runtime.supervisor)};$arguments=${literal('"' + runtime.supervisor + '"')};$task=${literal(task)}
224
+ function Report($mode,$persistent,$code) { @{mode=$mode;persistent=$persistent;code=$code}|ConvertTo-Json -Compress }
225
+ $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
226
+ $user=[Security.Principal.WindowsIdentity]::GetCurrent().Name
227
+ $name=$task+'.lnk'
228
+ try {
229
+ foreach($hive in @('HKCU:','HKLM:')) {
230
+ $key=$hive+'\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\StartupFolder'
231
+ if(Test-Path -LiteralPath $key) {
232
+ $approval=(Get-Item -LiteralPath $key).GetValue($name,$null)
233
+ if($null -ne $approval -and ($approval.Length -lt 1 -or $approval[0] -notin @(2,6))) { Report 'unavailable' $false 'STARTUP_DISABLED';exit 0 }
234
+ }
235
+ }
236
+ } catch { Report 'unavailable' $false 'STARTUP_REGISTRATION_FAILED';exit 0 }
237
+ try {
238
+ $old=Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
239
+ if($old) {
240
+ if($old.Actions.Count -ne 1 -or $old.Actions[0].Execute -ne $node -or $old.Actions[0].Arguments -ne $arguments -or $old.Principal.UserId -notin @($sid,$user)) { Report 'unavailable' $false 'STARTUP_CONFLICT';exit 0 }
241
+ if($old.State -eq 'Disabled') { Report 'unavailable' $false 'STARTUP_DISABLED';exit 0 }
242
+ ${runtime.upgraded ? "Stop-ScheduledTask -TaskName $task" : ""}
243
+ }
244
+ $action=New-ScheduledTaskAction -Execute $node -Argument $arguments
245
+ $trigger=New-ScheduledTaskTrigger -AtLogOn -User $sid
246
+ $principal=New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited
247
+ $settings=New-ScheduledTaskSettingsSet -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew
248
+ Register-ScheduledTask -TaskName $task -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force|Out-Null
249
+ $saved=Get-ScheduledTask -TaskName $task
250
+ if($saved.State -eq 'Disabled' -or $saved.Actions.Count -ne 1 -or $saved.Actions[0].Execute -ne $node -or $saved.Actions[0].Arguments -ne $arguments) { throw 'Registration verification failed' }
251
+ Start-ScheduledTask -TaskName $task
252
+ Report 'scheduled-task' $true $null;exit 0
253
+ } catch { }
254
+ try {
255
+ $startup=[Environment]::GetFolderPath('Startup','Create')
256
+ if(!$startup -or !(Test-Path -LiteralPath $startup -PathType Container) -or ((Get-Item -LiteralPath $startup).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Startup folder unavailable' }
257
+ $path=Join-Path $startup $name
258
+ if((Test-Path -LiteralPath $path) -and ((Get-Item -LiteralPath $path).Attributes -band [IO.FileAttributes]::ReparsePoint)) { Report 'unavailable' $false 'STARTUP_CONFLICT';exit 0 }
259
+ $shell=New-Object -ComObject WScript.Shell
260
+ $link=$shell.CreateShortcut($path)
261
+ if(Test-Path -LiteralPath $path) {
262
+ if($link.TargetPath -ne $node -or $link.Arguments -ne $arguments) { Report 'unavailable' $false 'STARTUP_CONFLICT';exit 0 }
263
+ } else {
264
+ $link.TargetPath=$node;$link.Arguments=$arguments;$link.WorkingDirectory=${literal(runtime.directory)}
265
+ $link.Description='Totem automatic listener';$link.WindowStyle=7;$link.Save()
266
+ }
267
+ $saved=$shell.CreateShortcut($path)
268
+ if(!(Test-Path -LiteralPath $path -PathType Leaf) -or $saved.TargetPath -ne $node -or $saved.Arguments -ne $arguments) { throw 'Shortcut verification failed' }
269
+ Report 'startup-shortcut' $true $null
270
+ } catch { Report 'unavailable' $false 'STARTUP_REGISTRATION_FAILED' }
271
+ `;
272
+ const invocation = powershellInvocation(script, env);
273
+ const result = await run(invocation.command, invocation.args, {
274
+ timeout: 20000,
275
+ maxBuffer: 8192,
276
+ });
277
+ const receipt = JSON.parse(result.stdout.trim());
278
+ if (
279
+ !["scheduled-task", "startup-shortcut", "unavailable"].includes(receipt.mode) ||
280
+ receipt.persistent !== (receipt.mode !== "unavailable") ||
281
+ (receipt.mode === "unavailable"
282
+ ? !["STARTUP_CONFLICT", "STARTUP_DISABLED", "STARTUP_REGISTRATION_FAILED"].includes(
283
+ receipt.code,
284
+ )
285
+ : receipt.code !== null)
286
+ )
287
+ throw new Error("Windows startup registration could not be verified.");
288
+ return receipt;
289
+ }
290
+
205
291
  export async function installRuntimeService(runtime, env = process.env, options = {}) {
206
292
  const platform = options.platform ?? process.platform;
207
293
  const run = options.execute ?? execute;
@@ -272,6 +358,8 @@ export async function installRuntimeService(runtime, env = process.env, options
272
358
  "NODE_EXTRA_CA_CERTS",
273
359
  ];
274
360
  let previousEnvironment = {};
361
+ let windowsRegistration;
362
+ let startupCode;
275
363
  try {
276
364
  previousEnvironment = await privateRead(join(runtime.directory, "environment.json"));
277
365
  if (
@@ -331,6 +419,11 @@ export async function installRuntimeService(runtime, env = process.env, options
331
419
  await handle.close();
332
420
  }
333
421
  const domain = `gui/${process.getuid()}`;
422
+ const disabled = await run("launchctl", ["print-disabled", domain]);
423
+ if (disabled.stdout.split("\n").some((line) => line.trim() === `"${label}" => true`)) {
424
+ startupCode = "STARTUP_DISABLED";
425
+ throw new Error("Automatic startup was disabled by the owner.");
426
+ }
334
427
  // Preserve an already running supervisor; its launcher uses current.json after restart.
335
428
  try {
336
429
  await run("launchctl", ["print", `${domain}/${label}`]);
@@ -341,6 +434,13 @@ export async function installRuntimeService(runtime, env = process.env, options
341
434
  } catch {
342
435
  await run("launchctl", ["bootstrap", domain, path]);
343
436
  }
437
+ // Registration alone is not proof that launchd loaded this exact entry.
438
+ const verified = await run("launchctl", ["print", `${domain}/${label}`]);
439
+ if (
440
+ !verified.stdout.includes(`program = ${node}`) ||
441
+ !verified.stdout.includes(runtime.supervisor)
442
+ )
443
+ throw new Error("The loaded launch agent does not match this runtime.");
344
444
  return { mode: "launchd", persistent: true, label, path };
345
445
  }
346
446
  if (platform === "linux") {
@@ -365,14 +465,9 @@ export async function installRuntimeService(runtime, env = process.env, options
365
465
  return { mode: "systemd-user", persistent: true, label: unit, path };
366
466
  }
367
467
  if (platform === "win32") {
368
- const literal = (value) => `'${String(value).replaceAll("'", "''")}'`;
369
- const upgrade = runtime.upgraded
370
- ? `$old=Get-ScheduledTask -TaskName ${literal(task)} -ErrorAction SilentlyContinue;if($old -and $old.Actions.Count -eq 1 -and $old.Actions[0].Execute -eq ${literal(node)} -and $old.Actions[0].Arguments -eq ${literal('"' + runtime.supervisor + '"')}){Stop-ScheduledTask -TaskName ${literal(task)};}`
371
- : "";
372
- const script = `$ErrorActionPreference='Stop';${upgrade}$action=New-ScheduledTaskAction -Execute ${literal(node)} -Argument ${literal('"' + runtime.supervisor + '"')};$trigger=New-ScheduledTaskTrigger -AtLogOn;$settings=New-ScheduledTaskSettingsSet -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew;Register-ScheduledTask -TaskName ${literal(task)} -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null;Start-ScheduledTask -TaskName ${literal(task)}`;
373
- const invocation = powershellInvocation(script, env);
374
- await run(invocation.command, invocation.args);
375
- return { mode: "scheduled-task", persistent: true, label: task };
468
+ windowsRegistration = await windowsStartup(runtime, node, task, env, run);
469
+ if (windowsRegistration.mode === "scheduled-task")
470
+ return { mode: "scheduled-task", persistent: true, label: task };
376
471
  }
377
472
  } catch {
378
473
  /* Ephemeral/headless hosts often have no user service manager. */
@@ -385,5 +480,13 @@ export async function installRuntimeService(runtime, env = process.env, options
385
480
  });
386
481
  child.on("error", () => {});
387
482
  child.unref();
388
- return { mode: "background-supervisor", persistent: false };
483
+ return windowsRegistration?.mode === "startup-shortcut"
484
+ ? { mode: "startup-shortcut", persistent: true, label: task }
485
+ : {
486
+ mode: "background-supervisor",
487
+ persistent: false,
488
+ ...(["win32", "darwin"].includes(platform)
489
+ ? { code: startupCode ?? windowsRegistration?.code ?? "STARTUP_REGISTRATION_FAILED" }
490
+ : {}),
491
+ };
389
492
  }
package/bin/totem.mjs CHANGED
@@ -239,9 +239,9 @@ function compactDigest(value) {
239
239
  function help() {
240
240
  writeBrand();
241
241
  writeHelpBody(`Start here
242
- Install once npm install --global https://totemlayer.com/downloads/totemlayer-0.1.40.tgz
242
+ Install once npm install --global https://totemlayer.com/downloads/totemlayer-0.1.42.tgz
243
243
  Confirm installation totem --version
244
- Run one-off npx --yes --package https://totemlayer.com/downloads/totemlayer-0.1.40.tgz totem --help
244
+ Run one-off npx --yes --package https://totemlayer.com/downloads/totemlayer-0.1.42.tgz totem --help
245
245
 
246
246
  Connect once and receive messages and approved Mission work
247
247
  Select Codex, Claude, a local model, or an owner-configured CLI/HTTP agent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {