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.
Files changed (46) hide show
  1. package/README.md +48 -11
  2. package/bin/agent-memory.mjs +255 -0
  3. package/bin/agent-profile.mjs +42 -5
  4. package/bin/agent-runner.mjs +55 -12
  5. package/bin/agent-setup.mjs +136 -50
  6. package/bin/automatic-runtime.mjs +26 -7
  7. package/bin/chat-prompt-limit.mjs +67 -0
  8. package/bin/codex-chat-bridge.mjs +36 -8
  9. package/bin/codex-chat-readiness.mjs +7 -0
  10. package/bin/codex-human-chat-bridge.mjs +209 -0
  11. package/bin/collaboration-listener.mjs +2 -1
  12. package/bin/connected-agent-service.mjs +4 -1
  13. package/bin/inbox-queue.mjs +2 -1
  14. package/bin/inference-broker.mjs +361 -0
  15. package/bin/installation.mjs +92 -60
  16. package/bin/isolated-agent-entry.mjs +35 -0
  17. package/bin/local-mcp.mjs +43 -4
  18. package/bin/mcp-config.d.mts +11 -0
  19. package/bin/mcp-config.mjs +56 -0
  20. package/bin/mcp-install.mjs +143 -12
  21. package/bin/mcp-listener.d.mts +22 -0
  22. package/bin/mcp-listener.mjs +4 -0
  23. package/bin/mcp-runtime.mjs +3 -1
  24. package/bin/mcp.mjs +331 -94
  25. package/bin/mission-agent-service.mjs +2 -1
  26. package/bin/mission-worker.mjs +13 -2
  27. package/bin/model-adapter.mjs +245 -107
  28. package/bin/network-contract.mjs +254 -0
  29. package/bin/network-runtime.mjs +162 -47
  30. package/bin/network.mjs +32 -7
  31. package/bin/platform-client.mjs +25 -3
  32. package/bin/process-isolation.mjs +214 -0
  33. package/bin/provider-detection.mjs +1 -4
  34. package/bin/runtime-diagnostics.mjs +1 -0
  35. package/bin/sdk/index.d.ts +50 -6
  36. package/bin/sdk/index.js +1 -1
  37. package/bin/sdk/inspector.js +1 -1
  38. package/bin/sdk/listener.d.ts +57 -0
  39. package/bin/sdk/listener.js +1 -0
  40. package/bin/sdk/types.d.ts +62 -0
  41. package/bin/startup-diagnostics.mjs +168 -0
  42. package/bin/totem.mjs +17 -3
  43. package/bin/worker-core/adapters.mjs +30 -13
  44. package/bin/worker-core/errors.mjs +4 -0
  45. package/package.json +1 -1
  46. package/scripts/prepare.mjs +28 -8
@@ -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
- --repo-root approves a checkout for this profile; omit it for connection-only setup.
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.provider ?? previous?.client ?? previous?.provider;
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
- "Install and sign in to an AI client, or choose an installed local model with --provider. Totem will connect and start it automatically.",
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
- !["qwen-local", "ollama-local", "nim-local", "codex", "claude", "command", "http"].includes(
354
- mode,
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 adapterSettings = Object.fromEntries(
363
- [
364
- ["command", flags.command ?? previous?.command],
365
- ["commandArgs", flags["command-arg"] ?? previous?.commandArgs],
366
- ["agentUrl", flags["agent-url"] ?? previous?.agentUrl],
367
- ["agentTokenEnv", flags["agent-token-env"] ?? previous?.agentTokenEnv],
368
- ["ollamaUrl", flags["ollama-url"] ?? previous?.ollamaUrl],
369
- ["nimUrl", flags["nim-url"] ?? previous?.nimUrl],
370
- ].filter(([, value]) => value !== undefined),
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 ? { interactiveRequired: true } : {}),
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 = `Run totem status --profile ${profile.profileName} to check your connection and automatic listener.`;
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
- automatic?.state ??
517
- (io.deferActivation && !flags["no-start"] ? "activation_pending" : "disabled_by_request"),
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
- : "Connection-only profile. Add an explicit --repo-root with setup before coding Missions.",
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
- state: "not_checked",
736
- message: "Run totem start for this profile to receive approved work.",
737
- };
738
- if (io.readRuntimeStatus) {
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
- state === "running"
793
- ? "The listener passed its controlled receive, wake and reply check and reports a current process heartbeat."
794
- : phase === "reconnecting"
795
- ? "The worker is reconnecting to Totem. Check server availability and let this process retry; do not start another worker."
796
- : phase === "stopping"
797
- ? "The worker is stopping and preserving progress. Wait for it to stop before changing setup or restarting."
798
- : phase === "blocked"
799
- ? "The running worker has an execution blocker. Check its error code; starting another worker will not resolve an uncertain run."
800
- : phase === "unresponsive"
801
- ? "The worker process is present but its status is stale. Inspect or stop it before starting another worker."
802
- : "The local worker is not confirmed ready. Check it with totem start for this profile.",
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(([name, check]) => `${name}: ${check.state}. ${check.message}`)
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
- recovery: "Run totem login to inspect authorization and adapter setup.",
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) throw new Error("START_FAILED");
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: Date.now() + Math.min(300000, 5000 * 2 ** attempts),
169
- recovery:
170
- "Inspect totem status. Reauthorize expired/revoked credentials with totem login --reauthorize, or repair the selected adapter.",
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 ?? 5000, undefined, {
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
- /** The linked chat's reply client uses the saved identity and the same output guard. */
261
- export async function completeCodexChatMessage({
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,