totemlayer 0.1.15 → 0.1.16

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.
@@ -9,6 +9,7 @@ import { parseArgs } from "node:util";
9
9
  import { readAgentCredential, readAgentProfile, withAgentProfileLock } from "./agent-profile.mjs";
10
10
  import { localClientEnvironment } from "./platform-client.mjs";
11
11
  import { Totem } from "./sdk/index.js";
12
+ import { RUNTIME_ERROR_CODES, runtimeErrorCode } from "./runtime-diagnostics.mjs";
12
13
 
13
14
  const CLI_PATH = fileURLToPath(new URL("./totem.mjs", import.meta.url));
14
15
  const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
@@ -29,7 +30,7 @@ const LABELS = {
29
30
  reconnecting: "Connection interrupted. Waiting to reconnect from saved progress.",
30
31
  working: "Working on an assigned Mission task.",
31
32
  waiting: "Connected and waiting for approved work.",
32
- blocked: "A Mission needs attention. Open Missions to review the blocker.",
33
+ blocked: "Agent execution needs attention. Check status for the blocker.",
33
34
  stopping: "Stopping the agent and saving its progress…",
34
35
  stopped: "Agent stopped. Saved progress will be checked when it starts again.",
35
36
  failed:
@@ -230,6 +231,7 @@ export async function readAgentRuntimeStatus(profile, { isAlive = alive, now = D
230
231
  : "interrupted",
231
232
  runId: record.runId,
232
233
  updatedAt: record.updatedAt,
234
+ ...(RUNTIME_ERROR_CODES.has(record.blockerCode) ? { blockerCode: record.blockerCode } : {}),
233
235
  ...(UUID.test(record.missionId ?? "") ? { missionId: record.missionId } : {}),
234
236
  ...(UUID.test(record.workItemId ?? "") ? { workItemId: record.workItemId } : {}),
235
237
  };
@@ -254,6 +256,7 @@ function eventState(event) {
254
256
  if (!["connected", "working", "waiting", "blocked"].includes(phase)) return null;
255
257
  return {
256
258
  phase,
259
+ blockerCode: phase === "blocked" ? runtimeErrorCode({ code: event.code }) : undefined,
257
260
  ...(UUID.test(event.missionId ?? "") ? { missionId: event.missionId } : {}),
258
261
  ...(UUID.test(event.workItemId ?? "") ? { workItemId: event.workItemId } : {}),
259
262
  };
@@ -311,6 +314,7 @@ export async function runAgentSession(profile, options = {}) {
311
314
  phase: status.phase,
312
315
  message: LABELS[status.phase],
313
316
  ...(status.missionId ? { missionId: status.missionId } : {}),
317
+ ...(status.blockerCode ? { blockerCode: status.blockerCode } : {}),
314
318
  });
315
319
  } catch {
316
320
  persistenceFailed = true;
@@ -12,10 +12,12 @@ import { installedClientInvocation, localClientEnvironment } from "./platform-cl
12
12
  import { inspectLocalRepository } from "./repository.mjs";
13
13
  import { Totem } from "./sdk/index.js";
14
14
  import { detectInstalledProvider } from "./provider-detection.mjs";
15
+ import { RUNTIME_ERROR_CODES } from "./runtime-diagnostics.mjs";
15
16
 
16
17
  const execute = promisify(execFile);
17
18
  const ORIGIN = "https://totemlayer.com";
18
19
  const KEY_LIMIT = 8192;
20
+ const IDENTITY_TIMEOUT_MS = 15000;
19
21
 
20
22
  export const AGENT_SETUP_HELP = `Connect your agent once
21
23
 
@@ -195,7 +197,8 @@ function dependencies(options) {
195
197
  stdin: process.stdin,
196
198
  stdout: process.stdout,
197
199
  stderr: process.stderr,
198
- createClient: (config) => new Totem({ ...config, timeoutMs: 5000, maxRetries: 0 }),
200
+ createClient: (config) =>
201
+ new Totem({ ...config, timeoutMs: IDENTITY_TIMEOUT_MS, maxRetries: 0 }),
199
202
  fetch: globalThis.fetch,
200
203
  inspectRepository: inspectLocalRepository,
201
204
  installMcp: installTotemMcp,
@@ -224,20 +227,43 @@ function safeOutput(value, secret) {
224
227
  }
225
228
 
226
229
  function identityError(error) {
227
- return error?.status === 401 || error?.status === 403
228
- ? "The connection key was rejected. For a saved profile, rerun setup with --replace to enter a new key, or use --replace --key-file with the replacement key."
229
- : "Totem identity could not be verified. Check the origin, connection key, and server availability.";
230
+ if (error?.status === 401 || error?.status === 403)
231
+ return [
232
+ "The connection key was rejected. For a saved profile, rerun setup with --replace to enter a new key, or use --replace --key-file with the replacement key.",
233
+ "CONNECTION_KEY_REJECTED",
234
+ ];
235
+ if (["TIMEOUT", "CANCELED"].includes(error?.code))
236
+ return [
237
+ "Totem identity verification timed out. Check connectivity and retry status; this does not mean the key was rejected.",
238
+ "IDENTITY_TIMEOUT",
239
+ ];
240
+ if (error?.code === "NETWORK_ERROR")
241
+ return [
242
+ "Totem identity verification could not reach the saved origin. Check connectivity, proxy and TLS settings.",
243
+ "IDENTITY_NETWORK_ERROR",
244
+ ];
245
+ if (error?.status >= 500)
246
+ return [
247
+ "Totem identity verification encountered a server error. Retry status; this does not mean the key was rejected.",
248
+ "IDENTITY_SERVER_ERROR",
249
+ ];
250
+ if (error?.code === "INVALID_RESPONSE")
251
+ return [
252
+ "The saved origin returned an invalid identity response. Check the origin and server availability.",
253
+ "IDENTITY_INVALID_RESPONSE",
254
+ ];
255
+ return [
256
+ "Totem identity could not be verified. Check the saved origin and server availability.",
257
+ "IDENTITY_VERIFICATION_FAILED",
258
+ ];
230
259
  }
231
260
 
232
261
  async function verifiedIdentity(client) {
233
262
  let identity;
234
263
  try {
235
- identity = await client.agent({ signal: AbortSignal.timeout(5000) });
264
+ identity = await client.agent({ signal: AbortSignal.timeout(IDENTITY_TIMEOUT_MS) });
236
265
  } catch (error) {
237
- fail(
238
- identityError(error),
239
- error?.status === 401 || error?.status === 403 ? "CONNECTION_KEY_REJECTED" : undefined,
240
- );
266
+ fail(...identityError(error));
241
267
  }
242
268
  if (
243
269
  !identity ||
@@ -248,7 +274,10 @@ async function verifiedIdentity(client) {
248
274
  !["SANDBOX", "PRODUCTION"].includes(identity.environment) ||
249
275
  (identity.status && !["ACTIVE", "OFFLINE"].includes(identity.status))
250
276
  )
251
- fail("Totem did not return an active agent identity. Check this agent in Totem.");
277
+ fail(
278
+ "Totem did not return an active agent identity. Check this agent in Totem.",
279
+ "IDENTITY_INVALID_RESPONSE",
280
+ );
252
281
  return identity;
253
282
  }
254
283
 
@@ -600,6 +629,7 @@ export async function agentStatusMain(args, options = {}) {
600
629
  )
601
630
  fail(
602
631
  "The saved key no longer matches this profile's agent. Run setup with a separate profile or explicitly replace it.",
632
+ "AGENT_IDENTITY_MISMATCH",
603
633
  );
604
634
  checks.credential = {
605
635
  state: "verified",
@@ -608,6 +638,9 @@ export async function agentStatusMain(args, options = {}) {
608
638
  } catch (error) {
609
639
  checks.credential = {
610
640
  state: "failed",
641
+ code: error?.setupSafe
642
+ ? (error.code ?? "IDENTITY_VERIFICATION_FAILED")
643
+ : "IDENTITY_VERIFICATION_FAILED",
611
644
  message: error?.setupSafe
612
645
  ? error.message
613
646
  : "The saved agent identity could not be verified.",
@@ -688,6 +721,7 @@ export async function agentStatusMain(args, options = {}) {
688
721
  : "needs_attention";
689
722
  checks.worker = {
690
723
  state,
724
+ ...(RUNTIME_ERROR_CODES.has(worker?.blockerCode) ? { code: worker.blockerCode } : {}),
691
725
  ...(phase ? { phase } : {}),
692
726
  message:
693
727
  state === "running"
@@ -697,7 +731,7 @@ export async function agentStatusMain(args, options = {}) {
697
731
  : phase === "stopping"
698
732
  ? "The worker is stopping and preserving progress. Wait for it to stop before changing setup or restarting."
699
733
  : phase === "blocked"
700
- ? "The running worker has a Mission blocker. Open Missions to resolve it; starting another worker will not resolve the blocker."
734
+ ? "The running worker has an execution blocker. Check its error code; starting another worker will not resolve an uncertain run."
701
735
  : phase === "unresponsive"
702
736
  ? "The worker process is present but its status is stale. Inspect or stop it before starting another worker."
703
737
  : "The local worker is not confirmed ready. Check it with totem start for this profile.",
@@ -11,13 +11,17 @@ export async function runConnectedAgentService(settings, dependencies = {}) {
11
11
  : controller.signal;
12
12
  const emit = settings.emit ?? (() => {});
13
13
  const phases = { messages: "starting", missions: "starting" };
14
+ const blockers = {};
14
15
  const report = (queue, event) => {
15
16
  phases[queue] = event.type === "completed" ? "waiting" : event.type;
17
+ if (event.type === "blocked") blockers[queue] = { code: event.code, messageId: event.messageId };
18
+ else delete blockers[queue];
16
19
  const values = Object.values(phases);
17
20
  const type =
18
21
  ["reconnecting", "starting", "blocked", "working"].find((phase) => values.includes(phase)) ??
19
22
  "waiting";
20
- emit({ ...event, type });
23
+ const blocked = type === "blocked" ? blockers.messages ?? blockers.missions : undefined;
24
+ emit({ ...event, type, ...(blocked ?? {}) });
21
25
  };
22
26
  const messages = async () => {
23
27
  let attempts = 0;
@@ -36,7 +40,9 @@ export async function runConnectedAgentService(settings, dependencies = {}) {
36
40
  attempts = 0;
37
41
  report("messages", { type: "waiting" });
38
42
  } else if (event.type === "runtime_error")
39
- report("messages", { type: "blocked", code: event.error?.code });
43
+ report("messages", { type: "blocked", code: event.error?.code, messageId: event.messageId });
44
+ else if (event.type === "message_started")
45
+ report("messages", { type: "working", messageId: event.data?.messageId });
40
46
  else if (["message_handled", "chat_completed"].includes(event.type))
41
47
  report("messages", { type: "waiting" });
42
48
  },
@@ -0,0 +1,37 @@
1
+ import { join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { modelReply } from "./network-runtime.mjs";
4
+ import { privateRead, privateWrite } from "./runtime-installation.mjs";
5
+ import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
6
+
7
+ export async function verifyExecutorReady(profile, env = process.env, options = {}) {
8
+ const version = options.version ?? JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
9
+ const file = join(profile.stateDirectory, "executor-readiness.json");
10
+ const binding = JSON.stringify([profile.agentId, profile.provider, profile.client, profile.model, profile.command, profile.commandArgs, profile.agentUrl, profile.qwenUrl, profile.ollamaUrl, profile.nimUrl]);
11
+ const previous = await privateRead(file).catch(error => {
12
+ if (error.code !== "ENOENT") throw error;
13
+ return null;
14
+ });
15
+ if (previous?.version === version && previous.binding === binding && previous.ready === true) return previous;
16
+ let response;
17
+ try { response = await (options.generate ?? modelReply)({
18
+ provider: profile.client ?? profile.provider,
19
+ model: profile.client && profile.provider === "qwen-local" ? "default" : profile.model,
20
+ origin: profile.ollamaUrl ?? "http://127.0.0.1:11434",
21
+ qwenUrl: profile.qwenUrl,
22
+ nimUrl: profile.nimUrl,
23
+ adapterSettings: { ...profile, environment: env },
24
+ messages: [{ role: "user", content: "This is a connection readiness check. Reply READY. No tools, file reads or external actions are authorized." }],
25
+ tools: [],
26
+ signal: options.signal ?? new AbortController().signal,
27
+ timeoutMs: 30000,
28
+ }); } catch (error) {
29
+ await privateWrite(file, { version, binding, ready: false, code: runtimeErrorCode(error), checkedAt: new Date().toISOString() });
30
+ throw error;
31
+ }
32
+ if (typeof response?.content !== "string" || !response.content.trim())
33
+ throw Object.assign(new Error("The selected executor did not complete its readiness check."), { code: "INVALID_AGENT_RESULT" });
34
+ const record = { version, binding, ready: true, checkedAt: new Date().toISOString() };
35
+ await privateWrite(file, record);
36
+ return record;
37
+ }
@@ -17,11 +17,25 @@ export async function activateConnection(profile, env = process.env, options = {
17
17
  ...(profile.agentTokenEnv ? { TOTEM_AGENT_TOKEN_ENV: profile.agentTokenEnv } : {}),
18
18
  });
19
19
  if (!enabled) return { state: "paused", ...service, entry: runtime.cli };
20
+ const { verifyExecutorReady } = await import("./executor-readiness.mjs");
21
+ try {
22
+ await (options.verifyExecutor ?? verifyExecutorReady)(profile, env);
23
+ } catch (error) {
24
+ return { state: "needs_attention", ...service, entry: runtime.cli,
25
+ code: error.code ?? "EXECUTOR_NOT_READY",
26
+ message: "Your connection is saved, but the selected agent could not complete its startup check. Run totem status to inspect the blocker." };
27
+ }
20
28
  const { agentStartMain } = await import("./agent-runner.mjs");
21
- await (options.start ?? agentStartMain)(
29
+ const started = await (options.start ?? agentStartMain)(
22
30
  ["--profile", profile.profileName, "--background", "--json"],
23
31
  { env, automatic: true, stdout: { write() {} } },
24
32
  );
33
+ if (started !== 0)
34
+ return { state: "needs_attention", ...service, entry: runtime.cli, code: "WORKER_START_FAILED" };
35
+ const { readAgentRuntimeStatus } = await import("./agent-runner.mjs");
36
+ const state = await (options.readStatus ?? readAgentRuntimeStatus)(profile);
37
+ if (!state.running || !["connected", "working", "waiting"].includes(state.phase))
38
+ return { state: "needs_attention", ...service, entry: runtime.cli, code: state.blockerCode ?? "WORKER_NOT_READY", phase: state.phase };
25
39
  return { state: "running", ...service, entry: runtime.cli };
26
40
  }
27
41
 
@@ -108,25 +122,36 @@ export async function installationMain(args = [], options = {}) {
108
122
  // An upgrade reuses private profiles and starts their existing connections.
109
123
  const { opendir } = await import("node:fs/promises");
110
124
  let saved = false;
125
+ const savedProfiles = [];
111
126
  try {
112
127
  const profiles = await opendir(join(runtime.root, "connections"));
113
128
  for await (const entry of profiles)
114
129
  if (
115
130
  entry.isDirectory() &&
116
131
  (!environment.TOTEM_AGENT_ID || entry.name === environment.TOTEM_AGENT_ID)
117
- )
132
+ ) {
118
133
  saved = true;
134
+ savedProfiles.push(entry.name);
135
+ }
119
136
  } catch (error) {
120
137
  if (error.code !== "ENOENT") throw error;
121
138
  }
122
139
  if (saved && options.forceLogin !== true) {
123
- const report = { installed: true, connection: "saved", automatic: true, service };
140
+ const { readAgentProfile } = await import("./agent-profile.mjs");
141
+ const results = [];
142
+ for (const name of savedProfiles) {
143
+ const profile = await readAgentProfile(name, { environment });
144
+ results.push({ profile: name, ...(await (options.activateConnection ?? activateConnection)(profile, environment)) });
145
+ }
146
+ const ready = results.every(result => ["running", "paused"].includes(result.state));
147
+ const report = { installed: true, connection: "saved", automatic: true, ready, profiles: results, service };
124
148
  stdout.write(
125
149
  flags.json
126
150
  ? JSON.stringify(report) + "\n"
127
- : "Totem's automatic listener is installed and reuses your saved connections.\n",
151
+ : ready ? "Totem's automatic listener reuses your saved connections; startup checks passed.\n"
152
+ : "Totem is installed, but an agent failed its startup check. Run totem status to inspect it.\n",
128
153
  );
129
- return 0;
154
+ return ready ? 0 : 1;
130
155
  }
131
156
  const connection = await (options.beginLogin ?? beginDeviceLogin)(runtime, environment, {
132
157
  openBrowser: !flags["no-browser"] && environment.TOTEM_NO_BROWSER !== "1",
@@ -4,6 +4,16 @@ import { tmpdir } from "node:os";
4
4
  import { isAbsolute, join } from "node:path";
5
5
  import { createCommandAdapter, createHttpAdapter } from "./worker-core/adapters.mjs";
6
6
  import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
7
+ import { WorkerError } from "./worker-core/errors.mjs";
8
+
9
+ export function providerFailureCode(text) {
10
+ if (/unexpected argument|unknown option|unrecognized option|no such option|unknown flag/i.test(text)) return "AGENT_CLI_INCOMPATIBLE";
11
+ if (/not logged in|authentication required|invalid[_ ]api[_ ]key|unauthorized|refresh token.*(?:expired|reused)|sign in.*required/i.test(text)) return "AGENT_AUTH_REQUIRED";
12
+ if (/usage limit|rate limit|quota.*(?:exceeded|insufficient)|insufficient_quota|credit balance/i.test(text)) return "AGENT_QUOTA_EXCEEDED";
13
+ if (/sandbox.*(?:setup failed|not available|not configured|unsupported|error)|CreateProcessAsUser|error 1385/i.test(text)) return "AGENT_SANDBOX_UNAVAILABLE";
14
+ if (/model.*(?:not found|not supported|does not exist|not have access)|model_not_found/i.test(text)) return "AGENT_MODEL_UNAVAILABLE";
15
+ return "AGENT_EXIT_FAILED";
16
+ }
7
17
 
8
18
  export const EXTERNAL_PROVIDERS = ["codex", "claude", "command", "http"];
9
19
  export const isExternalModelProvider = (provider) => EXTERNAL_PROVIDERS.includes(provider);
@@ -88,7 +98,7 @@ export async function externalModelReply({
88
98
  continue;
89
99
  }
90
100
  if (event.type === "turn.failed" || event.type === "error")
91
- throw new Error("Codex turn failed.");
101
+ throw new WorkerError(providerFailureCode(event.error?.message ?? event.message ?? ""));
92
102
  if (event.type === "item.completed" && event.item?.type === "agent_message")
93
103
  final = event.item.text;
94
104
  }
@@ -113,7 +123,7 @@ export async function externalModelReply({
113
123
  ];
114
124
  parseResult = (output) => {
115
125
  const result = JSON.parse(output);
116
- if (result.is_error || result.subtype !== "success") throw new Error("Claude turn failed.");
126
+ if (result.is_error || result.subtype !== "success") throw new WorkerError(providerFailureCode(String(result.result ?? "")));
117
127
  return {
118
128
  status: "completed",
119
129
  output: result.structured_output
@@ -153,6 +163,7 @@ export async function externalModelReply({
153
163
  env,
154
164
  timeoutMs,
155
165
  parseResult,
166
+ ...(["codex", "claude"].includes(provider) ? { classifyFailure: providerFailureCode } : {}),
156
167
  });
157
168
  }
158
169
  const result = await adapter.run({ prompt, signal, runId: randomUUID() });
@@ -6,6 +6,7 @@ import { pause } from "./network.mjs";
6
6
  import { localModelEndpoint } from "./local-model-provider.mjs";
7
7
  import { runInboxQueue } from "./inbox-queue.mjs";
8
8
  import { customerInspector, inspectNetworkMessage } from "./local-inspector.mjs";
9
+ import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
9
10
  import {
10
11
  externalModelReply,
11
12
  isExternalModelProvider,
@@ -572,7 +573,12 @@ export async function runRuntime(settings) {
572
573
  let inbox;
573
574
  let humanDone = false;
574
575
  let fatalError;
576
+ let diagnosticsSupported = false;
577
+ let diagnostics = { phase: "starting" };
578
+ const packageVersion = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
575
579
  const report = (error, context = {}) => {
580
+ diagnostics = { phase: "blocked", errorCode: runtimeErrorCode(error),
581
+ ...(typeof context.messageId === "string" ? { messageId: context.messageId } : {}) };
576
582
  if (settings.once) process.exitCode = 1;
577
583
  emit({
578
584
  type: "runtime_error",
@@ -591,11 +597,16 @@ export async function runRuntime(settings) {
591
597
  });
592
598
  };
593
599
  try {
594
- const beat = async () =>
595
- client.runtime.heartbeat(
596
- { label: `${settings.provider}:${settings.model}`.slice(0, 100) },
600
+ const beat = async () => {
601
+ const result = await client.runtime.heartbeat(
602
+ { label: `${settings.provider}:${settings.model}`.slice(0, 100),
603
+ ...(diagnosticsSupported ? { diagnostics: { ...diagnostics, packageVersion, provider: settings.provider,
604
+ platform: ["darwin", "linux", "win32"].includes(process.platform) ? process.platform : "other" } } : {}) },
597
605
  { signal },
598
606
  );
607
+ diagnosticsSupported = result?.diagnosticsSupported === true;
608
+ return result;
609
+ };
599
610
  await beat();
600
611
  heartbeat = (async () => {
601
612
  while (!signal.aborted) {
@@ -623,6 +634,7 @@ export async function runRuntime(settings) {
623
634
  inspection: settings.inspector.mode,
624
635
  },
625
636
  });
637
+ diagnostics = { phase: "waiting" };
626
638
  // The A2A queue must keep moving while a human command waits on another agent.
627
639
  // ask_agent reads durable conversation history, so acknowledgments cannot steal a reply.
628
640
  inbox = runInboxQueue({
@@ -636,10 +648,14 @@ export async function runRuntime(settings) {
636
648
  eligible: async (message) => !(await journal.read(`message-blocker:${message.id}`)),
637
649
  handle: async (message, signal) => {
638
650
  try {
651
+ diagnostics = { phase: "working", messageId: message.id };
652
+ emit({ type: "message_started", data: { messageId: message.id } });
639
653
  await inspectNetworkMessage(settings.inspector, client, message, { signal });
640
654
  const continuation = autonomousReplyMetadata(message, settings, identity.address);
641
655
  if (message.inReplyToId && !continuation) {
642
656
  await client.messages.ack(message.id, { signal });
657
+ diagnostics = { phase: "waiting" };
658
+ emit({ type: "message_handled", data: { messageId: message.id } });
643
659
  return;
644
660
  }
645
661
  const thread = [];
@@ -696,6 +712,7 @@ export async function runRuntime(settings) {
696
712
  { signal },
697
713
  );
698
714
  await client.messages.ack(message.id, { signal });
715
+ diagnostics = { phase: "waiting" };
699
716
  emit({
700
717
  type: "message_handled",
701
718
  data: { messageId: message.id, conversationId: message.conversationId },
@@ -1,4 +1,5 @@
1
- import { join } from "node:path";
1
+ import { join, dirname, isAbsolute, extname } from "node:path";
2
+ import { existsSync } from "node:fs";
2
3
 
3
4
  const INHERITED_ENVIRONMENT = new Set(
4
5
  [
@@ -71,9 +72,36 @@ export function powershellInvocation(script, environment = process.env) {
71
72
  // base64-encoded JSON argument array is interpolated into the script, so a
72
73
  // Windows path can never become shell syntax.
73
74
  export function installedClientInvocation(command, args, environment = process.env) {
75
+ // Native executables inherit the pipe directly. For npm clients, launch the
76
+ // installed JavaScript entrypoint with Node rather than a .cmd/PowerShell
77
+ // pipeline, which can leave stdin open indefinitely on Windows.
78
+ const clientPackage = {
79
+ codex: ["@openai", "codex", "bin", "codex.js"],
80
+ claude: ["@anthropic-ai", "claude-code", "cli.js"],
81
+ }[command];
82
+ const pathValue =
83
+ environment.PATH ??
84
+ environment.Path ??
85
+ Object.entries(environment).find(([key]) => key.toUpperCase() === "PATH")?.[1] ??
86
+ "";
87
+ const candidates = isAbsolute(command)
88
+ ? [command]
89
+ : pathValue
90
+ .split(";")
91
+ .filter(Boolean)
92
+ .flatMap((dir) => [join(dir, `${command}.exe`), join(dir, `${command}.cmd`)]);
93
+ for (const path of candidates) {
94
+ if (!existsSync(path)) continue;
95
+ if (extname(path).toLowerCase() === ".exe") return { command: path, args };
96
+ if (clientPackage) {
97
+ const entry = join(dirname(path), "node_modules", ...clientPackage);
98
+ if (existsSync(entry)) return { command: process.execPath, args: [entry, ...args] };
99
+ }
100
+ break;
101
+ }
74
102
  const payload = Buffer.from(JSON.stringify([command, ...args]), "utf8").toString("base64");
75
103
  return powershellInvocation(
76
- `$ErrorActionPreference = 'Stop'\n$items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json\n$client = [string]$items[0]\n$clientArgs = @($items | Select-Object -Skip 1)\n& $client @clientArgs\nif ($null -eq $LASTEXITCODE) { exit 1 }\nexit $LASTEXITCODE`,
104
+ `$ErrorActionPreference = 'Stop'\n$items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json\n$client = [string]$items[0]\n$clientArgs = @($items | Select-Object -Skip 1)\n$OutputEncoding = [Text.UTF8Encoding]::new($false)\n[Console]::OutputEncoding = $OutputEncoding\n$prompt = [Console]::In.ReadToEnd()\n$prompt | & $client @clientArgs\nif ($null -eq $LASTEXITCODE) { exit 1 }\nexit $LASTEXITCODE`,
77
105
  environment,
78
106
  );
79
107
  }
@@ -0,0 +1,13 @@
1
+ export const RUNTIME_ERROR_CODES = new Set([
2
+ "AGENT_START_FAILED", "AGENT_EXIT_FAILED", "AGENT_REPORTED_FAILURE", "INVALID_AGENT_RESULT",
3
+ "AGENT_CLI_INCOMPATIBLE", "AGENT_AUTH_REQUIRED", "AGENT_QUOTA_EXCEEDED", "AGENT_SANDBOX_UNAVAILABLE", "AGENT_MODEL_UNAVAILABLE",
4
+ "AGENT_OUTCOME_UNCERTAIN", "RUN_TIMEOUT", "RUN_CANCELLED", "OUTPUT_LIMIT_EXCEEDED",
5
+ "UNKNOWN_HTTP_OUTCOME", "HTTP_AGENT_FAILED", "INVALID_ADAPTER_CONFIG", "INVALID_ADAPTER_ENV",
6
+ "INSPECTION_BLOCKED", "INSPECTION_UNAVAILABLE", "INSPECTION_UNSUPPORTED_FILE",
7
+ "AGENT_REPLY_TIMEOUT", "INVALID_ASK", "TOOL_NOT_ALLOWED", "INVALID_PEER_REPLY",
8
+ "NETWORK_BUSY", "NETWORK_ERROR", "TIMEOUT", "CANCELED", "INVALID_NETWORK_CREDENTIAL",
9
+ "AGENT_IDENTITY_MISMATCH", "MODEL_ERROR", "MAX_STEPS", "INTERNAL_ERROR",
10
+ ]);
11
+ export function runtimeErrorCode(error) {
12
+ return RUNTIME_ERROR_CODES.has(error?.code) ? error.code : "INTERNAL_ERROR";
13
+ }
@@ -51,7 +51,8 @@ export function createCommandAdapter(config) {
51
51
  !Array.isArray(config.args ?? []) ||
52
52
  !(config.args ?? []).every((arg) => typeof arg === "string" && !arg.includes("\0")) ||
53
53
  typeof config.cwd !== "string" ||
54
- !isAbsolute(config.cwd)
54
+ !isAbsolute(config.cwd) ||
55
+ (config.classifyFailure !== undefined && typeof config.classifyFailure !== "function")
55
56
  ) {
56
57
  throw new WorkerError("INVALID_ADAPTER_CONFIG");
57
58
  }
@@ -76,13 +77,25 @@ export function createCommandAdapter(config) {
76
77
  let child;
77
78
  let failure;
78
79
  let output = "";
80
+ let diagnosticOutput = "";
79
81
  let bytes = 0;
80
82
  let killTimer;
81
83
  // Also remove surviving descendants if the parent exits before escalation.
82
84
  const kill = (kind) => {
83
85
  try {
84
86
  if (process.platform !== "win32" && child?.pid) process.kill(-child.pid, kind);
85
- else child?.kill(kind);
87
+ else if (child?.pid) {
88
+ // A Windows shim can outlive its parent and keep stdio open.
89
+ const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], {
90
+ windowsHide: true,
91
+ stdio: "ignore",
92
+ });
93
+ killer.on("error", () => {
94
+ try {
95
+ child.kill(kind);
96
+ } catch {}
97
+ });
98
+ }
86
99
  } catch {
87
100
  /* The process may already have exited. */
88
101
  }
@@ -122,6 +135,7 @@ export function createCommandAdapter(config) {
122
135
  child.stderr.on("data", (chunk) => {
123
136
  bytes += chunk.length;
124
137
  if (bytes > maxOutputBytes) stop("OUTPUT_LIMIT_EXCEEDED");
138
+ else if (config.classifyFailure) diagnosticOutput += chunk.toString("utf8");
125
139
  });
126
140
  child.stdin.on("error", () => {
127
141
  /* A failed child can close stdin early. */
@@ -137,7 +151,15 @@ export function createCommandAdapter(config) {
137
151
  kill("SIGKILL");
138
152
  return reject(failure);
139
153
  }
140
- if (code !== 0) return reject(new WorkerError("AGENT_EXIT_FAILED"));
154
+ if (code !== 0) {
155
+ try {
156
+ return reject(
157
+ new WorkerError(config.classifyFailure?.(diagnosticOutput) ?? "AGENT_EXIT_FAILED"),
158
+ );
159
+ } catch {
160
+ return reject(new WorkerError("AGENT_EXIT_FAILED"));
161
+ }
162
+ }
141
163
  try {
142
164
  resolve(validateResult(parseResult(output)));
143
165
  } catch (error) {
@@ -1,8 +1,4 @@
1
1
  const SAFE_CODES = new Set([
2
- "INSPECTION_BLOCKED",
3
- "INSPECTION_UNAVAILABLE",
4
- "INSPECTION_LIMIT",
5
- "INSPECTION_UNSUPPORTED_FILE",
6
2
  "INTERNAL_ERROR",
7
3
  "INVALID_AGENT_RESULT",
8
4
  "INVALID_ADAPTER_ENV",
@@ -30,6 +26,7 @@ const SAFE_CODES = new Set([
30
26
  "INVALID_PROMPT",
31
27
  "AGENT_START_FAILED",
32
28
  "AGENT_EXIT_FAILED",
29
+ "AGENT_CLI_INCOMPATIBLE", "AGENT_AUTH_REQUIRED", "AGENT_QUOTA_EXCEEDED", "AGENT_SANDBOX_UNAVAILABLE", "AGENT_MODEL_UNAVAILABLE",
33
30
  "AGENT_REPORTED_FAILURE",
34
31
  "UNKNOWN_HTTP_OUTCOME",
35
32
  "HTTP_AGENT_FAILED",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {