totemlayer 0.1.20 → 0.1.24
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 +31 -29
- package/bin/agent-profile.mjs +13 -0
- package/bin/agent-runner.mjs +31 -2
- package/bin/agent-setup.mjs +58 -6
- package/bin/automatic-runtime.mjs +44 -6
- package/bin/bound-login.mjs +107 -0
- package/bin/codex-chat-bridge.mjs +197 -0
- package/bin/codex-desktop-wake.mjs +22 -0
- package/bin/connected-agent-service.mjs +115 -78
- package/bin/device-login.mjs +100 -8
- package/bin/executor-readiness.mjs +97 -20
- package/bin/installation.mjs +129 -74
- package/bin/mission-agent-service.mjs +23 -5
- package/bin/mission-coordination.mjs +311 -0
- package/bin/mission-worker.mjs +271 -20
- package/bin/model-adapter.mjs +30 -8
- package/bin/network-runtime.mjs +115 -6
- package/bin/runtime-diagnostics.mjs +4 -0
- package/bin/runtime-installation.mjs +1 -0
- package/bin/sdk/action-types.d.ts +236 -0
- package/bin/sdk/actions.d.ts +19 -0
- package/bin/sdk/errors.d.ts +30 -0
- package/bin/sdk/errors.js +14 -1
- package/bin/sdk/index.d.ts +298 -0
- package/bin/sdk/index.js +55 -11
- package/bin/sdk/inspector.d.ts +40 -0
- package/bin/sdk/request-options.d.ts +13 -0
- package/bin/sdk/transport.d.ts +24 -0
- package/bin/sdk/types.d.ts +638 -0
- package/bin/totem.mjs +2 -2
- package/package.json +1 -1
- package/scripts/prepare.mjs +9 -20
package/bin/device-login.mjs
CHANGED
|
@@ -2,10 +2,11 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import { privateRead, privateWrite } from "./runtime-installation.mjs";
|
|
5
|
+
import { privateRead, privateWrite, privateDirectory } from "./runtime-installation.mjs";
|
|
6
|
+
import { mkdir, rm, rename } from "node:fs/promises";
|
|
6
7
|
import { detectInstalledProvider, providerArguments } from "./provider-detection.mjs";
|
|
7
8
|
|
|
8
|
-
function origin(value) {
|
|
9
|
+
export function origin(value) {
|
|
9
10
|
const url = new URL(value ?? "https://totemlayer.com");
|
|
10
11
|
if (
|
|
11
12
|
url.username ||
|
|
@@ -43,6 +44,7 @@ export async function beginDeviceLogin(runtime, env = process.env, options = {})
|
|
|
43
44
|
pending.baseUrl !== baseUrl ||
|
|
44
45
|
pending.requestedAgentId !== env.TOTEM_AGENT_ID ||
|
|
45
46
|
pending.provider !== (env.TOTEM_PROVIDER ?? "auto") ||
|
|
47
|
+
pending.redirectUri !== options.binding?.redirectUri ||
|
|
46
48
|
new Date(pending.expiresAt) <= new Date()
|
|
47
49
|
) {
|
|
48
50
|
const result = await request(
|
|
@@ -55,6 +57,12 @@ export async function beginDeviceLogin(runtime, env = process.env, options = {})
|
|
|
55
57
|
deviceName: hostname().slice(0, 100),
|
|
56
58
|
provider: env.TOTEM_PROVIDER ?? "auto",
|
|
57
59
|
...(env.TOTEM_AGENT_ID ? { agentId: env.TOTEM_AGENT_ID } : {}),
|
|
60
|
+
...(options.binding
|
|
61
|
+
? {
|
|
62
|
+
bindingChallenge: options.binding.challenge,
|
|
63
|
+
redirectUri: options.binding.redirectUri,
|
|
64
|
+
}
|
|
65
|
+
: {}),
|
|
58
66
|
}),
|
|
59
67
|
},
|
|
60
68
|
options.fetch,
|
|
@@ -71,6 +79,10 @@ export async function beginDeviceLogin(runtime, env = process.env, options = {})
|
|
|
71
79
|
baseUrl,
|
|
72
80
|
requestedAgentId: env.TOTEM_AGENT_ID,
|
|
73
81
|
provider: env.TOTEM_PROVIDER ?? "auto",
|
|
82
|
+
replace: Boolean(options.replace),
|
|
83
|
+
...(options.binding
|
|
84
|
+
? { redirectUri: options.binding.redirectUri, verifier: options.binding.verifier }
|
|
85
|
+
: {}),
|
|
74
86
|
};
|
|
75
87
|
await privateWrite(join(runtime.directory, "connection-request.json"), pending);
|
|
76
88
|
}
|
|
@@ -86,10 +98,45 @@ export async function beginDeviceLogin(runtime, env = process.env, options = {})
|
|
|
86
98
|
() => {},
|
|
87
99
|
);
|
|
88
100
|
}
|
|
89
|
-
return { url, code: pending.code, expiresAt: pending.expiresAt };
|
|
101
|
+
return { id: pending.id, url, code: pending.code, expiresAt: pending.expiresAt };
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
export async function resumeDeviceLogin(runtime, env = process.env, options = {}) {
|
|
105
|
+
await privateDirectory(runtime.directory);
|
|
106
|
+
const lock = join(runtime.directory, "login.lock");
|
|
107
|
+
try {
|
|
108
|
+
await mkdir(lock, { mode: 0o700 });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== "EEXIST") throw error;
|
|
111
|
+
const owner = await privateRead(join(lock, "owner.json")).catch(() => null);
|
|
112
|
+
if (owner && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
|
|
113
|
+
try {
|
|
114
|
+
process.kill(owner.pid, 0);
|
|
115
|
+
} catch (failure) {
|
|
116
|
+
if (failure.code === "ESRCH") await rm(lock, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { state: "setup_in_progress" };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await privateWrite(join(lock, "owner.json"), { pid: process.pid });
|
|
123
|
+
if (options.reset) {
|
|
124
|
+
for (const name of ["connection-ready.json", "connection-request.json"])
|
|
125
|
+
await rename(
|
|
126
|
+
join(runtime.directory, name),
|
|
127
|
+
join(runtime.directory, `${name}.reauthorize-${Date.now()}`),
|
|
128
|
+
).catch((error) => {
|
|
129
|
+
if (error.code !== "ENOENT") throw error;
|
|
130
|
+
});
|
|
131
|
+
return { state: "reset" };
|
|
132
|
+
}
|
|
133
|
+
return await resumeLoginLocked(runtime, env, options);
|
|
134
|
+
} finally {
|
|
135
|
+
await rm(lock, { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function resumeLoginLocked(runtime, env, options) {
|
|
93
140
|
let staged;
|
|
94
141
|
try {
|
|
95
142
|
staged = await privateRead(join(runtime.directory, "connection-ready.json"));
|
|
@@ -111,6 +158,8 @@ export async function resumeDeviceLogin(runtime, env = process.env, options = {}
|
|
|
111
158
|
{ headers: { "x-totem-installation-token": pending.token } },
|
|
112
159
|
options.fetch,
|
|
113
160
|
);
|
|
161
|
+
if (result.status === "expired")
|
|
162
|
+
throw new Error("This installer authorization expired. Run totem login again.");
|
|
114
163
|
if (result.status !== "approved") return;
|
|
115
164
|
if (
|
|
116
165
|
!/^[a-f0-9-]{36}$/.test(result.agentId) ||
|
|
@@ -122,6 +171,8 @@ export async function resumeDeviceLogin(runtime, env = process.env, options = {}
|
|
|
122
171
|
key: result.key,
|
|
123
172
|
baseUrl: pending.baseUrl,
|
|
124
173
|
requestId: pending.id,
|
|
174
|
+
installationId: pending.id,
|
|
175
|
+
replace: Boolean(pending.replace),
|
|
125
176
|
token: pending.token,
|
|
126
177
|
};
|
|
127
178
|
// Persist privately before acknowledging, so an interrupted setup never loses the key.
|
|
@@ -140,8 +191,36 @@ export async function resumeDeviceLogin(runtime, env = process.env, options = {}
|
|
|
140
191
|
delete staged.token;
|
|
141
192
|
await privateWrite(join(runtime.directory, "connection-ready.json"), staged);
|
|
142
193
|
}
|
|
143
|
-
const
|
|
144
|
-
|
|
194
|
+
const reportFailure = async (errorCode) => {
|
|
195
|
+
const pending = await privateRead(join(runtime.directory, "connection-request.json")).catch(
|
|
196
|
+
() => null,
|
|
197
|
+
);
|
|
198
|
+
if (pending && pending.id === staged.installationId)
|
|
199
|
+
await request(
|
|
200
|
+
origin(pending.baseUrl),
|
|
201
|
+
`/api/installations/${pending.id}/runtime`,
|
|
202
|
+
{
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: {
|
|
205
|
+
"content-type": "application/json",
|
|
206
|
+
"x-totem-installation-token": pending.token,
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({ errorCode }),
|
|
209
|
+
},
|
|
210
|
+
options.fetch,
|
|
211
|
+
).catch(() => {});
|
|
212
|
+
};
|
|
213
|
+
let provider;
|
|
214
|
+
try {
|
|
215
|
+
provider = await (options.detectProvider ?? detectInstalledProvider)(env);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
await reportFailure("UNSUPPORTED_ADAPTER");
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
if (!provider) {
|
|
221
|
+
await reportFailure("PROVIDER_AUTH_REQUIRED");
|
|
222
|
+
return { state: "executor_needed" };
|
|
223
|
+
}
|
|
145
224
|
const { agentSetupMain } = await import("./agent-setup.mjs");
|
|
146
225
|
const status = await (options.setup ?? agentSetupMain)(
|
|
147
226
|
[
|
|
@@ -152,6 +231,7 @@ export async function resumeDeviceLogin(runtime, env = process.env, options = {}
|
|
|
152
231
|
origin(staged.baseUrl),
|
|
153
232
|
...(await providerArguments(provider, env)),
|
|
154
233
|
"--no-start",
|
|
234
|
+
...(options.replace || staged.replace ? ["--replace"] : []),
|
|
155
235
|
"--json",
|
|
156
236
|
],
|
|
157
237
|
{
|
|
@@ -160,9 +240,21 @@ export async function resumeDeviceLogin(runtime, env = process.env, options = {}
|
|
|
160
240
|
stderr: { write() {} },
|
|
161
241
|
},
|
|
162
242
|
);
|
|
163
|
-
if (status)
|
|
164
|
-
|
|
243
|
+
if (status) {
|
|
244
|
+
await reportFailure("LISTENER_START_FAILED");
|
|
245
|
+
throw new Error("The approved connection could not be saved.");
|
|
246
|
+
}
|
|
247
|
+
await privateWrite(join(runtime.directory, "connection-last.json"), {
|
|
248
|
+
requestId: staged.installationId,
|
|
249
|
+
profile: staged.agentId,
|
|
250
|
+
});
|
|
165
251
|
await rm(join(runtime.directory, "connection-ready.json"));
|
|
166
|
-
await
|
|
252
|
+
const current = await privateRead(join(runtime.directory, "connection-request.json")).catch(
|
|
253
|
+
(error) => {
|
|
254
|
+
if (error.code !== "ENOENT") throw error;
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
if (current?.id === staged.installationId)
|
|
258
|
+
await rm(join(runtime.directory, "connection-request.json"), { force: true });
|
|
167
259
|
return { state: "connected", profile: staged.agentId };
|
|
168
260
|
}
|
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify, stripVTControlCharacters } from "node:util";
|
|
3
5
|
import { modelReply } from "./network-runtime.mjs";
|
|
6
|
+
import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
|
|
4
7
|
import { privateRead, privateWrite } from "./runtime-installation.mjs";
|
|
5
8
|
import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
|
|
6
9
|
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
export const CODEX_QUEUE_UNAVAILABLE_MESSAGE =
|
|
12
|
+
"This Codex CLI cannot queue messages to the selected chat. Update Codex, then check status again.";
|
|
13
|
+
|
|
14
|
+
function queueUnavailable() {
|
|
15
|
+
return Object.assign(new Error(CODEX_QUEUE_UNAVAILABLE_MESSAGE), {
|
|
16
|
+
code: "AGENT_CLI_INCOMPATIBLE",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function checkCodexQueueSupport(environment = process.env, options = {}) {
|
|
21
|
+
const env = Object.fromEntries(
|
|
22
|
+
Object.entries(localClientEnvironment(environment)).filter(
|
|
23
|
+
([name]) =>
|
|
24
|
+
![
|
|
25
|
+
"TOTEM_NETWORK_API_KEY_FILE",
|
|
26
|
+
"TOTEM_BASE_URL",
|
|
27
|
+
"OPENAI_API_KEY",
|
|
28
|
+
"ANTHROPIC_API_KEY",
|
|
29
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
30
|
+
].includes(name.toUpperCase()),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
const args = ["queue", "--help"];
|
|
34
|
+
try {
|
|
35
|
+
const invocation =
|
|
36
|
+
process.platform === "win32"
|
|
37
|
+
? installedClientInvocation("codex", args, env)
|
|
38
|
+
: { command: "codex", args };
|
|
39
|
+
let result;
|
|
40
|
+
if (options.runClient) result = await options.runClient("codex", args, env);
|
|
41
|
+
else {
|
|
42
|
+
const pending = execute(invocation.command, invocation.args, {
|
|
43
|
+
env,
|
|
44
|
+
timeout: 5000,
|
|
45
|
+
maxBuffer: 64 * 1024,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
48
|
+
});
|
|
49
|
+
// The Windows fallback reads stdin to EOF before invoking the client.
|
|
50
|
+
pending.child?.stdin?.end();
|
|
51
|
+
result = await pending;
|
|
52
|
+
}
|
|
53
|
+
const help = stripVTControlCharacters(`${result?.stdout ?? ""}\n${result?.stderr ?? ""}`);
|
|
54
|
+
if (
|
|
55
|
+
!/\bcodex\s+queue\b/i.test(help) ||
|
|
56
|
+
!help.includes("--thread") ||
|
|
57
|
+
!help.includes("--message")
|
|
58
|
+
)
|
|
59
|
+
throw queueUnavailable();
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
throw queueUnavailable();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
7
66
|
export async function verifyExecutorReady(profile, env = process.env, options = {}) {
|
|
8
67
|
const version =
|
|
9
68
|
options.version ??
|
|
@@ -20,33 +79,51 @@ export async function verifyExecutorReady(profile, env = process.env, options =
|
|
|
20
79
|
profile.qwenUrl,
|
|
21
80
|
profile.ollamaUrl,
|
|
22
81
|
profile.nimUrl,
|
|
82
|
+
profile.codexThreadId,
|
|
23
83
|
]);
|
|
24
84
|
const previous = await privateRead(file).catch((error) => {
|
|
25
85
|
if (error.code !== "ENOENT") throw error;
|
|
26
86
|
return null;
|
|
27
87
|
});
|
|
28
|
-
if (
|
|
88
|
+
if (
|
|
89
|
+
!profile.codexThreadId &&
|
|
90
|
+
previous?.version === version &&
|
|
91
|
+
previous.binding === binding &&
|
|
92
|
+
previous.ready === true
|
|
93
|
+
)
|
|
29
94
|
return previous;
|
|
30
95
|
let response;
|
|
31
96
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
97
|
+
if (profile.codexThreadId) {
|
|
98
|
+
try {
|
|
99
|
+
if (
|
|
100
|
+
(await (options.checkQueue ?? checkCodexQueueSupport)(env, {
|
|
101
|
+
signal: options.signal,
|
|
102
|
+
})) === false
|
|
103
|
+
)
|
|
104
|
+
throw queueUnavailable();
|
|
105
|
+
} catch {
|
|
106
|
+
throw queueUnavailable();
|
|
107
|
+
}
|
|
108
|
+
} else
|
|
109
|
+
response = await (options.generate ?? modelReply)({
|
|
110
|
+
provider: profile.client ?? profile.provider,
|
|
111
|
+
model: profile.client && profile.provider === "qwen-local" ? "default" : profile.model,
|
|
112
|
+
origin: profile.ollamaUrl ?? "http://127.0.0.1:11434",
|
|
113
|
+
qwenUrl: profile.qwenUrl,
|
|
114
|
+
nimUrl: profile.nimUrl,
|
|
115
|
+
adapterSettings: { ...profile, environment: env },
|
|
116
|
+
messages: [
|
|
117
|
+
{
|
|
118
|
+
role: "user",
|
|
119
|
+
content:
|
|
120
|
+
"This is a connection readiness check. Reply READY. No tools, file reads or external actions are authorized.",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
tools: [],
|
|
124
|
+
signal: options.signal ?? new AbortController().signal,
|
|
125
|
+
timeoutMs: 30000,
|
|
126
|
+
});
|
|
50
127
|
} catch (error) {
|
|
51
128
|
await privateWrite(file, {
|
|
52
129
|
version,
|
|
@@ -57,7 +134,7 @@ export async function verifyExecutorReady(profile, env = process.env, options =
|
|
|
57
134
|
});
|
|
58
135
|
throw error;
|
|
59
136
|
}
|
|
60
|
-
if (typeof response?.content !== "string" || !response.content.trim())
|
|
137
|
+
if (!profile.codexThreadId && (typeof response?.content !== "string" || !response.content.trim()))
|
|
61
138
|
throw Object.assign(new Error("The selected executor did not complete its readiness check."), {
|
|
62
139
|
code: "INVALID_AGENT_RESULT",
|
|
63
140
|
});
|
package/bin/installation.mjs
CHANGED
|
@@ -5,6 +5,11 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { ensureInstalledRuntime, installRuntimeService } from "./runtime-installation.mjs";
|
|
7
7
|
import { beginDeviceLogin } from "./device-login.mjs";
|
|
8
|
+
import { resumeDeviceLogin } from "./device-login.mjs";
|
|
9
|
+
import { beginBoundLogin } from "./bound-login.mjs";
|
|
10
|
+
import { readAgentProfile } from "./agent-profile.mjs";
|
|
11
|
+
import { readAgentRuntimeStatus } from "./agent-runner.mjs";
|
|
12
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
13
|
import { detectInstalledProvider, providerArguments } from "./provider-detection.mjs";
|
|
9
14
|
|
|
10
15
|
export async function activateConnection(profile, env = process.env, options = {}) {
|
|
@@ -19,7 +24,7 @@ export async function activateConnection(profile, env = process.env, options = {
|
|
|
19
24
|
if (!enabled) return { state: "paused", ...service, entry: runtime.cli };
|
|
20
25
|
const { verifyExecutorReady } = await import("./executor-readiness.mjs");
|
|
21
26
|
try {
|
|
22
|
-
await (options.verifyExecutor ?? verifyExecutorReady)(profile, env);
|
|
27
|
+
if (profile.codexThreadId) await (options.verifyExecutor ?? verifyExecutorReady)(profile, env);
|
|
23
28
|
} catch (error) {
|
|
24
29
|
return {
|
|
25
30
|
state: "needs_attention",
|
|
@@ -31,42 +36,18 @@ export async function activateConnection(profile, env = process.env, options = {
|
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
38
|
const { agentStartMain } = await import("./agent-runner.mjs");
|
|
34
|
-
const
|
|
39
|
+
const result = await (options.start ?? agentStartMain)(
|
|
35
40
|
["--profile", profile.profileName, "--background", "--json"],
|
|
36
41
|
{ env, automatic: true, stdout: { write() {} } },
|
|
37
42
|
);
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
|
|
47
|
-
if (
|
|
48
|
-
state.running &&
|
|
49
|
-
state.phase === "blocked" &&
|
|
50
|
-
["INSPECTION_BLOCKED", "INSPECTION_LIMIT", "INSPECTION_UNSUPPORTED_FILE"].includes(state.blockerCode)
|
|
51
|
-
)
|
|
52
|
-
return {
|
|
53
|
-
state: "running",
|
|
54
|
-
...service,
|
|
55
|
-
entry: runtime.cli,
|
|
56
|
-
phase: state.phase,
|
|
57
|
-
quarantinedMessage: true,
|
|
58
|
-
code: state.blockerCode,
|
|
59
|
-
message: "The listener is running. An unsafe message was quarantined; other messages can still be processed.",
|
|
60
|
-
};
|
|
61
|
-
if (!state.running || !["connected", "working", "waiting"].includes(state.phase))
|
|
62
|
-
return {
|
|
63
|
-
state: "needs_attention",
|
|
64
|
-
...service,
|
|
65
|
-
entry: runtime.cli,
|
|
66
|
-
code: state.blockerCode ?? "WORKER_NOT_READY",
|
|
67
|
-
phase: state.phase,
|
|
68
|
-
};
|
|
69
|
-
return { state: "running", ...service, entry: runtime.cli };
|
|
43
|
+
if (result) throw new Error("The listener could not start. Check totem status for this profile.");
|
|
44
|
+
const status = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
|
|
45
|
+
return {
|
|
46
|
+
state: status.ready ? "ready" : "needs_attention",
|
|
47
|
+
ready: Boolean(status.ready),
|
|
48
|
+
...service,
|
|
49
|
+
entry: runtime.cli,
|
|
50
|
+
};
|
|
70
51
|
}
|
|
71
52
|
|
|
72
53
|
export async function installationMain(args = [], options = {}) {
|
|
@@ -80,13 +61,16 @@ export async function installationMain(args = [], options = {}) {
|
|
|
80
61
|
"agent-id": { type: "string" },
|
|
81
62
|
"base-url": { type: "string" },
|
|
82
63
|
"no-browser": { type: "boolean" },
|
|
64
|
+
"device-login": { type: "boolean" },
|
|
65
|
+
reauthorize: { type: "boolean" },
|
|
66
|
+
"host-kind": { type: "string" },
|
|
83
67
|
help: { type: "boolean", short: "h" },
|
|
84
68
|
json: { type: "boolean" },
|
|
85
69
|
},
|
|
86
70
|
}).values;
|
|
87
71
|
if (flags.help) {
|
|
88
72
|
stdout.write(
|
|
89
|
-
"totem login [--provider codex|claude|qwen-local|ollama-local|nim-local|command|http] [--no-browser]\
|
|
73
|
+
"totem login [--provider codex|claude|qwen-local|ollama-local|nim-local|command|http] [--no-browser] [--device-login]\nAuthorize your agent in the initial browser flow. Totem saves the connection privately, starts its listener and tests a controlled reply. Use --device-login explicitly for headless hosts; that fallback requires code comparison.\n",
|
|
90
74
|
);
|
|
91
75
|
return 0;
|
|
92
76
|
}
|
|
@@ -95,7 +79,15 @@ export async function installationMain(args = [], options = {}) {
|
|
|
95
79
|
...(flags.provider ? { TOTEM_PROVIDER: flags.provider } : {}),
|
|
96
80
|
...(flags["agent-id"] ? { TOTEM_AGENT_ID: flags["agent-id"] } : {}),
|
|
97
81
|
...(flags["base-url"] ? { TOTEM_BASE_URL: flags["base-url"] } : {}),
|
|
82
|
+
...(flags["host-kind"] ? { TOTEM_RUNTIME_HOST_KIND: flags["host-kind"] } : {}),
|
|
98
83
|
};
|
|
84
|
+
if (
|
|
85
|
+
environment.TOTEM_RUNTIME_HOST_KIND &&
|
|
86
|
+
!["local", "hosted"].includes(environment.TOTEM_RUNTIME_HOST_KIND)
|
|
87
|
+
)
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Choose --host-kind local or hosted. Hosted mode labels an already provisioned, explicitly authorized host; it does not provision one.",
|
|
90
|
+
);
|
|
99
91
|
const runtime = await (options.ensureRuntime ?? ensureInstalledRuntime)(environment);
|
|
100
92
|
const service = await (options.installService ?? installRuntimeService)(runtime, environment);
|
|
101
93
|
if (environment.TOTEM_NETWORK_API_KEY || environment.TOTEM_NETWORK_API_KEY_FILE) {
|
|
@@ -129,6 +121,7 @@ export async function installationMain(args = [], options = {}) {
|
|
|
129
121
|
[
|
|
130
122
|
"--profile",
|
|
131
123
|
environment.TOTEM_AGENT_ID ?? "default",
|
|
124
|
+
...(flags.reauthorize ? ["--replace"] : []),
|
|
132
125
|
...(environment.TOTEM_AGENT_ID ? ["--agent-id", environment.TOTEM_AGENT_ID] : []),
|
|
133
126
|
...(await providerArguments(provider, environment)),
|
|
134
127
|
...(flags.json ? ["--json"] : []),
|
|
@@ -140,61 +133,74 @@ export async function installationMain(args = [], options = {}) {
|
|
|
140
133
|
},
|
|
141
134
|
);
|
|
142
135
|
}
|
|
143
|
-
|
|
144
|
-
await
|
|
136
|
+
const activate = async (name) => {
|
|
137
|
+
const profile = await (options.readProfile ?? readAgentProfile)(name, { environment });
|
|
138
|
+
return (options.activateConnection ?? activateConnection)(profile, environment, options);
|
|
139
|
+
};
|
|
140
|
+
const reportActivation = (status) => {
|
|
145
141
|
stdout.write(
|
|
146
|
-
|
|
142
|
+
flags.json
|
|
143
|
+
? JSON.stringify(status) + "\n"
|
|
144
|
+
: status.ready
|
|
145
|
+
? "Ready. The listener passed its controlled receive, wake and reply check. This host must remain awake and online.\n"
|
|
146
|
+
: `Runtime ${status.state}. Run totem status for diagnostics; an installed or paused listener is not ready.\n`,
|
|
147
147
|
);
|
|
148
|
-
return 0;
|
|
149
|
-
}
|
|
150
|
-
|
|
148
|
+
return status.ready ? 0 : 1;
|
|
149
|
+
};
|
|
150
|
+
const resume = options.resumeLogin ?? resumeDeviceLogin;
|
|
151
|
+
const loginOptions = { ...options, replace: Boolean(flags.reauthorize) };
|
|
152
|
+
if (flags.reauthorize) {
|
|
153
|
+
const reset = await resume(runtime, environment, { ...loginOptions, reset: true });
|
|
154
|
+
if (reset?.state !== "reset") {
|
|
155
|
+
stdout.write(
|
|
156
|
+
"Another installer is saving this connection. Wait for it to finish before reauthorizing.\n",
|
|
157
|
+
);
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const staged = flags.reauthorize
|
|
162
|
+
? undefined
|
|
163
|
+
: await resume(runtime, environment, loginOptions).catch((error) => {
|
|
164
|
+
if (error.message?.includes("authorization expired")) return undefined;
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
if (staged?.state === "connected") return reportActivation(await activate(staged.profile));
|
|
168
|
+
if (staged?.state === "executor_needed") {
|
|
169
|
+
stdout.write(
|
|
170
|
+
"Authorization is saved privately. Sign in to an installed Codex or Claude client, or configure a supported local/custom adapter, then retry totem login. The listener is not ready.\n",
|
|
171
|
+
);
|
|
172
|
+
return 1;
|
|
151
173
|
}
|
|
152
174
|
// An upgrade reuses private profiles and starts their existing connections.
|
|
153
175
|
const { opendir } = await import("node:fs/promises");
|
|
154
|
-
|
|
155
|
-
const savedProfiles = [];
|
|
176
|
+
const saved = [];
|
|
156
177
|
try {
|
|
157
178
|
const profiles = await opendir(join(runtime.root, "connections"));
|
|
158
179
|
for await (const entry of profiles)
|
|
159
180
|
if (
|
|
160
181
|
entry.isDirectory() &&
|
|
161
182
|
(!environment.TOTEM_AGENT_ID || entry.name === environment.TOTEM_AGENT_ID)
|
|
162
|
-
)
|
|
163
|
-
saved
|
|
164
|
-
savedProfiles.push(entry.name);
|
|
165
|
-
}
|
|
183
|
+
)
|
|
184
|
+
saved.push(entry.name);
|
|
166
185
|
} catch (error) {
|
|
167
186
|
if (error.code !== "ENOENT") throw error;
|
|
168
187
|
}
|
|
169
|
-
if (saved && options.forceLogin !== true) {
|
|
170
|
-
const { readAgentProfile } = await import("./agent-profile.mjs");
|
|
188
|
+
if (saved.length && options.forceLogin !== true && !flags.reauthorize) {
|
|
171
189
|
const results = [];
|
|
172
|
-
for (const name of
|
|
173
|
-
|
|
174
|
-
results.push({
|
|
175
|
-
profile: name,
|
|
176
|
-
...(await (options.activateConnection ?? activateConnection)(profile, environment)),
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
const ready = results.every((result) => ["running", "paused"].includes(result.state));
|
|
180
|
-
const report = {
|
|
190
|
+
for (const name of saved) results.push({ profile: name, ...(await activate(name)) });
|
|
191
|
+
return reportActivation({
|
|
181
192
|
installed: true,
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
ready,
|
|
193
|
+
ready: results.every((r) => r.ready),
|
|
194
|
+
state: results.every((r) => r.ready) ? "ready" : "needs_attention",
|
|
185
195
|
profiles: results,
|
|
186
196
|
service,
|
|
187
|
-
};
|
|
188
|
-
stdout.write(
|
|
189
|
-
flags.json
|
|
190
|
-
? JSON.stringify(report) + "\n"
|
|
191
|
-
: ready
|
|
192
|
-
? "Totem's automatic listener reuses your saved connections; startup checks passed.\n"
|
|
193
|
-
: "Totem is installed, but an agent failed its startup check. Run totem status to inspect it.\n",
|
|
194
|
-
);
|
|
195
|
-
return ready ? 0 : 1;
|
|
197
|
+
});
|
|
196
198
|
}
|
|
197
|
-
const connection = await (
|
|
199
|
+
const connection = await (
|
|
200
|
+
options.beginLogin ?? (flags["device-login"] ? beginDeviceLogin : beginBoundLogin)
|
|
201
|
+
)(runtime, environment, {
|
|
202
|
+
...options,
|
|
203
|
+
replace: Boolean(flags.reauthorize),
|
|
198
204
|
openBrowser: !flags["no-browser"] && environment.TOTEM_NO_BROWSER !== "1",
|
|
199
205
|
});
|
|
200
206
|
const report = {
|
|
@@ -202,14 +208,63 @@ export async function installationMain(args = [], options = {}) {
|
|
|
202
208
|
connection: "sign_in_required",
|
|
203
209
|
automatic: true,
|
|
204
210
|
service,
|
|
205
|
-
|
|
211
|
+
url: connection.url,
|
|
212
|
+
...(flags["device-login"] ? { code: connection.code } : {}),
|
|
213
|
+
ready: false,
|
|
206
214
|
};
|
|
207
215
|
stdout.write(
|
|
208
216
|
flags.json
|
|
209
217
|
? JSON.stringify(report) + "\n"
|
|
210
|
-
: `
|
|
218
|
+
: `Authorize your agent in the installer browser:\n${connection.url}\n${flags["device-login"] ? `Compare this code before approving: ${connection.code.match(/.{1,4}/g).join(" ")}\n` : ""}Waiting for authorization and the controlled listener check.\n`,
|
|
211
219
|
);
|
|
212
|
-
|
|
220
|
+
try {
|
|
221
|
+
const deadline = Math.min(
|
|
222
|
+
Date.parse(connection.expiresAt),
|
|
223
|
+
Date.now() + (options.loginTimeoutMs ?? 600000),
|
|
224
|
+
);
|
|
225
|
+
while (Date.now() < deadline && !options.signal?.aborted) {
|
|
226
|
+
const result = await resume(runtime, environment, loginOptions);
|
|
227
|
+
if (result?.state === "connected") return reportActivation(await activate(result.profile));
|
|
228
|
+
const receipt = await privateRead(join(runtime.directory, "connection-last.json")).catch(
|
|
229
|
+
(error) => {
|
|
230
|
+
if (error.code !== "ENOENT") throw error;
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
if (receipt?.requestId === connection.id && receipt.profile)
|
|
234
|
+
return reportActivation(await activate(receipt.profile));
|
|
235
|
+
if (result?.state === "executor_needed") {
|
|
236
|
+
stdout.write(
|
|
237
|
+
"Authorization saved. Sign in to an installed AI client or configure a supported adapter, then run totem login again. Runtime is not ready.\n",
|
|
238
|
+
);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
// The supervisor may have saved the profile while this installer was polling.
|
|
242
|
+
const pending = await privateRead(join(runtime.directory, "connection-request.json")).catch(
|
|
243
|
+
(error) => {
|
|
244
|
+
if (error.code !== "ENOENT") throw error;
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
const name = environment.TOTEM_AGENT_ID ?? pending?.requestedAgentId;
|
|
248
|
+
if (name) {
|
|
249
|
+
const profile = await (options.readProfile ?? readAgentProfile)(name, {
|
|
250
|
+
environment,
|
|
251
|
+
}).catch((error) => {
|
|
252
|
+
if (error.code !== "AGENT_PROFILE_NOT_FOUND") throw error;
|
|
253
|
+
});
|
|
254
|
+
if (profile)
|
|
255
|
+
return reportActivation(
|
|
256
|
+
await (options.activateConnection ?? activateConnection)(profile, environment, options),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
await (options.sleep ?? sleep)(3000, undefined, { signal: options.signal });
|
|
260
|
+
}
|
|
261
|
+
stdout.write(
|
|
262
|
+
"Setup is not ready yet. Run totem login again to resume securely; inspect totem status for any saved profile.\n",
|
|
263
|
+
);
|
|
264
|
+
return 1;
|
|
265
|
+
} finally {
|
|
266
|
+
connection.close?.();
|
|
267
|
+
}
|
|
213
268
|
}
|
|
214
269
|
export async function postinstallMain(env = process.env) {
|
|
215
270
|
if (env.TOTEM_AUTO_START === "0" || env.CI) return 0;
|