totemlayer 0.1.44 → 0.1.63
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 +48 -11
- package/bin/agent-memory.mjs +255 -0
- package/bin/agent-profile.mjs +42 -5
- package/bin/agent-runner.mjs +55 -12
- package/bin/agent-setup.mjs +136 -50
- package/bin/automatic-runtime.mjs +26 -7
- package/bin/chat-prompt-limit.mjs +67 -0
- package/bin/codex-chat-bridge.mjs +36 -8
- package/bin/codex-chat-readiness.mjs +7 -0
- package/bin/codex-human-chat-bridge.mjs +209 -0
- package/bin/collaboration-listener.mjs +2 -1
- package/bin/connected-agent-service.mjs +4 -1
- package/bin/inbox-queue.mjs +2 -1
- package/bin/inference-broker.mjs +361 -0
- package/bin/installation.mjs +92 -60
- package/bin/isolated-agent-entry.mjs +35 -0
- package/bin/local-mcp.mjs +43 -4
- package/bin/mcp-config.d.mts +11 -0
- package/bin/mcp-config.mjs +56 -0
- package/bin/mcp-install.mjs +143 -12
- package/bin/mcp-listener.d.mts +22 -0
- package/bin/mcp-listener.mjs +4 -0
- package/bin/mcp-runtime.mjs +3 -1
- package/bin/mcp.mjs +331 -94
- package/bin/mission-agent-service.mjs +2 -1
- package/bin/mission-worker.mjs +13 -2
- package/bin/model-adapter.mjs +245 -107
- package/bin/network-contract.mjs +254 -0
- package/bin/network-runtime.mjs +162 -47
- package/bin/network.mjs +32 -7
- package/bin/platform-client.mjs +25 -3
- package/bin/process-isolation.mjs +214 -0
- package/bin/provider-detection.mjs +1 -4
- package/bin/runtime-diagnostics.mjs +1 -0
- package/bin/sdk/index.d.ts +50 -6
- package/bin/sdk/index.js +1 -1
- package/bin/sdk/inspector.js +1 -1
- package/bin/sdk/listener.d.ts +57 -0
- package/bin/sdk/listener.js +1 -0
- package/bin/sdk/types.d.ts +62 -0
- package/bin/startup-diagnostics.mjs +168 -0
- package/bin/totem.mjs +17 -3
- package/bin/worker-core/adapters.mjs +30 -13
- package/bin/worker-core/errors.mjs +4 -0
- package/package.json +1 -1
- package/scripts/prepare.mjs +28 -8
package/bin/agent-setup.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
4
5
|
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { parseArgs, promisify, stripVTControlCharacters } from "node:util";
|
|
6
7
|
|
|
@@ -12,6 +13,7 @@ import { installedClientInvocation, localClientEnvironment } from "./platform-cl
|
|
|
12
13
|
import { inspectLocalRepository } from "./repository.mjs";
|
|
13
14
|
import { Totem } from "./sdk/index.js";
|
|
14
15
|
import { detectInstalledProvider } from "./provider-detection.mjs";
|
|
16
|
+
import { privateRead, privateWrite } from "./runtime-installation.mjs";
|
|
15
17
|
import { RUNTIME_ERROR_CODES } from "./runtime-diagnostics.mjs";
|
|
16
18
|
import { checkCodexQueueSupport, CODEX_QUEUE_UNAVAILABLE_MESSAGE } from "./executor-readiness.mjs";
|
|
17
19
|
|
|
@@ -23,6 +25,7 @@ const IDENTITY_TIMEOUT_MS = 15000;
|
|
|
23
25
|
export const AGENT_SETUP_HELP = `Connect your agent once
|
|
24
26
|
|
|
25
27
|
totem setup [--profile default] [--provider qwen-local|ollama-local|nim-local|codex|claude|command|http]
|
|
28
|
+
[--connection-only]
|
|
26
29
|
[--key-file <private-file>] [--base-url <Totem-origin>] [--agent-id <expected-id>]
|
|
27
30
|
[--repo-root <checkout>]... [--model qwen3.5-9b]
|
|
28
31
|
[--qwen-url http://127.0.0.1:8087/v1] [--install-mcp] [--replace] [--json]
|
|
@@ -35,7 +38,8 @@ In a terminal, setup can ask for a hidden connection key. Otherwise use --key-fi
|
|
|
35
38
|
or provision TOTEM_NETWORK_API_KEY through your cloud environment secret store.
|
|
36
39
|
Keys are never accepted as command arguments. Repeating unchanged setup is safe.
|
|
37
40
|
--replace explicitly replaces this profile's connection settings and credential.
|
|
38
|
-
--
|
|
41
|
+
--connection-only verifies and saves identity without an executor or worker startup.
|
|
42
|
+
--repo-root approves a checkout for coding Missions; omit it to grant no checkout access.
|
|
39
43
|
--install-mcp connects Codex or Claude using the saved private key-file path.
|
|
40
44
|
Setup automatically detects the installed executor, connects its MCP tools, and
|
|
41
45
|
starts a supervised listener for messages, human Chat, and approved Missions.
|
|
@@ -76,6 +80,7 @@ function parse(args, status = false) {
|
|
|
76
80
|
"agent-token-env": { type: "string" },
|
|
77
81
|
"codex-thread": { type: "string" },
|
|
78
82
|
"install-mcp": { type: "boolean" },
|
|
83
|
+
"connection-only": { type: "boolean" },
|
|
79
84
|
"no-start": { type: "boolean" },
|
|
80
85
|
"no-mcp": { type: "boolean" },
|
|
81
86
|
replace: { type: "boolean" },
|
|
@@ -102,6 +107,24 @@ function parse(args, status = false) {
|
|
|
102
107
|
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(options["codex-thread"])
|
|
103
108
|
)
|
|
104
109
|
fail("The owner-selected Codex chat ID must be a valid UUID.");
|
|
110
|
+
if (
|
|
111
|
+
options["connection-only"] &&
|
|
112
|
+
[
|
|
113
|
+
"provider",
|
|
114
|
+
"model",
|
|
115
|
+
"repo-root",
|
|
116
|
+
"install-mcp",
|
|
117
|
+
"qwen-url",
|
|
118
|
+
"ollama-url",
|
|
119
|
+
"nim-url",
|
|
120
|
+
"command",
|
|
121
|
+
"command-arg",
|
|
122
|
+
"agent-url",
|
|
123
|
+
"agent-token-env",
|
|
124
|
+
"codex-thread",
|
|
125
|
+
].some((name) => options[name] !== undefined)
|
|
126
|
+
)
|
|
127
|
+
fail("Use --connection-only without executor, MCP installation, or repository options.");
|
|
105
128
|
return options;
|
|
106
129
|
}
|
|
107
130
|
|
|
@@ -215,10 +238,7 @@ function dependencies(options) {
|
|
|
215
238
|
ask,
|
|
216
239
|
readSecret: readHiddenCredential,
|
|
217
240
|
runClient: async (client, args, environment) => {
|
|
218
|
-
const invocation =
|
|
219
|
-
process.platform === "win32"
|
|
220
|
-
? installedClientInvocation(client, args, environment)
|
|
221
|
-
: { command: client, args };
|
|
241
|
+
const invocation = installedClientInvocation(client, args, environment);
|
|
222
242
|
const pending = execute(invocation.command, invocation.args, {
|
|
223
243
|
env: localClientEnvironment(environment),
|
|
224
244
|
timeout: 5000,
|
|
@@ -338,7 +358,9 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
338
358
|
fail("The saved profile could not be read safely. Inspect it before replacing it.");
|
|
339
359
|
}
|
|
340
360
|
const interactive = Boolean(io.stdin.isTTY && !flags.json);
|
|
341
|
-
let mode = flags
|
|
361
|
+
let mode = flags["connection-only"]
|
|
362
|
+
? "none"
|
|
363
|
+
: (flags.provider ?? previous?.client ?? previous?.provider);
|
|
342
364
|
if (!mode || mode === "auto")
|
|
343
365
|
mode = await (io.detectProvider ?? detectInstalledProvider)(io.env);
|
|
344
366
|
if (!mode && interactive) {
|
|
@@ -347,28 +369,38 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
347
369
|
}
|
|
348
370
|
if (!mode)
|
|
349
371
|
fail(
|
|
350
|
-
"
|
|
372
|
+
"Use --connection-only to verify and save your connection without an executor. For automatic processing, choose --provider with an authenticated AI client, installed local model, custom command, or HTTPS agent endpoint.",
|
|
351
373
|
);
|
|
352
374
|
if (
|
|
353
|
-
![
|
|
354
|
-
|
|
355
|
-
|
|
375
|
+
![
|
|
376
|
+
"none",
|
|
377
|
+
"qwen-local",
|
|
378
|
+
"ollama-local",
|
|
379
|
+
"nim-local",
|
|
380
|
+
"codex",
|
|
381
|
+
"claude",
|
|
382
|
+
"command",
|
|
383
|
+
"http",
|
|
384
|
+
].includes(mode)
|
|
356
385
|
)
|
|
357
386
|
fail("Choose qwen-local, ollama-local, nim-local, codex, claude, command, or http.");
|
|
358
387
|
if (flags["codex-thread"] !== undefined && mode !== "codex")
|
|
359
388
|
fail("Choose --provider codex before binding an existing Codex chat.");
|
|
360
389
|
const codexThreadId =
|
|
361
390
|
mode === "codex" ? (flags["codex-thread"] ?? previous?.codexThreadId) : undefined;
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
391
|
+
const connectionOnly = mode === "none";
|
|
392
|
+
const adapterSettings = connectionOnly
|
|
393
|
+
? {}
|
|
394
|
+
: Object.fromEntries(
|
|
395
|
+
[
|
|
396
|
+
["command", flags.command ?? previous?.command],
|
|
397
|
+
["commandArgs", flags["command-arg"] ?? previous?.commandArgs],
|
|
398
|
+
["agentUrl", flags["agent-url"] ?? previous?.agentUrl],
|
|
399
|
+
["agentTokenEnv", flags["agent-token-env"] ?? previous?.agentTokenEnv],
|
|
400
|
+
["ollamaUrl", flags["ollama-url"] ?? previous?.ollamaUrl],
|
|
401
|
+
["nimUrl", flags["nim-url"] ?? previous?.nimUrl],
|
|
402
|
+
].filter(([, value]) => value !== undefined),
|
|
403
|
+
);
|
|
372
404
|
if (isExternalModelProvider(mode)) {
|
|
373
405
|
try {
|
|
374
406
|
validateModelAdapter(mode, adapterSettings);
|
|
@@ -387,7 +419,7 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
387
419
|
"This profile is bound to another origin. Choose a new profile or explicitly use --replace with a key for the new origin.",
|
|
388
420
|
);
|
|
389
421
|
let selectedRoots = flags["repo-root"];
|
|
390
|
-
if (!selectedRoots && !previous && interactive) {
|
|
422
|
+
if (!connectionOnly && !selectedRoots && !previous && interactive) {
|
|
391
423
|
const chosen = await io.ask(
|
|
392
424
|
"Checkout to approve for coding Missions (leave blank for connection only): ",
|
|
393
425
|
io,
|
|
@@ -462,7 +494,9 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
462
494
|
repositoryRoots,
|
|
463
495
|
...(["codex", "claude"].includes(mode) ? { client: mode } : {}),
|
|
464
496
|
...(codexThreadId === undefined ? {} : { codexThreadId }),
|
|
465
|
-
...(previous?.interactiveRequired
|
|
497
|
+
...(!connectionOnly && previous?.interactiveRequired
|
|
498
|
+
? { interactiveRequired: true }
|
|
499
|
+
: {}),
|
|
466
500
|
},
|
|
467
501
|
key,
|
|
468
502
|
{
|
|
@@ -487,14 +521,24 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
487
521
|
let automatic;
|
|
488
522
|
// Installer handoff defers activation without turning an owner-approved new
|
|
489
523
|
// connection into a persistent opt-out. An existing pause stays untouched.
|
|
490
|
-
if (!flags["no-start"] && !io.deferActivation) {
|
|
524
|
+
if (!connectionOnly && !flags["no-start"] && !io.deferActivation) {
|
|
491
525
|
try {
|
|
492
526
|
const activate =
|
|
493
527
|
io.activateConnection ?? (await import("./installation.mjs")).activateConnection;
|
|
494
528
|
automatic = await activate(profile, io.env);
|
|
495
529
|
if (automatic.entry) io.env = { ...io.env, TOTEM_RUNTIME_ENTRY: automatic.entry };
|
|
496
|
-
} catch {
|
|
530
|
+
} catch (error) {
|
|
531
|
+
const diagnostic = startupDiagnostic(error);
|
|
532
|
+
if (!diagnostic.retryable)
|
|
533
|
+
await privateWrite(join(profile.stateDirectory, "automatic-health.json"), {
|
|
534
|
+
state: "needs_attention",
|
|
535
|
+
code: "LISTENER_START_FAILED",
|
|
536
|
+
...diagnostic,
|
|
537
|
+
retryAt: null,
|
|
538
|
+
updatedAt: new Date().toISOString(),
|
|
539
|
+
});
|
|
497
540
|
automatic = {
|
|
541
|
+
...diagnostic,
|
|
498
542
|
state: "needs_attention",
|
|
499
543
|
message:
|
|
500
544
|
"Your connection is saved. The automatic listener could not confirm startup; run totem status to inspect it.",
|
|
@@ -503,27 +547,32 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
503
547
|
}
|
|
504
548
|
const install = Boolean(profile.client && !flags["no-mcp"] && !identity.listenerConnectionId);
|
|
505
549
|
const mcp = install ? await configureMcp(profile, previous, io) : undefined;
|
|
506
|
-
const next =
|
|
550
|
+
const next = connectionOnly
|
|
551
|
+
? `Connection saved. Automatic receiving is not configured. To add an executor, run totem setup --profile ${profile.profileName} --provider <provider> --replace with its settings.`
|
|
552
|
+
: `Run totem status --profile ${profile.profileName} to check your connection and automatic listener.`;
|
|
507
553
|
const report = {
|
|
508
554
|
ok: (!mcp || mcp.state !== "needs_attention") && automatic?.state !== "needs_attention",
|
|
509
555
|
profile: profile.profileName,
|
|
510
556
|
agent: identity.address,
|
|
511
557
|
origin: baseUrl,
|
|
512
558
|
connection: "verified",
|
|
513
|
-
mode,
|
|
559
|
+
mode: connectionOnly ? "connection-only" : mode,
|
|
514
560
|
approvedRepositories: repositoryRoots.length,
|
|
515
|
-
worker:
|
|
516
|
-
|
|
517
|
-
(
|
|
561
|
+
worker: connectionOnly
|
|
562
|
+
? "not_configured"
|
|
563
|
+
: (automatic?.state ??
|
|
564
|
+
(io.deferActivation && !flags["no-start"]
|
|
565
|
+
? "activation_pending"
|
|
566
|
+
: "disabled_by_request")),
|
|
518
567
|
...(automatic ? { automatic } : {}),
|
|
519
568
|
...(mcp ? { mcp } : {}),
|
|
520
|
-
next,
|
|
569
|
+
next: automatic?.retryable === false ? automatic.recovery : next,
|
|
521
570
|
};
|
|
522
571
|
io.stdout.write(
|
|
523
572
|
safeOutput(
|
|
524
573
|
flags.json
|
|
525
574
|
? report
|
|
526
|
-
: `Connected ${identity.address} at ${baseUrl}.\n${mcp ? `${mcp.message}\n` : ""}${next}\n`,
|
|
575
|
+
: `Connected ${identity.address} at ${baseUrl}.\n${mcp ? `${mcp.message}\n` : ""}${automatic?.state === "needs_attention" ? `${automatic.message ?? "Listener startup needs attention."}\n${automatic.cause ?? ""}\n${automatic.recovery ?? ""}\n` : ""}${report.next}\n`,
|
|
527
576
|
key,
|
|
528
577
|
) + (flags.json ? "\n" : ""),
|
|
529
578
|
);
|
|
@@ -540,6 +589,11 @@ export async function agentSetupMain(args, options = {}) {
|
|
|
540
589
|
|
|
541
590
|
async function executorReadiness(profile, io) {
|
|
542
591
|
const provider = profile.client ?? profile.provider;
|
|
592
|
+
if (provider === "none")
|
|
593
|
+
return {
|
|
594
|
+
state: "not_configured",
|
|
595
|
+
message: "Connection-only profile. No executor is selected.",
|
|
596
|
+
};
|
|
543
597
|
if (!isExternalModelProvider(provider)) return modelReadiness(profile, io);
|
|
544
598
|
try {
|
|
545
599
|
validateModelAdapter(provider, profile);
|
|
@@ -692,7 +746,7 @@ export async function agentStatusMain(args, options = {}) {
|
|
|
692
746
|
state: profile.repositoryRoots.length ? "verified" : "not_configured",
|
|
693
747
|
message: profile.repositoryRoots.length
|
|
694
748
|
? "Approved repository origins match their saved checkouts."
|
|
695
|
-
: "
|
|
749
|
+
: "No checkout is approved. Add an explicit --repo-root with setup before coding Missions.",
|
|
696
750
|
};
|
|
697
751
|
for (const approved of profile.repositoryRoots) {
|
|
698
752
|
try {
|
|
@@ -731,11 +785,17 @@ export async function agentStatusMain(args, options = {}) {
|
|
|
731
785
|
};
|
|
732
786
|
}
|
|
733
787
|
}
|
|
734
|
-
checks.worker =
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
788
|
+
checks.worker =
|
|
789
|
+
profile.provider === "none"
|
|
790
|
+
? {
|
|
791
|
+
state: "not_configured",
|
|
792
|
+
message: "Connection-only profile. Automatic receiving is not configured.",
|
|
793
|
+
}
|
|
794
|
+
: {
|
|
795
|
+
state: "not_checked",
|
|
796
|
+
message: "Run totem start for this profile to receive approved work.",
|
|
797
|
+
};
|
|
798
|
+
if (profile.provider !== "none" && io.readRuntimeStatus) {
|
|
739
799
|
try {
|
|
740
800
|
const worker = await io.readRuntimeStatus(profile);
|
|
741
801
|
const phases = [
|
|
@@ -787,19 +847,22 @@ export async function agentStatusMain(args, options = {}) {
|
|
|
787
847
|
: `Check ${profile.provider === "codex" ? "codex login status and the installed Codex CLI version" : profile.provider === "claude" ? "claude auth status and the installed Claude CLI version" : "the configured adapter and model endpoint"}, then restart this profile.`,
|
|
788
848
|
}
|
|
789
849
|
: {}),
|
|
850
|
+
...(worker.causeCode ? startupDiagnostic(worker) : {}),
|
|
790
851
|
...(phase ? { phase } : {}),
|
|
791
852
|
message:
|
|
792
|
-
|
|
793
|
-
? "
|
|
794
|
-
:
|
|
795
|
-
? "The
|
|
796
|
-
: phase === "
|
|
797
|
-
? "The worker is
|
|
798
|
-
: phase === "
|
|
799
|
-
? "The
|
|
800
|
-
: phase === "
|
|
801
|
-
? "The worker
|
|
802
|
-
:
|
|
853
|
+
worker.causeCode === "HOST_APPROVAL_DENIED"
|
|
854
|
+
? "Automatic startup is paused because the host denied approval. Owner action is required."
|
|
855
|
+
: state === "running"
|
|
856
|
+
? "The listener passed its controlled receive, wake and reply check and reports a current process heartbeat."
|
|
857
|
+
: phase === "reconnecting"
|
|
858
|
+
? "The worker is reconnecting to Totem. Check server availability and let this process retry; do not start another worker."
|
|
859
|
+
: phase === "stopping"
|
|
860
|
+
? "The worker is stopping and preserving progress. Wait for it to stop before changing setup or restarting."
|
|
861
|
+
: phase === "blocked"
|
|
862
|
+
? "The running worker has an execution blocker. Check its error code; starting another worker will not resolve an uncertain run."
|
|
863
|
+
: phase === "unresponsive"
|
|
864
|
+
? "The worker process is present but its status is stale. Inspect or stop it before starting another worker."
|
|
865
|
+
: "The local worker is not confirmed ready. Check it with totem start for this profile.",
|
|
803
866
|
};
|
|
804
867
|
} catch {
|
|
805
868
|
checks.worker = {
|
|
@@ -808,6 +871,26 @@ export async function agentStatusMain(args, options = {}) {
|
|
|
808
871
|
};
|
|
809
872
|
}
|
|
810
873
|
}
|
|
874
|
+
if (profile.provider !== "none" && checks.worker.state !== "running") {
|
|
875
|
+
const health = await privateRead(join(profile.stateDirectory, "automatic-health.json")).catch(
|
|
876
|
+
(error) => {
|
|
877
|
+
if (error.code !== "ENOENT") throw error;
|
|
878
|
+
},
|
|
879
|
+
);
|
|
880
|
+
if (health?.code === "LISTENER_START_FAILED")
|
|
881
|
+
checks.worker = {
|
|
882
|
+
...checks.worker,
|
|
883
|
+
state: "needs_attention",
|
|
884
|
+
errorCode: health.code,
|
|
885
|
+
...startupDiagnostic(health),
|
|
886
|
+
...(health.retryable === false
|
|
887
|
+
? {
|
|
888
|
+
message:
|
|
889
|
+
"Automatic startup is paused because the host denied approval. Owner action is required.",
|
|
890
|
+
}
|
|
891
|
+
: {}),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
811
894
|
const ok = checks.credential.state === "verified";
|
|
812
895
|
const executionReady =
|
|
813
896
|
ok && checks.model.state === "available" && checks.worker.state === "running";
|
|
@@ -828,7 +911,10 @@ export async function agentStatusMain(args, options = {}) {
|
|
|
828
911
|
const output = flags.json
|
|
829
912
|
? report
|
|
830
913
|
: `${profile.address} at ${profile.baseUrl}\n${Object.entries(checks)
|
|
831
|
-
.map(
|
|
914
|
+
.map(
|
|
915
|
+
([name, check]) =>
|
|
916
|
+
`${name}: ${check.state}. ${check.message}${check.cause ? ` Cause: ${check.cause}` : ""}${check.recovery ? ` Recovery: ${check.recovery}` : ""}`,
|
|
917
|
+
)
|
|
832
918
|
.join("\n")}\n`;
|
|
833
919
|
io.stdout.write(safeOutput(output, key) + (flags.json ? "\n" : ""));
|
|
834
920
|
return ok ? 0 : 1;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
2
|
+
import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
|
|
1
3
|
import { opendir, rm, readFile, lstat, rename, readdir, unlink, rmdir } from "node:fs/promises";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -23,8 +25,10 @@ const PACKAGE_VERSION = JSON.parse(
|
|
|
23
25
|
|
|
24
26
|
export async function setAutomaticEnabled(profile, enabled) {
|
|
25
27
|
await privateWrite(join(profile.stateDirectory, "automatic.json"), { enabled });
|
|
28
|
+
if (enabled) await rm(join(profile.stateDirectory, "automatic-health.json"), { force: true });
|
|
26
29
|
}
|
|
27
30
|
export async function automaticEnabled(profile) {
|
|
31
|
+
if (profile.provider === "none") return false;
|
|
28
32
|
try {
|
|
29
33
|
return (await privateRead(join(profile.stateDirectory, "automatic.json"))).enabled === true;
|
|
30
34
|
} catch (error) {
|
|
@@ -50,11 +54,11 @@ async function reportActivations(client, ids, state, errorCode) {
|
|
|
50
54
|
export async function superviseOnce(env = process.env, options = {}) {
|
|
51
55
|
const directory = join(runtimeRoot(env), "runtime");
|
|
52
56
|
await Promise.resolve((options.resumeLogin ?? resumeDeviceLogin)({ directory }, env)).catch(
|
|
53
|
-
async () => {
|
|
57
|
+
async (error) => {
|
|
54
58
|
await privateWrite(join(directory, "setup-health.json"), {
|
|
55
59
|
state: "error",
|
|
56
60
|
code: "SETUP_NEEDS_ATTENTION",
|
|
57
|
-
|
|
61
|
+
...startupDiagnostic(error),
|
|
58
62
|
updatedAt: new Date().toISOString(),
|
|
59
63
|
});
|
|
60
64
|
},
|
|
@@ -75,6 +79,7 @@ export async function superviseOnce(env = process.env, options = {}) {
|
|
|
75
79
|
activationClient;
|
|
76
80
|
try {
|
|
77
81
|
profile = await readAgentProfile(entry.name, { environment: env });
|
|
82
|
+
if (profile.provider === "none") continue;
|
|
78
83
|
const enabled = await automaticEnabled(profile);
|
|
79
84
|
let activations = [];
|
|
80
85
|
try {
|
|
@@ -121,6 +126,15 @@ export async function superviseOnce(env = process.env, options = {}) {
|
|
|
121
126
|
attempts = Number.isSafeInteger(health?.attempts)
|
|
122
127
|
? Math.max(0, Math.min(health.attempts, 10))
|
|
123
128
|
: 0;
|
|
129
|
+
if (health?.retryable === false) {
|
|
130
|
+
await reportActivations(
|
|
131
|
+
activationClient,
|
|
132
|
+
activationIds,
|
|
133
|
+
"blocked",
|
|
134
|
+
"LISTENER_START_FAILED",
|
|
135
|
+
);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
124
138
|
const newAcceptance = activationIds.some((id) => !health?.activationIds?.includes(id));
|
|
125
139
|
if (!newAcceptance && Number.isFinite(health?.retryAt) && health.retryAt > Date.now())
|
|
126
140
|
continue;
|
|
@@ -133,7 +147,10 @@ export async function superviseOnce(env = process.env, options = {}) {
|
|
|
133
147
|
stdout: { write() {} },
|
|
134
148
|
},
|
|
135
149
|
);
|
|
136
|
-
if (result)
|
|
150
|
+
if (result) {
|
|
151
|
+
const failure = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
|
|
152
|
+
throw Object.assign(new Error("START_FAILED"), failure);
|
|
153
|
+
}
|
|
137
154
|
const started = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
|
|
138
155
|
await reportActivations(
|
|
139
156
|
activationClient,
|
|
@@ -149,6 +166,7 @@ export async function superviseOnce(env = process.env, options = {}) {
|
|
|
149
166
|
});
|
|
150
167
|
results.push(entry.name);
|
|
151
168
|
} catch (error) {
|
|
169
|
+
const diagnostic = startupDiagnostic(error);
|
|
152
170
|
await reportActivations(
|
|
153
171
|
activationClient,
|
|
154
172
|
activationIds,
|
|
@@ -164,10 +182,11 @@ export async function superviseOnce(env = process.env, options = {}) {
|
|
|
164
182
|
state: "error",
|
|
165
183
|
activationIds,
|
|
166
184
|
code: "LISTENER_START_FAILED",
|
|
185
|
+
...diagnostic,
|
|
167
186
|
attempts: attempts + 1,
|
|
168
|
-
retryAt:
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
retryAt: diagnostic.retryable
|
|
188
|
+
? Date.now() + Math.min(300000, 5000 * 2 ** attempts)
|
|
189
|
+
: null,
|
|
171
190
|
updatedAt: new Date().toISOString(),
|
|
172
191
|
}).catch(() => {});
|
|
173
192
|
/* One offline/revoked connection cannot stop other profiles. */
|
|
@@ -317,7 +336,7 @@ export async function automaticRuntimeMain(env = process.env, options = {}) {
|
|
|
317
336
|
updatedAt: new Date().toISOString(),
|
|
318
337
|
});
|
|
319
338
|
await superviseOnce(environment, options);
|
|
320
|
-
await sleep(options.intervalMs ??
|
|
339
|
+
await sleep(options.intervalMs ?? DEFAULT_LISTENER_POLL_MS, undefined, {
|
|
321
340
|
signal: options.signal
|
|
322
341
|
? AbortSignal.any([controller.signal, options.signal])
|
|
323
342
|
: controller.signal,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
privateDirectory,
|
|
5
|
+
privateRead,
|
|
6
|
+
privateWrite,
|
|
7
|
+
runtimeRoot,
|
|
8
|
+
} from "./runtime-installation.mjs";
|
|
9
|
+
|
|
10
|
+
const MINUTE = 60000;
|
|
11
|
+
const HOUR = 60 * MINUTE;
|
|
12
|
+
// Shared by inbox deliveries and readiness checks, across runs and credentials.
|
|
13
|
+
export async function reserveChatPrompt({ configDirectory, threadId, kind, now = Date.now() }) {
|
|
14
|
+
if (!/^[a-f0-9-]{36}$/.test(threadId ?? "") || !["message", "readiness"].includes(kind))
|
|
15
|
+
throw new Error("Invalid automatic chat prompt binding.");
|
|
16
|
+
const root = runtimeRoot(configDirectory ? { TOTEM_CONFIG_DIR: configDirectory } : process.env);
|
|
17
|
+
await privateDirectory(root);
|
|
18
|
+
await privateDirectory(join(root, "runtime"));
|
|
19
|
+
const directory = join(root, "runtime", "chat-prompt-limits");
|
|
20
|
+
await privateDirectory(directory);
|
|
21
|
+
const key = createHash("sha256").update(threadId).digest("hex");
|
|
22
|
+
const { acquireSupervisorLock } = await import("./automatic-runtime.mjs");
|
|
23
|
+
let release;
|
|
24
|
+
try {
|
|
25
|
+
release = await acquireSupervisorLock(join(directory, key));
|
|
26
|
+
} catch {
|
|
27
|
+
throw Object.assign(new Error("An automatic chat prompt is already being reserved."), {
|
|
28
|
+
code: "CHAT_PROMPT_RATE_LIMITED",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const file = join(directory, `${key}.json`);
|
|
33
|
+
const saved = await privateRead(file).catch((error) => {
|
|
34
|
+
if (error.code !== "ENOENT") throw error;
|
|
35
|
+
return { attempts: [] };
|
|
36
|
+
});
|
|
37
|
+
if (
|
|
38
|
+
!Array.isArray(saved.attempts) ||
|
|
39
|
+
saved.attempts.length > 60 ||
|
|
40
|
+
saved.attempts.some(
|
|
41
|
+
(item) => !Number.isFinite(item.at) || !["message", "readiness"].includes(item.kind),
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
throw new Error("Automatic chat prompt history needs inspection.");
|
|
45
|
+
const attempts = saved.attempts.filter((item) => now - item.at < HOUR);
|
|
46
|
+
const readiness = attempts.filter((item) => item.kind === "readiness");
|
|
47
|
+
const last = Math.max(-Infinity, ...attempts.map((item) => item.at));
|
|
48
|
+
const lastReadiness = Math.max(-Infinity, ...readiness.map((item) => item.at));
|
|
49
|
+
const retryAt = Math.max(
|
|
50
|
+
last + 10000,
|
|
51
|
+
attempts.length >= 60 ? Math.min(...attempts.map((item) => item.at)) + HOUR : 0,
|
|
52
|
+
kind === "readiness" ? lastReadiness + 5 * MINUTE : 0,
|
|
53
|
+
kind === "readiness" && readiness.length >= 6
|
|
54
|
+
? Math.min(...readiness.map((item) => item.at)) + HOUR
|
|
55
|
+
: 0,
|
|
56
|
+
);
|
|
57
|
+
if (now < retryAt)
|
|
58
|
+
throw Object.assign(
|
|
59
|
+
new Error("Automatic prompts are cooling down. Unsent work stays in the durable inbox."),
|
|
60
|
+
{ code: "CHAT_PROMPT_RATE_LIMITED", retryAt },
|
|
61
|
+
);
|
|
62
|
+
// Reserve before wake/queue. A failed or uncertain attempt still consumes its budget.
|
|
63
|
+
await privateWrite(file, { attempts: [...attempts, { at: now, kind }] });
|
|
64
|
+
} finally {
|
|
65
|
+
await release();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -8,6 +8,7 @@ import { installedClientInvocation, localClientEnvironment } from "./platform-cl
|
|
|
8
8
|
import { Totem } from "./sdk/index.js";
|
|
9
9
|
import { privateRead } from "./runtime-installation.mjs";
|
|
10
10
|
import { wakeCodexDesktop } from "./codex-desktop-wake.mjs";
|
|
11
|
+
import { reserveChatPrompt } from "./chat-prompt-limit.mjs";
|
|
11
12
|
|
|
12
13
|
const execute = promisify(execFile);
|
|
13
14
|
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
|
|
@@ -50,7 +51,7 @@ export async function defaultCodexQueue({ threadId, prompt, signal, environment
|
|
|
50
51
|
await pending;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
async function activeTaskForReply(client, message, identity, signal) {
|
|
54
|
+
export async function activeTaskForReply(client, message, identity, signal) {
|
|
54
55
|
// Connection credentials may receive bounded replies, but cannot read task or
|
|
55
56
|
// conversation APIs. Preserve their acknowledgement-only notification path.
|
|
56
57
|
if (identity.listenerConnectionId) return null;
|
|
@@ -241,6 +242,12 @@ export async function queueCodexChatMessage({
|
|
|
241
242
|
);
|
|
242
243
|
if (payloadBytes(prompt) > 7000) prompt = deliveryPrompt(settings, message, true, activeTask);
|
|
243
244
|
if (payloadBytes(prompt) > 7000) throw error("INSPECTION_LIMIT");
|
|
245
|
+
await reserveChatPrompt({
|
|
246
|
+
configDirectory: settings.profileConfigDirectory,
|
|
247
|
+
threadId: settings.codexThreadId,
|
|
248
|
+
kind: "message",
|
|
249
|
+
now: now(),
|
|
250
|
+
});
|
|
244
251
|
await loadChat();
|
|
245
252
|
await journal.save(key, { state: "pending", threadId: settings.codexThreadId });
|
|
246
253
|
try {
|
|
@@ -257,18 +264,13 @@ export async function queueCodexChatMessage({
|
|
|
257
264
|
return { state: "queued" };
|
|
258
265
|
}
|
|
259
266
|
|
|
260
|
-
/**
|
|
261
|
-
export async function
|
|
267
|
+
/** Callbacks inherit exactly the saved owner-selected chat and authenticated identity. */
|
|
268
|
+
export async function loadCodexChatSession({
|
|
262
269
|
profileName,
|
|
263
|
-
messageId,
|
|
264
|
-
text,
|
|
265
|
-
ackOnly = false,
|
|
266
|
-
readOnly = false,
|
|
267
270
|
environment = process.env,
|
|
268
271
|
createClient = (input) => new Totem(input),
|
|
269
272
|
inspector,
|
|
270
273
|
}) {
|
|
271
|
-
if (!UUID.test(messageId ?? "")) throw error("AGENT_IDENTITY_MISMATCH");
|
|
272
274
|
const profile = await readAgentProfile(profileName, { environment });
|
|
273
275
|
if (!inspector) {
|
|
274
276
|
const root = dirname(dirname(dirname(dirname(profile.stateDirectory))));
|
|
@@ -313,6 +315,32 @@ export async function completeCodexChatMessage({
|
|
|
313
315
|
identity.environment !== profile.environment
|
|
314
316
|
)
|
|
315
317
|
throw error("AGENT_IDENTITY_MISMATCH");
|
|
318
|
+
return { profile, client, identity, inspector };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The linked chat's reply client uses the saved identity and the same output guard. */
|
|
322
|
+
export async function completeCodexChatMessage({
|
|
323
|
+
profileName,
|
|
324
|
+
messageId,
|
|
325
|
+
text,
|
|
326
|
+
ackOnly = false,
|
|
327
|
+
readOnly = false,
|
|
328
|
+
environment = process.env,
|
|
329
|
+
createClient = (input) => new Totem(input),
|
|
330
|
+
inspector,
|
|
331
|
+
}) {
|
|
332
|
+
if (!UUID.test(messageId ?? "")) throw error("AGENT_IDENTITY_MISMATCH");
|
|
333
|
+
const {
|
|
334
|
+
client,
|
|
335
|
+
identity,
|
|
336
|
+
inspector: policy,
|
|
337
|
+
} = await loadCodexChatSession({
|
|
338
|
+
profileName,
|
|
339
|
+
environment,
|
|
340
|
+
createClient,
|
|
341
|
+
inspector,
|
|
342
|
+
});
|
|
343
|
+
inspector = policy;
|
|
316
344
|
const message = await client.messages.get(messageId);
|
|
317
345
|
if (message.to !== identity.address) throw error("AGENT_IDENTITY_MISMATCH");
|
|
318
346
|
await inspectNetworkMessage(inspector, client, message);
|
|
@@ -8,6 +8,7 @@ import { defaultCodexQueue } from "./codex-chat-bridge.mjs";
|
|
|
8
8
|
import { wakeCodexDesktop } from "./codex-desktop-wake.mjs";
|
|
9
9
|
import { privateDirectory, privateRead } from "./runtime-installation.mjs";
|
|
10
10
|
import { Totem } from "./sdk/index.js";
|
|
11
|
+
import { reserveChatPrompt } from "./chat-prompt-limit.mjs";
|
|
11
12
|
|
|
12
13
|
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
|
|
13
14
|
const CHALLENGE = /^[a-f0-9]{64}$/;
|
|
@@ -225,6 +226,12 @@ export async function verifyCodexChatReadiness(
|
|
|
225
226
|
assertRecord(pending, profile, runId, now(), probe.challenge);
|
|
226
227
|
activeSignal.throwIfAborted();
|
|
227
228
|
if (fresh) {
|
|
229
|
+
await reserveChatPrompt({
|
|
230
|
+
configDirectory: profileRoot(profile),
|
|
231
|
+
threadId: profile.codexThreadId,
|
|
232
|
+
kind: "readiness",
|
|
233
|
+
now: now(),
|
|
234
|
+
});
|
|
228
235
|
// This reservation survives both known failures and unknown external outcomes.
|
|
229
236
|
await (options.wake ?? wakeCodexDesktop)({
|
|
230
237
|
threadId: profile.codexThreadId,
|