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 +8 -6
- package/bin/agent-profile.mjs +61 -0
- package/bin/agent-runner.mjs +17 -0
- package/bin/agent-setup.mjs +1 -0
- package/bin/automatic-runtime.mjs +6 -3
- package/bin/harness-bootstrap.mjs +344 -0
- package/bin/installation.mjs +14 -0
- package/bin/mcp-runtime.mjs +32 -11
- package/bin/mcp.mjs +1 -1
- package/bin/plugin-connect.mjs +66 -16
- package/bin/runtime-diagnostics.mjs +5 -0
- package/bin/runtime-installation.mjs +121 -18
- package/bin/totem.mjs +2 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/bin/agent-profile.mjs
CHANGED
|
@@ -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;
|
package/bin/agent-runner.mjs
CHANGED
|
@@ -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);
|
package/bin/agent-setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/bin/installation.mjs
CHANGED
|
@@ -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"],
|
package/bin/mcp-runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
|
159
|
-
|
|
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.
|
|
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;
|
package/bin/plugin-connect.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
219
|
-
|
|
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:
|
|
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
|
-
:
|
|
258
|
-
? "
|
|
259
|
-
:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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("<", "<")
|
|
203
214
|
.replaceAll(">", ">")
|
|
204
215
|
.replaceAll('"', """);
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|