totemlayer 0.1.5 → 0.1.11
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 +26 -44
- package/bin/agent-profile.mjs +18 -2
- package/bin/agent-runner.mjs +20 -10
- package/bin/agent-setup.mjs +141 -55
- package/bin/automatic-runtime.mjs +131 -0
- package/bin/cloud-bootstrap.mjs +64 -0
- package/bin/collaboration-listener.mjs +83 -83
- package/bin/connected-agent-service.mjs +84 -0
- package/bin/device-login.mjs +168 -0
- package/bin/docker-test-sandbox.mjs +1 -1
- package/bin/installation.mjs +160 -0
- package/bin/mcp-install.mjs +74 -18
- package/bin/mcp.mjs +56 -1
- package/bin/mission-agent-service.mjs +40 -2
- package/bin/mission-infrastructure.mjs +609 -0
- package/bin/mission-worker.mjs +259 -49
- package/bin/model-adapter.mjs +164 -0
- package/bin/network-runtime.mjs +58 -3
- package/bin/platform-client.mjs +3 -0
- package/bin/provider-detection.mjs +94 -0
- package/bin/runtime-installation.mjs +305 -0
- package/bin/sdk/index.js +5 -0
- package/bin/totem.mjs +13 -3
- package/bin/worker-core/adapters.mjs +279 -0
- package/bin/worker-core/errors.mjs +60 -0
- package/package.json +2 -1
- package/scripts/prepare.mjs +5 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { opendir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readAgentProfile } from "./agent-profile.mjs";
|
|
6
|
+
import { agentStartMain, readAgentRuntimeStatus } from "./agent-runner.mjs";
|
|
7
|
+
import {
|
|
8
|
+
privateDirectory,
|
|
9
|
+
privateRead,
|
|
10
|
+
privateWrite,
|
|
11
|
+
runtimeRoot,
|
|
12
|
+
} from "./runtime-installation.mjs";
|
|
13
|
+
import { resumeDeviceLogin } from "./device-login.mjs";
|
|
14
|
+
|
|
15
|
+
export async function setAutomaticEnabled(profile, enabled) {
|
|
16
|
+
await privateWrite(join(profile.stateDirectory, "automatic.json"), { enabled });
|
|
17
|
+
}
|
|
18
|
+
export async function automaticEnabled(profile) {
|
|
19
|
+
try {
|
|
20
|
+
return (await privateRead(join(profile.stateDirectory, "automatic.json"))).enabled === true;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === "ENOENT") return true;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function superviseOnce(env = process.env, options = {}) {
|
|
27
|
+
const directory = join(runtimeRoot(env), "runtime");
|
|
28
|
+
await Promise.resolve((options.resumeLogin ?? resumeDeviceLogin)({ directory }, env)).catch(
|
|
29
|
+
() => {},
|
|
30
|
+
);
|
|
31
|
+
let profiles;
|
|
32
|
+
try {
|
|
33
|
+
profiles = await opendir(join(runtimeRoot(env), "connections"));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === "ENOENT") return [];
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
const results = [];
|
|
39
|
+
for await (const entry of profiles) {
|
|
40
|
+
if (!entry.isDirectory()) continue;
|
|
41
|
+
try {
|
|
42
|
+
const profile = await readAgentProfile(entry.name, { environment: env });
|
|
43
|
+
if (!(await automaticEnabled(profile))) continue;
|
|
44
|
+
const state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
|
|
45
|
+
if (state.running) continue;
|
|
46
|
+
// The run-local journal decides whether any interrupted work can safely resume.
|
|
47
|
+
await (options.start ?? agentStartMain)(["--profile", entry.name, "--background", "--json"], {
|
|
48
|
+
env,
|
|
49
|
+
automatic: true,
|
|
50
|
+
stdout: { write() {} },
|
|
51
|
+
});
|
|
52
|
+
results.push(entry.name);
|
|
53
|
+
} catch {
|
|
54
|
+
/* One offline/revoked connection cannot stop other profiles. */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
export async function automaticRuntimeMain(env = process.env, options = {}) {
|
|
60
|
+
const directory = join(runtimeRoot(env), "runtime");
|
|
61
|
+
await privateDirectory(directory);
|
|
62
|
+
const lock = join(directory, "supervisor.lock");
|
|
63
|
+
await import("node:fs/promises").then(async ({ mkdir }) => {
|
|
64
|
+
try {
|
|
65
|
+
await mkdir(lock, { mode: 0o700 });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code !== "EEXIST") throw error;
|
|
68
|
+
const current = await privateRead(join(lock, "owner.json")).catch(() => null);
|
|
69
|
+
if (!current || !Number.isInteger(current.pid))
|
|
70
|
+
throw new Error("Another supervisor is starting. Wait before retrying.");
|
|
71
|
+
try {
|
|
72
|
+
process.kill(current.pid, 0);
|
|
73
|
+
throw new Error("The Totem supervisor is already running.");
|
|
74
|
+
} catch (failure) {
|
|
75
|
+
if (failure.code !== "ESRCH") throw failure;
|
|
76
|
+
}
|
|
77
|
+
await rm(lock, { recursive: true });
|
|
78
|
+
await mkdir(lock, { mode: 0o700 });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
await privateWrite(join(lock, "owner.json"), { pid: process.pid });
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const stop = () => controller.abort();
|
|
84
|
+
process.once("SIGTERM", stop);
|
|
85
|
+
process.once("SIGINT", stop);
|
|
86
|
+
try {
|
|
87
|
+
while (!controller.signal.aborted && !options.signal?.aborted) {
|
|
88
|
+
let environment = { ...env };
|
|
89
|
+
try {
|
|
90
|
+
environment = {
|
|
91
|
+
...environment,
|
|
92
|
+
...(await privateRead(join(directory, "environment.json"))),
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code !== "ENOENT") throw error;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
environment = {
|
|
99
|
+
...environment,
|
|
100
|
+
...(await privateRead(join(directory, "provider-environment.json"))),
|
|
101
|
+
};
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code !== "ENOENT") throw error;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await privateWrite(join(directory, "supervisor.json"), {
|
|
107
|
+
pid: process.pid,
|
|
108
|
+
updatedAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
await superviseOnce(environment, options);
|
|
111
|
+
await sleep(options.intervalMs ?? 5000, undefined, {
|
|
112
|
+
signal: options.signal
|
|
113
|
+
? AbortSignal.any([controller.signal, options.signal])
|
|
114
|
+
: controller.signal,
|
|
115
|
+
}).catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
process.removeListener("SIGTERM", stop);
|
|
119
|
+
process.removeListener("SIGINT", stop);
|
|
120
|
+
await rm(lock, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
process.argv[1] === fileURLToPath(import.meta.url) ||
|
|
125
|
+
process.argv[1]?.endsWith("/runtime/supervisor.mjs") ||
|
|
126
|
+
process.argv[1]?.endsWith("\\runtime\\supervisor.mjs")
|
|
127
|
+
) {
|
|
128
|
+
await automaticRuntimeMain().catch(() => {
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { agentSetupMain, agentStatusMain } from "./agent-setup.mjs";
|
|
2
|
+
import { agentStartMain } from "./agent-runner.mjs";
|
|
3
|
+
import { installationMain } from "./installation.mjs";
|
|
4
|
+
|
|
5
|
+
/** Run from the cloud environment's startup command; secrets stay in its store. */
|
|
6
|
+
export async function cloudBootstrapMain(args = [], options = {}) {
|
|
7
|
+
const env = options.env ?? process.env;
|
|
8
|
+
const output = options.stdout ?? process.stdout;
|
|
9
|
+
if (args.includes("--help")) {
|
|
10
|
+
output.write(
|
|
11
|
+
"totem cloud setup [--provider codex|claude|qwen-local|command|http] [--repo-root <checkout>] [--install-mcp]\nProvision TOTEM_NETWORK_API_KEY or TOTEM_NETWORK_API_KEY_FILE and TOTEM_AGENT_ID in the environment secret/settings store. The setup command verifies the exact identity, saves a private profile, and starts its message and Mission worker.\n",
|
|
12
|
+
);
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
if (!env.TOTEM_NETWORK_API_KEY && !env.TOTEM_NETWORK_API_KEY_FILE) {
|
|
16
|
+
const loginArgs = ["--no-browser"];
|
|
17
|
+
const selected = args.indexOf("--provider");
|
|
18
|
+
if (selected >= 0 && args[selected + 1]) loginArgs.push("--provider", args[selected + 1]);
|
|
19
|
+
if (args.includes("--json")) loginArgs.push("--json");
|
|
20
|
+
return installationMain(loginArgs, options);
|
|
21
|
+
}
|
|
22
|
+
const profile = env.TOTEM_AGENT_ID ?? "default";
|
|
23
|
+
const setup = [
|
|
24
|
+
"--profile",
|
|
25
|
+
profile,
|
|
26
|
+
...(env.TOTEM_AGENT_ID ? ["--agent-id", env.TOTEM_AGENT_ID] : []),
|
|
27
|
+
"--base-url",
|
|
28
|
+
env.TOTEM_BASE_URL ?? "https://totemlayer.com",
|
|
29
|
+
"--json",
|
|
30
|
+
...args,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
if (await agentSetupMain(setup, options)) return 1;
|
|
34
|
+
let readiness;
|
|
35
|
+
const probe = {
|
|
36
|
+
...options,
|
|
37
|
+
stdout: {
|
|
38
|
+
write(value) {
|
|
39
|
+
output.write(value);
|
|
40
|
+
try {
|
|
41
|
+
readiness = JSON.parse(value);
|
|
42
|
+
} catch {
|
|
43
|
+
/* Only status JSON is inspected. */
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
if (await agentStatusMain(["--profile", profile, "--json"], probe)) return 1;
|
|
49
|
+
if (readiness?.checks?.model?.state !== "available") {
|
|
50
|
+
output.write(
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
ok: false,
|
|
53
|
+
error: {
|
|
54
|
+
code: "CLOUD_EXECUTOR_UNAVAILABLE",
|
|
55
|
+
message:
|
|
56
|
+
"The cloud connection is saved, but its selected executor is unavailable. Install and authenticate that executor before startup.",
|
|
57
|
+
},
|
|
58
|
+
}) + "\n",
|
|
59
|
+
);
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
if (await agentStartMain(["--profile", profile, "--background", "--json"], options)) return 1;
|
|
63
|
+
return agentStatusMain(["--profile", profile, "--json"], options);
|
|
64
|
+
}
|
|
@@ -7,9 +7,9 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
7
7
|
|
|
8
8
|
import { inspectLocalRepository } from "./repository.mjs";
|
|
9
9
|
import { localClientEnvironment, streamingClientInvocation } from "./platform-client.mjs";
|
|
10
|
+
import { runFile } from "./mission-worker.mjs";
|
|
10
11
|
|
|
11
12
|
const MAX_RUNS_PER_TASK = 16;
|
|
12
|
-
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
13
13
|
const MAX_PROMPT_BYTES = 32 * 1024;
|
|
14
14
|
const DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
15
15
|
|
|
@@ -84,7 +84,10 @@ function runnerCommand(provider, sessionId, platform = process.platform) {
|
|
|
84
84
|
if (provider === "codex")
|
|
85
85
|
invocation = sessionId
|
|
86
86
|
? { command: "codex", args: ["exec", "resume", "--json", sessionId, "-"] }
|
|
87
|
-
: {
|
|
87
|
+
: {
|
|
88
|
+
command: "codex",
|
|
89
|
+
args: ["exec", "--sandbox", "workspace-write", "--json", "-"],
|
|
90
|
+
};
|
|
88
91
|
else if (provider === "claude")
|
|
89
92
|
invocation = {
|
|
90
93
|
command: "claude",
|
|
@@ -106,7 +109,9 @@ function runnerCommand(provider, sessionId, platform = process.platform) {
|
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
function runnerEnvironment(repositoryRoot, environment = process.env) {
|
|
109
|
-
return localClientEnvironment(environment, {
|
|
112
|
+
return localClientEnvironment(environment, {
|
|
113
|
+
TOTEM_REPOSITORY_ROOT: repositoryRoot,
|
|
114
|
+
});
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
export async function runLocalCollaborationAgent({
|
|
@@ -120,79 +125,42 @@ export async function runLocalCollaborationAgent({
|
|
|
120
125
|
spawnProcess = spawn,
|
|
121
126
|
}) {
|
|
122
127
|
const { command, args } = runnerCommand(provider, sessionId, platform);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let totalOutputBytes = 0;
|
|
132
|
-
let observedSession = sessionId ?? null;
|
|
133
|
-
let finalResponse = "";
|
|
134
|
-
let finished = false;
|
|
135
|
-
const fail = (error) => {
|
|
136
|
-
if (finished) return;
|
|
137
|
-
finished = true;
|
|
138
|
-
clearTimeout(timer);
|
|
139
|
-
signal?.removeEventListener("abort", abort);
|
|
140
|
-
rejectRun(error);
|
|
141
|
-
};
|
|
142
|
-
const abort = () => child.kill("SIGTERM");
|
|
143
|
-
const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
|
|
144
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
145
|
-
if (signal?.aborted) abort();
|
|
146
|
-
child.stdout.on("data", (chunk) => {
|
|
147
|
-
totalOutputBytes += chunk.byteLength;
|
|
148
|
-
output += chunk.toString("utf8");
|
|
149
|
-
if (totalOutputBytes > MAX_OUTPUT_BYTES) child.kill("SIGTERM");
|
|
150
|
-
if (provider !== "codex") return;
|
|
151
|
-
let newline;
|
|
152
|
-
while ((newline = output.indexOf("\n")) >= 0) {
|
|
153
|
-
const line = output.slice(0, newline);
|
|
154
|
-
output = output.slice(newline + 1);
|
|
155
|
-
try {
|
|
156
|
-
const event = JSON.parse(line);
|
|
157
|
-
if (event.type === "thread.started" && typeof event.thread_id === "string")
|
|
158
|
-
observedSession = event.thread_id;
|
|
159
|
-
if (event.type === "item.completed" && event.item?.type === "agent_message")
|
|
160
|
-
finalResponse = String(event.item.text ?? "");
|
|
161
|
-
} catch {
|
|
162
|
-
// Ignore non-JSON progress lines; never echo model output to CLI logs.
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
child.stderr.on("data", () => {});
|
|
167
|
-
child.once("error", (error) => fail(error));
|
|
168
|
-
child.once("close", (code) => {
|
|
169
|
-
if (finished) return;
|
|
170
|
-
if (signal?.aborted) return fail(new Error("The collaboration listener was stopped."));
|
|
171
|
-
if (code !== 0)
|
|
172
|
-
return fail(
|
|
173
|
-
new Error(
|
|
174
|
-
`The ${provider} run exited with status ${code ?? "unknown"}. Check that client's local logs and permissions.`,
|
|
175
|
-
),
|
|
176
|
-
);
|
|
177
|
-
if (provider === "claude") {
|
|
178
|
-
try {
|
|
179
|
-
const result = JSON.parse(output);
|
|
180
|
-
observedSession = result.session_id ?? observedSession;
|
|
181
|
-
finalResponse = typeof result.result === "string" ? result.result : "";
|
|
182
|
-
if (result.is_error) return fail(new Error("Claude reported a failed run."));
|
|
183
|
-
} catch {
|
|
184
|
-
return fail(new Error("Claude returned an invalid JSON result."));
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (!observedSession)
|
|
188
|
-
return fail(new Error(`${provider} did not return a resumable session ID.`));
|
|
189
|
-
finished = true;
|
|
190
|
-
clearTimeout(timer);
|
|
191
|
-
signal?.removeEventListener("abort", abort);
|
|
192
|
-
resolveRun({ sessionId: observedSession, finalResponse });
|
|
193
|
-
});
|
|
194
|
-
child.stdin.end(prompt);
|
|
128
|
+
const { stdout } = await runFile(command, args, {
|
|
129
|
+
cwd: repositoryRoot,
|
|
130
|
+
env: runnerEnvironment(repositoryRoot),
|
|
131
|
+
input: prompt,
|
|
132
|
+
signal,
|
|
133
|
+
timeoutMs,
|
|
134
|
+
platform,
|
|
135
|
+
spawnProcess,
|
|
195
136
|
});
|
|
137
|
+
let observedSession = sessionId ?? null;
|
|
138
|
+
let finalResponse = "";
|
|
139
|
+
if (provider === "claude") {
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = JSON.parse(stdout);
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error("Claude returned an invalid JSON result.");
|
|
145
|
+
}
|
|
146
|
+
observedSession = result.session_id ?? observedSession;
|
|
147
|
+
finalResponse = typeof result.result === "string" ? result.result : "";
|
|
148
|
+
if (result.is_error) throw new Error("Claude reported a failed run.");
|
|
149
|
+
} else {
|
|
150
|
+
for (const line of stdout.split("\n")) {
|
|
151
|
+
try {
|
|
152
|
+
const event = JSON.parse(line);
|
|
153
|
+
if (event.type === "thread.started" && typeof event.thread_id === "string")
|
|
154
|
+
observedSession = event.thread_id;
|
|
155
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message")
|
|
156
|
+
finalResponse = String(event.item.text ?? "");
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore non-JSON progress lines; never echo model output to CLI logs.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!observedSession) throw new Error(`${provider} did not return a resumable session ID.`);
|
|
163
|
+
return { sessionId: observedSession, finalResponse };
|
|
196
164
|
}
|
|
197
165
|
|
|
198
166
|
async function readJournal(file) {
|
|
@@ -216,7 +184,12 @@ async function latestOutboundTaskCursor(client, taskId, cursor, signal) {
|
|
|
216
184
|
let latest = cursor;
|
|
217
185
|
for (let page = 0; page < 20; page++) {
|
|
218
186
|
const result = await client.messages.list(
|
|
219
|
-
{
|
|
187
|
+
{
|
|
188
|
+
direction: "outbound",
|
|
189
|
+
taskId,
|
|
190
|
+
limit: 100,
|
|
191
|
+
...(latest ? { cursor: latest } : {}),
|
|
192
|
+
},
|
|
220
193
|
{ signal },
|
|
221
194
|
);
|
|
222
195
|
const messages = result.data ?? [];
|
|
@@ -247,7 +220,9 @@ export async function runCollaborationListener({
|
|
|
247
220
|
if (!Number.isInteger(pollMs) || pollMs < 1000 || pollMs > 60000)
|
|
248
221
|
throw new Error("--poll-ms must be between 1000 and 60000.");
|
|
249
222
|
const root = resolve(repositoryRoot);
|
|
250
|
-
const repository = await inspectLocalRepository({
|
|
223
|
+
const repository = await inspectLocalRepository({
|
|
224
|
+
TOTEM_REPOSITORY_ROOT: root,
|
|
225
|
+
});
|
|
251
226
|
const identity = await client.agent({ signal });
|
|
252
227
|
const peer = await client.resolve(peerAddress, { signal });
|
|
253
228
|
if (!peer?.id || peer.address !== peerAddress) throw new Error("The allowed peer was not found.");
|
|
@@ -267,7 +242,12 @@ export async function runCollaborationListener({
|
|
|
267
242
|
}
|
|
268
243
|
try {
|
|
269
244
|
const journal = await readJournal(journalFile);
|
|
270
|
-
const binding = {
|
|
245
|
+
const binding = {
|
|
246
|
+
agentId: identity.id,
|
|
247
|
+
peerAddress,
|
|
248
|
+
repository: repository.repository,
|
|
249
|
+
root,
|
|
250
|
+
};
|
|
271
251
|
if (journal.binding && JSON.stringify(journal.binding) !== JSON.stringify(binding))
|
|
272
252
|
throw new Error("This journal belongs to a different agent, peer, or checkout.");
|
|
273
253
|
if (!journal.binding) {
|
|
@@ -301,7 +281,11 @@ export async function runCollaborationListener({
|
|
|
301
281
|
continue;
|
|
302
282
|
}
|
|
303
283
|
if (prior?.status === "started" || prior?.status === "failed") {
|
|
304
|
-
emit({
|
|
284
|
+
emit({
|
|
285
|
+
type: "needs_attention",
|
|
286
|
+
messageId: message.id,
|
|
287
|
+
taskId: message.taskId,
|
|
288
|
+
});
|
|
305
289
|
continue;
|
|
306
290
|
}
|
|
307
291
|
const task = await client.tasks.get(message.taskId, { signal });
|
|
@@ -352,7 +336,12 @@ export async function runCollaborationListener({
|
|
|
352
336
|
const outboundBefore = await latestOutboundTaskCursor(client, task.id, undefined, signal);
|
|
353
337
|
journal.messages[message.id] = { status: "started", taskId: task.id };
|
|
354
338
|
await saveJournal(journalFile, journal);
|
|
355
|
-
emit({
|
|
339
|
+
emit({
|
|
340
|
+
type: "started",
|
|
341
|
+
messageId: message.id,
|
|
342
|
+
taskId: task.id,
|
|
343
|
+
provider,
|
|
344
|
+
});
|
|
356
345
|
try {
|
|
357
346
|
const result = await runAgent({
|
|
358
347
|
provider,
|
|
@@ -377,12 +366,23 @@ export async function runCollaborationListener({
|
|
|
377
366
|
sessionId: result.sessionId,
|
|
378
367
|
runCount: (savedTask?.runCount ?? 0) + 1,
|
|
379
368
|
};
|
|
380
|
-
journal.messages[message.id] = {
|
|
369
|
+
journal.messages[message.id] = {
|
|
370
|
+
status: "completed",
|
|
371
|
+
taskId: task.id,
|
|
372
|
+
};
|
|
381
373
|
await saveJournal(journalFile, journal);
|
|
382
374
|
await client.messages.ack(message.id, { signal });
|
|
383
|
-
emit({
|
|
375
|
+
emit({
|
|
376
|
+
type: "completed",
|
|
377
|
+
messageId: message.id,
|
|
378
|
+
taskId: task.id,
|
|
379
|
+
provider,
|
|
380
|
+
});
|
|
384
381
|
} catch (error) {
|
|
385
|
-
journal.messages[message.id] = {
|
|
382
|
+
journal.messages[message.id] = {
|
|
383
|
+
status: "failed",
|
|
384
|
+
taskId: task.id,
|
|
385
|
+
};
|
|
386
386
|
await saveJournal(journalFile, journal);
|
|
387
387
|
await client.tasks
|
|
388
388
|
.postUpdate(task.id, listenerStatusText(provider, "failed"), {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { runMissionAgentService } from "./mission-agent-service.mjs";
|
|
3
|
+
import { runRuntime } from "./network-runtime.mjs";
|
|
4
|
+
|
|
5
|
+
// Both queues use the same verified identity and private profile. A stopped or
|
|
6
|
+
// revoked connection cancels both consumers; Missions retains its own leases.
|
|
7
|
+
export async function runConnectedAgentService(settings, dependencies = {}) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const signal = settings.signal
|
|
10
|
+
? AbortSignal.any([settings.signal, controller.signal])
|
|
11
|
+
: controller.signal;
|
|
12
|
+
const emit = settings.emit ?? (() => {});
|
|
13
|
+
const phases = { messages: "starting", missions: "starting" };
|
|
14
|
+
const report = (queue, event) => {
|
|
15
|
+
phases[queue] = event.type === "completed" ? "waiting" : event.type;
|
|
16
|
+
const values = Object.values(phases);
|
|
17
|
+
const type =
|
|
18
|
+
["reconnecting", "starting", "blocked", "working"].find((phase) => values.includes(phase)) ??
|
|
19
|
+
"waiting";
|
|
20
|
+
emit({ ...event, type });
|
|
21
|
+
};
|
|
22
|
+
const messages = async () => {
|
|
23
|
+
let attempts = 0;
|
|
24
|
+
while (!signal.aborted) {
|
|
25
|
+
try {
|
|
26
|
+
await (dependencies.runRuntime ?? runRuntime)({
|
|
27
|
+
...settings,
|
|
28
|
+
signal,
|
|
29
|
+
stateDir: settings.stateDirectory,
|
|
30
|
+
allowAgents: [],
|
|
31
|
+
modelTimeoutMs: 900000,
|
|
32
|
+
replyTimeoutMs: 90000,
|
|
33
|
+
pollMs: settings.pollMs ?? 5000,
|
|
34
|
+
emit(event) {
|
|
35
|
+
if (event.type === "runtime_started") {
|
|
36
|
+
attempts = 0;
|
|
37
|
+
report("messages", { type: "waiting" });
|
|
38
|
+
} else if (event.type === "runtime_error")
|
|
39
|
+
report("messages", { type: "blocked", code: event.error?.code });
|
|
40
|
+
else if (["message_handled", "chat_completed"].includes(event.type))
|
|
41
|
+
report("messages", { type: "waiting" });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (!signal.aborted) throw new Error("The message runtime stopped unexpectedly.");
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (signal.aborted) return;
|
|
47
|
+
if (
|
|
48
|
+
error.status === 401 ||
|
|
49
|
+
error.status === 403 ||
|
|
50
|
+
error.code === "AGENT_IDENTITY_MISMATCH"
|
|
51
|
+
)
|
|
52
|
+
throw error;
|
|
53
|
+
report("messages", { type: "reconnecting" });
|
|
54
|
+
await sleep(
|
|
55
|
+
error.status === 429 ? 30000 : Math.min(30000, 1000 * 2 ** Math.min(attempts++, 5)),
|
|
56
|
+
undefined,
|
|
57
|
+
{ signal },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const guarded = async (run) => {
|
|
63
|
+
try {
|
|
64
|
+
await run();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!signal.aborted) {
|
|
67
|
+
controller.abort();
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const results = await Promise.allSettled([
|
|
73
|
+
guarded(messages),
|
|
74
|
+
guarded(() =>
|
|
75
|
+
(dependencies.runMissions ?? runMissionAgentService)({
|
|
76
|
+
...settings,
|
|
77
|
+
signal,
|
|
78
|
+
emit: (event) => report("missions", event),
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
]);
|
|
82
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
83
|
+
if (failure) throw failure.reason;
|
|
84
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { privateRead, privateWrite } from "./runtime-installation.mjs";
|
|
6
|
+
import { detectInstalledProvider, providerArguments } from "./provider-detection.mjs";
|
|
7
|
+
|
|
8
|
+
function origin(value) {
|
|
9
|
+
const url = new URL(value ?? "https://totemlayer.com");
|
|
10
|
+
if (
|
|
11
|
+
url.username ||
|
|
12
|
+
url.password ||
|
|
13
|
+
url.search ||
|
|
14
|
+
url.hash ||
|
|
15
|
+
url.pathname !== "/" ||
|
|
16
|
+
(url.protocol !== "https:" &&
|
|
17
|
+
!(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
18
|
+
)
|
|
19
|
+
throw new Error("Choose an HTTPS Totem origin or local development origin.");
|
|
20
|
+
return url.origin;
|
|
21
|
+
}
|
|
22
|
+
async function request(baseUrl, path, options = {}, fetcher = fetch) {
|
|
23
|
+
const response = await fetcher(baseUrl + path, {
|
|
24
|
+
...options,
|
|
25
|
+
redirect: "error",
|
|
26
|
+
signal: AbortSignal.timeout(15000),
|
|
27
|
+
});
|
|
28
|
+
const result = await response.json();
|
|
29
|
+
if (!response.ok && response.status !== 410)
|
|
30
|
+
throw new Error("Totem could not complete this installation request.");
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
export async function beginDeviceLogin(runtime, env = process.env, options = {}) {
|
|
34
|
+
const baseUrl = origin(env.TOTEM_BASE_URL);
|
|
35
|
+
let pending;
|
|
36
|
+
try {
|
|
37
|
+
pending = await privateRead(join(runtime.directory, "connection-request.json"));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code !== "ENOENT") throw error;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
!pending ||
|
|
43
|
+
pending.baseUrl !== baseUrl ||
|
|
44
|
+
pending.requestedAgentId !== env.TOTEM_AGENT_ID ||
|
|
45
|
+
pending.provider !== (env.TOTEM_PROVIDER ?? "auto") ||
|
|
46
|
+
new Date(pending.expiresAt) <= new Date()
|
|
47
|
+
) {
|
|
48
|
+
const result = await request(
|
|
49
|
+
baseUrl,
|
|
50
|
+
"/api/installations",
|
|
51
|
+
{
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/json" },
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
deviceName: hostname().slice(0, 100),
|
|
56
|
+
provider: env.TOTEM_PROVIDER ?? "auto",
|
|
57
|
+
...(env.TOTEM_AGENT_ID ? { agentId: env.TOTEM_AGENT_ID } : {}),
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
options.fetch,
|
|
61
|
+
);
|
|
62
|
+
if (
|
|
63
|
+
!/^[a-f0-9-]{36}$/.test(result.id) ||
|
|
64
|
+
!/^[A-Za-z0-9_-]{43}$/.test(result.token) ||
|
|
65
|
+
!/^[A-Z2-9]{12}$/.test(result.code) ||
|
|
66
|
+
!Number.isFinite(Date.parse(result.expiresAt))
|
|
67
|
+
)
|
|
68
|
+
throw new Error("Totem returned an invalid installation request.");
|
|
69
|
+
pending = {
|
|
70
|
+
...result,
|
|
71
|
+
baseUrl,
|
|
72
|
+
requestedAgentId: env.TOTEM_AGENT_ID,
|
|
73
|
+
provider: env.TOTEM_PROVIDER ?? "auto",
|
|
74
|
+
};
|
|
75
|
+
await privateWrite(join(runtime.directory, "connection-request.json"), pending);
|
|
76
|
+
}
|
|
77
|
+
const url = `${baseUrl}/connect?code=${pending.code}`;
|
|
78
|
+
if (options.openBrowser !== false) {
|
|
79
|
+
const command =
|
|
80
|
+
process.platform === "darwin"
|
|
81
|
+
? ["open", [url]]
|
|
82
|
+
: process.platform === "win32"
|
|
83
|
+
? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
|
|
84
|
+
: ["xdg-open", [url]];
|
|
85
|
+
await (options.execute ?? promisify(execFile))(command[0], command[1], { timeout: 5000 }).catch(
|
|
86
|
+
() => {},
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return { url, code: pending.code, expiresAt: pending.expiresAt };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function resumeDeviceLogin(runtime, env = process.env, options = {}) {
|
|
93
|
+
let staged;
|
|
94
|
+
try {
|
|
95
|
+
staged = await privateRead(join(runtime.directory, "connection-ready.json"));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error.code !== "ENOENT") throw error;
|
|
98
|
+
}
|
|
99
|
+
if (!staged) {
|
|
100
|
+
let pending;
|
|
101
|
+
try {
|
|
102
|
+
pending = await privateRead(join(runtime.directory, "connection-request.json"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.code === "ENOENT") return;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await request(
|
|
109
|
+
origin(pending.baseUrl),
|
|
110
|
+
`/api/installations/${pending.id}`,
|
|
111
|
+
{ headers: { "x-totem-installation-token": pending.token } },
|
|
112
|
+
options.fetch,
|
|
113
|
+
);
|
|
114
|
+
if (result.status !== "approved") return;
|
|
115
|
+
if (
|
|
116
|
+
!/^[a-f0-9-]{36}$/.test(result.agentId) ||
|
|
117
|
+
!/^tn_(?:test|live)_[A-Za-z0-9_-]{43}$/.test(result.key)
|
|
118
|
+
)
|
|
119
|
+
throw new Error("Totem returned an invalid connection.");
|
|
120
|
+
staged = {
|
|
121
|
+
agentId: result.agentId,
|
|
122
|
+
key: result.key,
|
|
123
|
+
baseUrl: pending.baseUrl,
|
|
124
|
+
requestId: pending.id,
|
|
125
|
+
token: pending.token,
|
|
126
|
+
};
|
|
127
|
+
// Persist privately before acknowledging, so an interrupted setup never loses the key.
|
|
128
|
+
await privateWrite(join(runtime.directory, "connection-ready.json"), staged);
|
|
129
|
+
}
|
|
130
|
+
if (staged.requestId) {
|
|
131
|
+
const receipt = await request(
|
|
132
|
+
staged.baseUrl,
|
|
133
|
+
`/api/installations/${staged.requestId}`,
|
|
134
|
+
{ method: "POST", headers: { "x-totem-installation-token": staged.token } },
|
|
135
|
+
options.fetch,
|
|
136
|
+
);
|
|
137
|
+
if (receipt.status !== "completed")
|
|
138
|
+
throw new Error("This connection expired before it was saved. Run totem login again.");
|
|
139
|
+
delete staged.requestId;
|
|
140
|
+
delete staged.token;
|
|
141
|
+
await privateWrite(join(runtime.directory, "connection-ready.json"), staged);
|
|
142
|
+
}
|
|
143
|
+
const provider = await (options.detectProvider ?? detectInstalledProvider)(env);
|
|
144
|
+
if (!provider) return { state: "executor_needed" };
|
|
145
|
+
const { agentSetupMain } = await import("./agent-setup.mjs");
|
|
146
|
+
const status = await (options.setup ?? agentSetupMain)(
|
|
147
|
+
[
|
|
148
|
+
"--profile",
|
|
149
|
+
staged.agentId ?? "default",
|
|
150
|
+
...(staged.agentId ? ["--agent-id", staged.agentId] : []),
|
|
151
|
+
"--base-url",
|
|
152
|
+
origin(staged.baseUrl),
|
|
153
|
+
...(await providerArguments(provider, env)),
|
|
154
|
+
"--no-start",
|
|
155
|
+
"--json",
|
|
156
|
+
],
|
|
157
|
+
{
|
|
158
|
+
env: { ...env, TOTEM_NETWORK_API_KEY: staged.key },
|
|
159
|
+
stdout: { write() {} },
|
|
160
|
+
stderr: { write() {} },
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
if (status) throw new Error("The approved connection could not be saved.");
|
|
164
|
+
const { rm } = await import("node:fs/promises");
|
|
165
|
+
await rm(join(runtime.directory, "connection-ready.json"));
|
|
166
|
+
await rm(join(runtime.directory, "connection-request.json"), { force: true });
|
|
167
|
+
return { state: "connected", profile: staged.agentId };
|
|
168
|
+
}
|