@spinalfluid/ultrac 0.1.6 → 0.1.9

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 (3) hide show
  1. package/README.md +61 -6
  2. package/dist/cli.js +675 -110
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -548,6 +548,9 @@ function ensurePrivateDirectory(path) {
548
548
  }
549
549
  chmodSync(path, 448);
550
550
  }
551
+ function currentDiagnosticLogPath() {
552
+ return diagnosticPath;
553
+ }
551
554
  function diagnosticEvent(event, fields = {}) {
552
555
  if (!diagnosticPath) return;
553
556
  const record3 = {
@@ -8040,7 +8043,9 @@ async function createSkill(workspace, input) {
8040
8043
  name,
8041
8044
  description: input.description.trim(),
8042
8045
  ...input.whenToUse ? { when_to_use: input.whenToUse.trim() } : {},
8043
- ...permissions.length ? { permissions } : {}
8046
+ ...permissions.length ? { permissions } : {},
8047
+ ...input.command ? { command: input.command } : {},
8048
+ ...input.argumentHint ? { "argument-hint": input.argumentHint } : {}
8044
8049
  },
8045
8050
  input.body?.trim() || `# ${name}
8046
8051
 
@@ -8057,7 +8062,8 @@ Describe the steps the agent should follow when this skill is invoked.`
8057
8062
  whenToUse: input.whenToUse?.trim(),
8058
8063
  permissions,
8059
8064
  body: document,
8060
- path
8065
+ path,
8066
+ ...input.command ? { command: { raw: input.command, argumentHint: input.argumentHint } } : {}
8061
8067
  };
8062
8068
  }
8063
8069
  async function loadSkillsContext(workspace) {
@@ -8831,6 +8837,124 @@ import { z as z25 } from "zod";
8831
8837
  import { mkdir as mkdir11, writeFile as writeFile8 } from "fs/promises";
8832
8838
  import { join as join9 } from "path";
8833
8839
 
8840
+ // src/core/periodic-compaction.ts
8841
+ var DEFAULT_SUMMARY_INTERVAL_STEPS = 12;
8842
+ var MIN_PERIODIC_HISTORY_CHARS = 12e3;
8843
+ function summaryIntervalSteps(value = process.env.ULTRAC_SUMMARY_INTERVAL_STEPS) {
8844
+ if (value === void 0 || value.trim() === "") return DEFAULT_SUMMARY_INTERVAL_STEPS;
8845
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) > 1e3) throw new Error("ULTRAC_SUMMARY_INTERVAL_STEPS must be an integer from 0 to 1000.");
8846
+ return Number(value);
8847
+ }
8848
+ function completeContextUnits(messages) {
8849
+ const units = [];
8850
+ for (const message of messages) {
8851
+ if (message.role === "tool") {
8852
+ const previous = units.at(-1);
8853
+ if (!previous || previous[0].role !== "assistant") return null;
8854
+ previous.push(message);
8855
+ } else units.push([message]);
8856
+ }
8857
+ for (const unit of units) {
8858
+ const first = unit[0];
8859
+ if (first.role !== "assistant") continue;
8860
+ if (!first.toolCalls?.length) {
8861
+ if (unit.slice(1).some((message) => "toolCallId" in message)) return null;
8862
+ continue;
8863
+ }
8864
+ const ids = new Set(first.toolCalls.map((call) => call.id));
8865
+ const results = unit.slice(1).map((message) => message.toolCallId);
8866
+ if (ids.size !== first.toolCalls.length || results.length !== ids.size || results.some((id) => !id || !ids.has(id)) || new Set(results).size !== ids.size) return null;
8867
+ }
8868
+ return units;
8869
+ }
8870
+ var PeriodicCompactor = class {
8871
+ constructor(options = {}) {
8872
+ this.options = options;
8873
+ }
8874
+ options;
8875
+ lastAttemptStep = 0;
8876
+ async compact(messages, pinned, provider, model, completedSteps, signal) {
8877
+ const unchanged = () => ({ messages: [...messages], compacted: false });
8878
+ const interval = this.options.intervalSteps ?? summaryIntervalSteps();
8879
+ if (!interval || signal?.aborted || completedSteps - this.lastAttemptStep < interval) return unchanged();
8880
+ const units = completeContextUnits(messages);
8881
+ if (!units) return unchanged();
8882
+ const recent = new Set(units.filter((unit) => unit[0].role === "assistant").slice(-2));
8883
+ const pinnedSet = new Set(pinned);
8884
+ const history = units.filter((unit) => !recent.has(unit) && !unit.some((message) => message.role === "user" || message.role === "system" || pinnedSet.has(message) || isCompactionSummary(message))).flat();
8885
+ const removedChars = history.reduce((size, message) => size + JSON.stringify(message).length, 0);
8886
+ if (history.length < 2 || removedChars < (this.options.minHistoryChars ?? MIN_PERIODIC_HISTORY_CHARS)) return unchanged();
8887
+ this.lastAttemptStep = completedSteps;
8888
+ const trace = startDiagnosticSpan("context.compaction", { path: "periodic", completedSteps, compactedMessageCount: history.length }, 5e3);
8889
+ const controller = new AbortController();
8890
+ let rejectCancelled;
8891
+ const interrupted2 = new Promise((_resolve, reject) => {
8892
+ rejectCancelled = reject;
8893
+ });
8894
+ const abort = () => {
8895
+ controller.abort();
8896
+ rejectCancelled(new Error("summary_interrupted"));
8897
+ };
8898
+ const timer = setTimeout(abort, this.options.timeoutMs ?? DEFAULT_SUMMARY_TIMEOUT_MS);
8899
+ signal?.addEventListener("abort", abort, { once: true });
8900
+ if (signal?.aborted) abort();
8901
+ try {
8902
+ const completion = await Promise.race([provider.complete({ model, system: summaryInstructions(DEFAULT_MAX_SUMMARY_CHARS), messages: [{ role: "user", content: renderDroppedTranscript(history, { maxTranscriptChars: DEFAULT_MAX_TRANSCRIPT_CHARS }) }], signal: controller.signal, context: { purpose: "compaction_summary", compactedMessages: history.length } }), interrupted2]);
8903
+ if (controller.signal.aborted) {
8904
+ trace.end({ outcome: "skipped", reason: "interrupted" });
8905
+ return unchanged();
8906
+ }
8907
+ const summary = completion.text.trim();
8908
+ if (!summary || summary.length > DEFAULT_MAX_SUMMARY_CHARS || summary.length + 300 >= removedChars) {
8909
+ trace.end({ outcome: "skipped", reason: "invalid-summary" });
8910
+ return unchanged();
8911
+ }
8912
+ const removed = new Set(history);
8913
+ const compacted = messages.filter((message) => !removed.has(message));
8914
+ const firstRemoved = messages.findIndex((message) => removed.has(message));
8915
+ const insertion = messages.slice(0, firstRemoved).filter((message) => !removed.has(message)).length;
8916
+ compacted.splice(insertion, 0, compactionSummaryMessage(summary));
8917
+ if (!completeContextUnits(compacted)) {
8918
+ trace.end({ outcome: "skipped", reason: "invalid-pairing" });
8919
+ return unchanged();
8920
+ }
8921
+ trace.end({ outcome: "compacted", summaryChars: summary.length });
8922
+ return { messages: compacted, compacted: true };
8923
+ } catch {
8924
+ trace.end({ outcome: "failed", reason: controller.signal.aborted ? "interrupted" : "summary-failed" });
8925
+ return unchanged();
8926
+ } finally {
8927
+ clearTimeout(timer);
8928
+ signal?.removeEventListener("abort", abort);
8929
+ }
8930
+ }
8931
+ };
8932
+ function snapshotMessages(messages) {
8933
+ const result = [];
8934
+ for (let index = 0; index < messages.length; index += 1) {
8935
+ const message = messages[index];
8936
+ const native = message;
8937
+ if (native.role !== "assistant" || !native.toolCalls?.length) {
8938
+ result.push(message);
8939
+ continue;
8940
+ }
8941
+ const replies = [];
8942
+ while (messages[index + 1]?.role === "tool") replies.push(messages[++index]);
8943
+ const ids = new Set(replies.map((reply) => reply.toolCallId));
8944
+ const calls = native.toolCalls.filter((call) => ids.has(call.id));
8945
+ const completedIds = new Set(calls.map((call) => call.id));
8946
+ const base = { role: native.role, content: native.content };
8947
+ result.push({ ...base, ...calls.length ? { toolCalls: calls } : {} });
8948
+ result.push(...replies.filter((reply) => completedIds.has(reply.toolCallId)));
8949
+ }
8950
+ return result;
8951
+ }
8952
+ function snapshotWithConstraints(messages, constraints) {
8953
+ const snapshot = snapshotMessages(messages);
8954
+ for (const constraint of constraints) if (!snapshot.some((message) => message.role === "user" && message.content === constraint.content)) snapshot.push({ role: "user", content: constraint.content });
8955
+ return snapshot;
8956
+ }
8957
+
8834
8958
  // src/core/progress-events.ts
8835
8959
  var listeners = /* @__PURE__ */ new Set();
8836
8960
  function onAgentProgress(listener) {
@@ -8997,18 +9121,18 @@ function isConcurrencySafe(tool) {
8997
9121
  }
8998
9122
  function planToolBatches(calls, tools) {
8999
9123
  const batches = [];
9000
- let open8;
9124
+ let open9;
9001
9125
  for (const call of calls) {
9002
9126
  if (!isConcurrencySafe(tools.get(call.tool))) {
9003
9127
  batches.push([call]);
9004
- open8 = void 0;
9128
+ open9 = void 0;
9005
9129
  continue;
9006
9130
  }
9007
- if (!open8) {
9008
- open8 = [];
9009
- batches.push(open8);
9131
+ if (!open9) {
9132
+ open9 = [];
9133
+ batches.push(open9);
9010
9134
  }
9011
- open8.push(call);
9135
+ open9.push(call);
9012
9136
  }
9013
9137
  return batches;
9014
9138
  }
@@ -9546,6 +9670,17 @@ function readSessionUserConstraints(session) {
9546
9670
  }
9547
9671
  return constraints;
9548
9672
  }
9673
+ function readSessionContextSnapshot(session) {
9674
+ if (session.contextSnapshot !== void 0) {
9675
+ const snapshot = session.contextSnapshot;
9676
+ if (!snapshot || typeof snapshot !== "object" || snapshot.version !== 1 || !["native", "text", "run"].includes(snapshot.kind) || !Number.isSafeInteger(snapshot.completedSteps) || snapshot.completedSteps < 0 || !Array.isArray(snapshot.messages) || snapshot.messages.some((message) => {
9677
+ if (!message || typeof message !== "object" || !["user", "assistant", "system", "tool"].includes(message.role) || typeof message.content !== "string") return true;
9678
+ const native = message;
9679
+ return snapshot.kind !== "native" && (message.role === "system" || native.toolCalls !== void 0 || native.toolCallId !== void 0) || native.toolCalls !== void 0 && message.role !== "assistant" || native.toolCallId !== void 0 && message.role !== "tool" || snapshot.kind === "native" && message.role === "tool" && typeof native.toolCallId !== "string" || native.toolCallId !== void 0 && typeof native.toolCallId !== "string" || native.toolCalls !== void 0 && (!Array.isArray(native.toolCalls) || native.toolCalls.some((call) => !call || typeof call.id !== "string" || typeof call.name !== "string" || typeof call.arguments !== "string"));
9680
+ }) || !completeContextUnits(snapshot.messages) || (readSessionUserConstraints(session) ?? [{ content: session.task }]).some((constraint) => !snapshot.messages.some((message) => message.role === "user" && message.content === constraint.content))) throw new Error(`Session ${session.id} has an invalid saved context snapshot and cannot be continued safely.`);
9681
+ }
9682
+ return session.contextSnapshot;
9683
+ }
9549
9684
  function createSessionId(date = /* @__PURE__ */ new Date()) {
9550
9685
  const stamp = date.toISOString().replace(/[:.]/g, "-");
9551
9686
  return `${stamp}-${randomBytes3(4).toString("hex")}`;
@@ -9823,6 +9958,7 @@ function validateSession(value, expectedId, label) {
9823
9958
  if (!isSafeSessionId(sessionId) || sessionId !== expectedId || session.parentSessionId !== void 0 && (typeof session.parentSessionId !== "string" || !isSafeSessionId(session.parentSessionId) || normalizedSessionId(session.parentSessionId) !== session.parentSessionId) || typeof session.task !== "string" || typeof session.workspace !== "string" || typeof session.provider !== "string" || typeof session.model !== "string" || typeof session.startedAt !== "string" || typeof session.endedAt !== "string" || typeof session.finalMessage !== "string" || !isToolPermissions(session.permissions) || !Array.isArray(session.steps) || session.mcpResultArchiveVersion !== void 0 && session.mcpResultArchiveVersion !== 1 || session.mode !== void 0 && !isAgentModeName2(session.mode) || session.stopReason !== void 0 && !isStopReason(session.stopReason) || session.subagents !== void 0 && !Array.isArray(session.subagents)) {
9824
9959
  invalid();
9825
9960
  }
9961
+ readSessionContextSnapshot(value);
9826
9962
  const steps = session.steps;
9827
9963
  for (const stepValue of steps) {
9828
9964
  if (!stepValue || typeof stepValue !== "object" || Array.isArray(stepValue)) invalid();
@@ -9933,6 +10069,7 @@ var AgentRunner = class {
9933
10069
  ];
9934
10070
  const pinnedMessages = [...constraintMessages];
9935
10071
  const steps = [];
10072
+ const periodicCompactor = new PeriodicCompactor();
9936
10073
  const toolsByName = new Map(
9937
10074
  registered.map((tool) => [tool.name, tool])
9938
10075
  );
@@ -9955,7 +10092,8 @@ var AgentRunner = class {
9955
10092
  ...subagents2.length ? { subagents: subagents2 } : {},
9956
10093
  constraintVersion: 1,
9957
10094
  userConstraints: [...userConstraints],
9958
- mcpResultArchiveVersion: 1
10095
+ mcpResultArchiveVersion: 1,
10096
+ ...messages.some((message) => message.role === "user" && message.content.startsWith("[compacted-context]")) ? { contextSnapshot: { version: 1, kind: "run", messages: snapshotWithConstraints(messages, userConstraints), completedSteps: steps.length } } : {}
9959
10097
  });
9960
10098
  const depth = options.depth ?? 0;
9961
10099
  const isTopLevel = depth === 0;
@@ -9964,6 +10102,7 @@ var AgentRunner = class {
9964
10102
  try {
9965
10103
  await options.onCheckpoint(buildSession(void 0, listSubagentTranscripts()));
9966
10104
  } catch {
10105
+ diagnosticEvent("session.checkpoint", { outcome: "failed", path: "run" });
9967
10106
  }
9968
10107
  };
9969
10108
  let stopReason = "max-steps";
@@ -9981,6 +10120,11 @@ var AgentRunner = class {
9981
10120
  transcriptKind: "run",
9982
10121
  fileProvenance
9983
10122
  };
10123
+ const periodic = await periodicCompactor.compact(messages, [...pinnedMessages, taskMessage], options.provider, options.model, steps.length, options.signal);
10124
+ if (periodic.compacted) {
10125
+ messages.splice(0, messages.length, ...periodic.messages);
10126
+ await checkpoint();
10127
+ }
9984
10128
  await compactIfNeeded(options, messages, system, taskMessage, pinnedMessages, requestContext);
9985
10129
  if (options.signal?.aborted) {
9986
10130
  stopReason = "aborted";
@@ -10294,7 +10438,7 @@ var NOTHING_RESTORED = {
10294
10438
  legacyMcpResults: 0
10295
10439
  };
10296
10440
  function restorePreviousTranscript(sessions, maxToolResultChars, fileProvenance) {
10297
- if (sessions.length === 0 || sessions.every((session) => session.steps.length === 0)) return NOTHING_RESTORED;
10441
+ if (sessions.length === 0 || sessions.every((session) => session.steps.length === 0 && !readSessionContextSnapshot(session))) return NOTHING_RESTORED;
10298
10442
  const messages = [];
10299
10443
  let toolResults = 0;
10300
10444
  let truncatedResults = 0;
@@ -10302,6 +10446,11 @@ function restorePreviousTranscript(sessions, maxToolResultChars, fileProvenance)
10302
10446
  let steps = 0;
10303
10447
  for (const session of sessions) {
10304
10448
  steps += session.steps.length;
10449
+ const snapshot = readSessionContextSnapshot(session);
10450
+ if (snapshot?.kind === "run" || snapshot?.kind === "text") {
10451
+ messages.splice(0, messages.length, ...snapshot.messages);
10452
+ continue;
10453
+ }
10305
10454
  for (const step of session.steps) {
10306
10455
  const content = step.parsed ? JSON.stringify(step.parsed) : step.modelText;
10307
10456
  if (content) messages.push({ role: "assistant", content });
@@ -10316,6 +10465,7 @@ function restorePreviousTranscript(sessions, maxToolResultChars, fileProvenance)
10316
10465
  fileProvenance?.set(entry.message, toolResultFileProvenance(result, entry.truncated));
10317
10466
  }
10318
10467
  }
10468
+ if (snapshot) messages.push(...snapshot.messages.filter((message) => message.role === "user" && message.content.startsWith("[compacted-context]")).map((message) => ({ role: "user", content: message.content })));
10319
10469
  }
10320
10470
  return { messages, steps, toolResults, truncatedResults, legacyMcpResults };
10321
10471
  }
@@ -13522,7 +13672,10 @@ function buildHarnessManifest() {
13522
13672
  }
13523
13673
 
13524
13674
  // src/commands/mcp.ts
13525
- import { resolve as resolve9 } from "path";
13675
+ import { resolve as resolve9, dirname as dirname12, join as join12 } from "path";
13676
+ import { constants as constants5 } from "fs";
13677
+ import { lstat as lstat3, mkdir as mkdir14, open as open8, rename as rename2, unlink as unlink3 } from "fs/promises";
13678
+ import { randomUUID as randomUUID4 } from "crypto";
13526
13679
  import chalk5 from "chalk";
13527
13680
 
13528
13681
  // src/mcp/client.ts
@@ -14348,7 +14501,47 @@ function asError(error) {
14348
14501
 
14349
14502
  // src/commands/mcp.ts
14350
14503
  function registerMcpCommand(program2) {
14351
- const mcpCommand = program2.command("mcp").description("Inspect the MCP servers this workspace connects to");
14504
+ const mcpCommand = program2.command("mcp").description("Configure and inspect workspace MCP servers");
14505
+ mcpCommand.command("add <name> <command> [args...]").description("Add a stdio MCP server without starting it").option("-w, --workspace <path>", "Workspace directory", process.cwd()).option("--env <NAME=value>", "Explicit child environment value; prefer --env-pass for credentials", collect, []).option("--env-pass <NAME>", "Forward a named parent environment variable", collect, []).option("--cwd <path>", "Server working directory").option("--timeout <milliseconds>", "Per-request timeout", "30000").option("--disabled", "Keep the server disabled", false).option("--force", "Replace an existing server with this name", false).action(async (name, command, args, options) => {
14506
+ validateServerName(name);
14507
+ const env2 = {};
14508
+ for (const entry of options.env) {
14509
+ const separator = entry.indexOf("=");
14510
+ if (separator < 1) throw new Error("--env must use NAME=value.");
14511
+ const key = entry.slice(0, separator);
14512
+ validateEnvironmentName(key);
14513
+ Object.defineProperty(env2, key, { value: entry.slice(separator + 1), enumerable: true, configurable: true });
14514
+ }
14515
+ options.envPass.forEach(validateEnvironmentName);
14516
+ const spec = McpServerSchema.parse({
14517
+ command,
14518
+ args,
14519
+ env: env2,
14520
+ envPassthrough: [...new Set(options.envPass)],
14521
+ cwd: options.cwd,
14522
+ enabled: !options.disabled,
14523
+ timeoutMs: Number(options.timeout)
14524
+ });
14525
+ const workspace = await assertWorkspaceExists(options.workspace);
14526
+ await updateMcpConfig(workspace, (config) => {
14527
+ const servers = { ...config.mcpServers ?? {} };
14528
+ if (Object.hasOwn(servers, name) && !options.force) throw new Error(`MCP server ${name} already exists. Use --force to replace it.`);
14529
+ servers[name] = spec;
14530
+ return { ...config, mcpServers: servers };
14531
+ });
14532
+ console.log(`Added MCP server ${name}. Run ultrac trust in this workspace to approve the updated settings.`);
14533
+ });
14534
+ mcpCommand.command("remove <name>").description("Remove a workspace MCP server").option("-w, --workspace <path>", "Workspace directory", process.cwd()).action(async (name, options) => {
14535
+ validateServerName(name);
14536
+ const workspace = await assertWorkspaceExists(options.workspace);
14537
+ await updateMcpConfig(workspace, (config) => {
14538
+ const servers = { ...config.mcpServers ?? {} };
14539
+ if (!Object.hasOwn(servers, name)) throw new Error(`No MCP server named ${name}.`);
14540
+ delete servers[name];
14541
+ return { ...config, mcpServers: servers };
14542
+ });
14543
+ console.log(`Removed MCP server ${name}. Reapprove changed workspace settings with ultrac trust if required.`);
14544
+ });
14352
14545
  mcpCommand.command("list", { isDefault: true }).description("Connect every configured server and report what it exports").option("-w, --workspace <path>", "Workspace directory", process.cwd()).option("--json", "Print the result as JSON", false).action(async (options) => {
14353
14546
  const workspace = resolve9(options.workspace);
14354
14547
  const loaded = await loadConfigWithTrust(workspace);
@@ -14374,6 +14567,7 @@ function registerMcpCommand(program2) {
14374
14567
  }
14375
14568
  const runtime = await withRuntime(workspace, servers, (active) => active);
14376
14569
  try {
14570
+ if (runtime.statuses.some((status) => !status.ok)) process.exitCode = 1;
14377
14571
  if (options.json) {
14378
14572
  console.log(JSON.stringify({ servers: runtime.statuses }, null, 2));
14379
14573
  return;
@@ -14396,7 +14590,7 @@ function registerMcpCommand(program2) {
14396
14590
  runtime.close();
14397
14591
  }
14398
14592
  });
14399
- mcpCommand.command("call <server> <tool>").description("Call one MCP tool directly, for checking a server by hand").option("-w, --workspace <path>", "Workspace directory", process.cwd()).option("--input <json>", "Tool arguments as a JSON object", "{}").action(async (server, tool, options) => {
14593
+ mcpCommand.command("call <server> <tool>").description("Call one MCP tool directly, for checking a server by hand").option("-w, --workspace <path>", "Workspace directory", process.cwd()).option("--input <json>", "Tool arguments as a JSON object", "{}").option("--json", "Print the result as JSON", false).action(async (server, tool, options) => {
14400
14594
  const workspace = resolve9(options.workspace);
14401
14595
  const loaded = await loadConfigWithTrust(workspace);
14402
14596
  const spec = loaded.config.mcpServers?.[server];
@@ -14414,6 +14608,7 @@ function registerMcpCommand(program2) {
14414
14608
  } catch (error) {
14415
14609
  throw new Error(`--input must be a JSON object: ${error instanceof Error ? error.message : String(error)}`);
14416
14610
  }
14611
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("--input must be a JSON object.");
14417
14612
  const runtime = await withRuntime(workspace, { [server]: spec }, (active) => active);
14418
14613
  try {
14419
14614
  const status = runtime.statuses[0];
@@ -14438,6 +14633,68 @@ function registerMcpCommand(program2) {
14438
14633
  }
14439
14634
  });
14440
14635
  }
14636
+ function collect(value, previous) {
14637
+ return [...previous, value];
14638
+ }
14639
+ function validateServerName(name) {
14640
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(name) || name.includes("__") || ["constructor", "prototype"].includes(name)) {
14641
+ throw new Error("MCP server names must contain letters, numbers, dots, single underscores, or hyphens.");
14642
+ }
14643
+ }
14644
+ function validateEnvironmentName(name) {
14645
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Environment variable names must contain letters, numbers, or underscores.");
14646
+ }
14647
+ async function updateMcpConfig(workspace, update) {
14648
+ const target = configPath(workspace);
14649
+ await resolveInsideWorkspaceReal(workspace, target);
14650
+ await mkdir14(dirname12(target), { recursive: true, mode: 448 });
14651
+ await resolveInsideWorkspaceReal(workspace, target);
14652
+ const lockPath = `${target}.mcp-lock`;
14653
+ const lock = await open8(lockPath, "wx", 384).catch((error) => {
14654
+ if (error.code === "EEXIST") throw new Error("Another MCP configuration update is in progress.");
14655
+ throw error;
14656
+ });
14657
+ const temporary = join12(dirname12(target), `.mcp-config-${randomUUID4()}.tmp`);
14658
+ try {
14659
+ let config = defaultConfig();
14660
+ const existing = await lstat3(target).catch((error) => {
14661
+ if (error.code === "ENOENT") return null;
14662
+ throw error;
14663
+ });
14664
+ if (existing) {
14665
+ if (!existing.isFile() || existing.isSymbolicLink() || existing.size > 1048576) throw new Error("MCP config must be a regular JSON file no larger than 1 MiB.");
14666
+ const handle2 = await open8(target, constants5.O_RDONLY | constants5.O_NOFOLLOW);
14667
+ try {
14668
+ const content = await handle2.readFile("utf8");
14669
+ try {
14670
+ config = JSON.parse(content);
14671
+ } catch {
14672
+ throw new Error("Workspace config is not valid JSON.");
14673
+ }
14674
+ } finally {
14675
+ await handle2.close();
14676
+ }
14677
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Workspace config must be a JSON object.");
14678
+ if (!UltracConfigSchema.safeParse(config).success) throw new Error("Workspace config does not match the Ultrac configuration schema.");
14679
+ }
14680
+ const next = update(config);
14681
+ UltracConfigSchema.parse(next);
14682
+ const handle = await open8(temporary, "wx", 384);
14683
+ try {
14684
+ await handle.writeFile(`${JSON.stringify(next, null, 2)}
14685
+ `, "utf8");
14686
+ await handle.sync();
14687
+ } finally {
14688
+ await handle.close();
14689
+ }
14690
+ await resolveInsideWorkspaceReal(workspace, target);
14691
+ await rename2(temporary, target);
14692
+ } finally {
14693
+ await unlink3(temporary).catch(() => void 0);
14694
+ await lock.close();
14695
+ await unlink3(lockPath).catch(() => void 0);
14696
+ }
14697
+ }
14441
14698
  async function withRuntime(workspace, servers, use) {
14442
14699
  const packageInfo = readPackageInfo();
14443
14700
  const runtime = await connectMcpServers(servers, {
@@ -14554,7 +14811,7 @@ import chalk6 from "chalk";
14554
14811
 
14555
14812
  // src/notify/config.ts
14556
14813
  import { chmod as chmod3, readFile as readFile12, rm as rm4, writeFile as writeFile11 } from "fs/promises";
14557
- import { join as join12 } from "path";
14814
+ import { join as join13 } from "path";
14558
14815
  import { z as z31 } from "zod";
14559
14816
  var TelegramSchema = z31.object({
14560
14817
  botToken: z31.string().min(1),
@@ -14580,7 +14837,7 @@ var NotifyConfigSchema = z31.object({
14580
14837
  answerTimeoutSeconds: z31.number().int().min(30).max(86400).default(1800)
14581
14838
  });
14582
14839
  function notifyConfigPath() {
14583
- return join12(ultracHomeDir(), "notify.json");
14840
+ return join13(ultracHomeDir(), "notify.json");
14584
14841
  }
14585
14842
  async function readStoredNotifyConfig() {
14586
14843
  let text;
@@ -15019,7 +15276,7 @@ function registerNotifyCommand(program2) {
15019
15276
  }
15020
15277
 
15021
15278
  // src/commands/repl.ts
15022
- import { createInterface as createInterface3 } from "readline";
15279
+ import { createInterface as createInterface3, emitKeypressEvents } from "readline";
15023
15280
  import { stdin as stdin5, stdout as stdout5 } from "process";
15024
15281
  import chalk11 from "chalk";
15025
15282
 
@@ -15197,6 +15454,9 @@ function registerTrustCommand(program2) {
15197
15454
  // src/core/manual-compaction.ts
15198
15455
  async function compactManually(messages, pinned, provider, model, focus = "", signal) {
15199
15456
  const retained = new Set(pinned);
15457
+ const units = completeContextUnits(messages);
15458
+ if (!units) throw new Error("Compaction found incomplete tool units; original context was kept.");
15459
+ for (const unit of units) if (unit.some((message) => retained.has(message))) for (const message of unit) retained.add(message);
15200
15460
  const history = messages.filter(
15201
15461
  (message) => message.role !== "system" && !retained.has(message) && !isCompactionSummary(message)
15202
15462
  );
@@ -15252,6 +15512,10 @@ Give extra attention to this user-supplied focus: ${focus}` : ""
15252
15512
  }
15253
15513
 
15254
15514
  // src/core/interactive.ts
15515
+ var SKIPPED_FOR_QUEUED_MESSAGE = "Skipped; a new user message was queued after the previous tool. Follow that message instead of retrying this call.";
15516
+ function skippedForQueuedMessage(tool) {
15517
+ return { tool, ok: false, content: SKIPPED_FOR_QUEUED_MESSAGE };
15518
+ }
15255
15519
  function permissionDeniedMessage(tool, permission, sticky) {
15256
15520
  const denied = `User denied ${permission} permission for ${tool}.`;
15257
15521
  if (!sticky) return denied;
@@ -15310,6 +15574,7 @@ var InteractiveSession = class {
15310
15574
  started = false;
15311
15575
  /** How the last turn ended. Absent until one has. */
15312
15576
  lastStopReason;
15577
+ periodicCompactor = new PeriodicCompactor();
15313
15578
  pinnedMessages = [];
15314
15579
  /** Tools described in this turn's system prompt. `/mode` can change it. */
15315
15580
  advertisedTools() {
@@ -15348,6 +15613,7 @@ var InteractiveSession = class {
15348
15613
  }
15349
15614
  async restoreSessions(sessions) {
15350
15615
  if (this.steps.length || this.userConstraints.length) throw new Error("Restore requires a fresh session.");
15616
+ for (const saved of sessions) readSessionContextSnapshot(saved);
15351
15617
  await this.start();
15352
15618
  this.parentSessionId = sessions.at(-1)?.id;
15353
15619
  this.firstTask = sessions.at(-1)?.task ?? "";
@@ -15368,6 +15634,17 @@ var InteractiveSession = class {
15368
15634
  for (const { call, result } of step.toolResults) this.messages.push({ role: "tool", content: JSON.stringify({ call, result: capResultForTranscript(result, this.options.maxToolResultChars) }) });
15369
15635
  }
15370
15636
  }
15637
+ const snapshot = sessions.at(-1)?.contextSnapshot;
15638
+ if (snapshot && snapshot.kind !== "text") this.messages.push(...snapshot.messages.filter(isCompactionSummary).map((message) => ({ role: "user", content: message.content })));
15639
+ if (snapshot?.kind === "text") {
15640
+ this.messages.splice(0, this.messages.length, ...snapshot.messages);
15641
+ this.pinnedMessages.splice(0, this.pinnedMessages.length, ...this.messages.filter((message) => message.role === "user" && !isCompactionSummary(message)));
15642
+ for (const constraint of this.userConstraints) if (!this.messages.some((message) => message.role === "user" && message.content === constraint.content)) {
15643
+ const message = { role: "user", content: constraint.content };
15644
+ this.messages.push(message);
15645
+ this.pinnedMessages.push(message);
15646
+ }
15647
+ }
15371
15648
  }
15372
15649
  contextStatus() {
15373
15650
  const mode = getAgentModePreset(this.activeMode);
@@ -15430,6 +15707,7 @@ Acknowledge that you have the context and wait for the user's first request.`
15430
15707
  try {
15431
15708
  await handlers.onCheckpoint(this.toSession());
15432
15709
  } catch {
15710
+ diagnosticEvent("session.checkpoint", { outcome: "failed", path: "interactive" });
15433
15711
  }
15434
15712
  }
15435
15713
  appendQueuedMessages(handlers) {
@@ -15472,6 +15750,11 @@ Acknowledge that you have the context and wait for the user's first request.`
15472
15750
  modePreset
15473
15751
  );
15474
15752
  const turn = this.steps.length + 1;
15753
+ const periodic = await this.periodicCompactor.compact(this.messages, this.pinnedMessages, this.options.provider, this.activeModel, this.steps.length, handlers.signal);
15754
+ if (periodic.compacted) {
15755
+ this.messages.splice(0, this.messages.length, ...periodic.messages);
15756
+ await this.checkpoint(handlers);
15757
+ }
15475
15758
  await this.compactIfNeeded(system, handlers, turn);
15476
15759
  handlers.onModelStart?.();
15477
15760
  let text;
@@ -15512,12 +15795,13 @@ Acknowledge that you have the context and wait for the user's first request.`
15512
15795
  this.messages.push({ role: "assistant", content: JSON.stringify(parsed) });
15513
15796
  this.steps.push(step);
15514
15797
  await this.checkpoint(handlers);
15515
- for (const call of parsed.toolCalls) {
15798
+ for (let callIndex = 0; callIndex < parsed.toolCalls.length; callIndex += 1) {
15799
+ const call = parsed.toolCalls[callIndex];
15516
15800
  if (handlers.signal?.aborted) break;
15517
15801
  const authorization = await this.authorize(call, handlers);
15518
15802
  if (handlers.signal?.aborted) break;
15519
15803
  if (authorization.denied) {
15520
- const result2 = {
15804
+ const result = {
15521
15805
  tool: call.tool,
15522
15806
  ok: false,
15523
15807
  content: authorization.message ?? permissionDeniedMessage(
@@ -15527,24 +15811,33 @@ Acknowledge that you have the context and wait for the user's first request.`
15527
15811
  )
15528
15812
  };
15529
15813
  if (authorization.permission) handlers.onToolDenied?.(call, authorization.permission);
15530
- step.toolResults.push({ call, result: result2 });
15531
- this.pushToolResult(call, result2);
15814
+ step.toolResults.push({ call, result });
15815
+ this.pushToolResult(call, result);
15816
+ await this.checkpoint(handlers);
15817
+ } else {
15818
+ handlers.onToolStart?.(call);
15819
+ const modeAllowlist = getEnforcedModeToolAllowlist(this.activeMode);
15820
+ const rawResult = await this.registry.execute(call, {
15821
+ workspace: this.options.workspace,
15822
+ permissions: getEffectiveModePermissions(this.activeMode, authorization.permissions),
15823
+ ...handlers.signal ? { signal: handlers.signal } : {},
15824
+ ...modeAllowlist ? { modeAllowlist } : {}
15825
+ });
15826
+ const result = await this.mcpResultArchive.store(rawResult, this.options.maxToolResultChars);
15827
+ handlers.onToolResult?.(call, result);
15828
+ step.toolResults.push({ call, result });
15829
+ this.pushToolResult(call, result);
15532
15830
  await this.checkpoint(handlers);
15533
- continue;
15534
15831
  }
15535
- handlers.onToolStart?.(call);
15536
- const modeAllowlist = getEnforcedModeToolAllowlist(this.activeMode);
15537
- const rawResult = await this.registry.execute(call, {
15538
- workspace: this.options.workspace,
15539
- permissions: getEffectiveModePermissions(this.activeMode, authorization.permissions),
15540
- ...handlers.signal ? { signal: handlers.signal } : {},
15541
- ...modeAllowlist ? { modeAllowlist } : {}
15542
- });
15543
- const result = await this.mcpResultArchive.store(rawResult, this.options.maxToolResultChars);
15544
- handlers.onToolResult?.(call, result);
15545
- step.toolResults.push({ call, result });
15546
- this.pushToolResult(call, result);
15547
- await this.checkpoint(handlers);
15832
+ if (this.appendQueuedMessages(handlers) > 0) {
15833
+ for (const skipped of parsed.toolCalls.slice(callIndex + 1)) {
15834
+ const result = skippedForQueuedMessage(skipped.tool);
15835
+ step.toolResults.push({ call: skipped, result });
15836
+ this.pushToolResult(skipped, result);
15837
+ await this.checkpoint(handlers);
15838
+ }
15839
+ break;
15840
+ }
15548
15841
  }
15549
15842
  if (handlers.signal?.aborted) {
15550
15843
  stopReason = "aborted";
@@ -15604,7 +15897,7 @@ Acknowledge that you have the context and wait for the user's first request.`
15604
15897
  mode: this.activeMode,
15605
15898
  purpose: "turn"
15606
15899
  }),
15607
- // The turn's own signal: a Ctrl-C abandons the summary, not the turn.
15900
+ // The turn's own signal: Esc abandons the summary, not the turn.
15608
15901
  ...handlers.signal ? { signal: handlers.signal } : {}
15609
15902
  }
15610
15903
  );
@@ -15751,6 +16044,7 @@ Acknowledge that you have the context and wait for the user's first request.`
15751
16044
  constraintVersion: 1,
15752
16045
  userConstraints: [...this.userConstraints],
15753
16046
  mcpResultArchiveVersion: 1,
16047
+ ...this.messages.some(isCompactionSummary) ? { contextSnapshot: { version: 1, kind: "text", messages: snapshotWithConstraints(this.messages, this.userConstraints), completedSteps: this.steps.length } } : {},
15754
16048
  // How the last turn ended, so a saved interactive transcript says whether
15755
16049
  // its final message is an answer or the place it ran out of room - the
15756
16050
  // same question `ultrac run` already answers for a one-shot run.
@@ -15803,6 +16097,7 @@ var NativeSession = class {
15803
16097
  started = false;
15804
16098
  /** How the last turn ended. Absent until one has. */
15805
16099
  lastStopReason;
16100
+ periodicCompactor = new PeriodicCompactor();
15806
16101
  pinnedMessages = [];
15807
16102
  /**
15808
16103
  * The tools this turn advertises. Resolved per turn rather than once in the
@@ -15844,6 +16139,7 @@ var NativeSession = class {
15844
16139
  }
15845
16140
  async restoreSessions(sessions) {
15846
16141
  if (this.steps.length || this.userConstraints.length) throw new Error("Restore requires a fresh session.");
16142
+ for (const saved of sessions) readSessionContextSnapshot(saved);
15847
16143
  await this.start();
15848
16144
  this.parentSessionId = sessions.at(-1)?.id;
15849
16145
  this.firstTask = sessions.at(-1)?.task ?? "";
@@ -15871,6 +16167,17 @@ var NativeSession = class {
15871
16167
  }
15872
16168
  }
15873
16169
  }
16170
+ const snapshot = sessions.at(-1)?.contextSnapshot;
16171
+ if (snapshot && snapshot.kind !== "native") this.messages.push(...snapshot.messages.filter(isCompactionSummary).map((message) => ({ role: "user", content: message.content })));
16172
+ if (snapshot?.kind === "native") {
16173
+ this.messages.splice(0, this.messages.length, ...snapshot.messages);
16174
+ this.pinnedMessages.splice(0, this.pinnedMessages.length, ...this.messages.filter((message) => message.role === "user" && !isCompactionSummary(message)));
16175
+ for (const constraint of this.userConstraints) if (!this.messages.some((message) => message.role === "user" && message.content === constraint.content)) {
16176
+ const message = { role: "user", content: constraint.content };
16177
+ this.messages.push(message);
16178
+ this.pinnedMessages.push(message);
16179
+ }
16180
+ }
15874
16181
  }
15875
16182
  contextStatus() {
15876
16183
  const system = this.systemPrompt();
@@ -15931,6 +16238,7 @@ ${context}`
15931
16238
  try {
15932
16239
  await handlers.onCheckpoint(this.toSession());
15933
16240
  } catch {
16241
+ diagnosticEvent("session.checkpoint", { outcome: "failed", path: "interactive" });
15934
16242
  }
15935
16243
  }
15936
16244
  appendQueuedMessages(handlers) {
@@ -15969,6 +16277,11 @@ ${context}`
15969
16277
  this.refreshSystemPrompt();
15970
16278
  const tools = toToolSchemas(this.advertisedTools());
15971
16279
  const turn = this.steps.length + 1;
16280
+ const periodic = await this.periodicCompactor.compact(this.messages, this.pinnedMessages, this.options.provider, this.activeModel, this.steps.length, handlers.signal);
16281
+ if (periodic.compacted) {
16282
+ this.messages.splice(0, this.messages.length, ...periodic.messages);
16283
+ await this.checkpoint(handlers);
16284
+ }
15972
16285
  await this.compactIfNeeded(tools, handlers, turn);
15973
16286
  handlers.onModelStart?.();
15974
16287
  let response;
@@ -16029,13 +16342,14 @@ ${context}`
16029
16342
  }
16030
16343
  continue;
16031
16344
  }
16032
- for (const call of response.toolCalls) {
16345
+ for (let callIndex = 0; callIndex < response.toolCalls.length; callIndex += 1) {
16346
+ const call = response.toolCalls[callIndex];
16033
16347
  if (handlers.signal?.aborted) break;
16034
16348
  const displayCall = { tool: call.name, input: safeParseArgs(call.arguments) };
16035
16349
  const authorization = await this.authorize(call.name, handlers, displayCall);
16036
16350
  if (handlers.signal?.aborted) break;
16037
16351
  if (authorization.denied) {
16038
- const result2 = {
16352
+ const result = {
16039
16353
  tool: call.name,
16040
16354
  ok: false,
16041
16355
  content: authorization.message ?? permissionDeniedMessage(
@@ -16045,27 +16359,37 @@ ${context}`
16045
16359
  )
16046
16360
  };
16047
16361
  if (authorization.permission) handlers.onToolDenied?.(displayCall, authorization.permission);
16048
- step.toolResults.push({ call: displayCall, result: result2 });
16049
- this.pushToolResult(call, result2);
16362
+ step.toolResults.push({ call: displayCall, result });
16363
+ this.pushToolResult(call, result);
16364
+ await this.checkpoint(handlers);
16365
+ } else {
16366
+ handlers.onToolStart?.(displayCall);
16367
+ const modeAllowlist = getEnforcedModeToolAllowlist(this.activeMode);
16368
+ const rawResult = await this.registry.execute(
16369
+ { tool: call.name, input: displayCall.input },
16370
+ {
16371
+ workspace: this.options.workspace,
16372
+ permissions: getEffectiveModePermissions(this.activeMode, authorization.permissions),
16373
+ ...handlers.signal ? { signal: handlers.signal } : {},
16374
+ ...modeAllowlist ? { modeAllowlist } : {}
16375
+ }
16376
+ );
16377
+ const result = await this.mcpResultArchive.store(rawResult, this.options.maxToolResultChars);
16378
+ handlers.onToolResult?.(displayCall, result);
16379
+ step.toolResults.push({ call: displayCall, result });
16380
+ this.pushToolResult(call, result);
16050
16381
  await this.checkpoint(handlers);
16051
- continue;
16052
16382
  }
16053
- handlers.onToolStart?.(displayCall);
16054
- const modeAllowlist = getEnforcedModeToolAllowlist(this.activeMode);
16055
- const rawResult = await this.registry.execute(
16056
- { tool: call.name, input: displayCall.input },
16057
- {
16058
- workspace: this.options.workspace,
16059
- permissions: getEffectiveModePermissions(this.activeMode, authorization.permissions),
16060
- ...handlers.signal ? { signal: handlers.signal } : {},
16061
- ...modeAllowlist ? { modeAllowlist } : {}
16383
+ if (this.appendQueuedMessages(handlers) > 0) {
16384
+ for (const skipped of response.toolCalls.slice(callIndex + 1)) {
16385
+ const skippedCall = { tool: skipped.name, input: safeParseArgs(skipped.arguments) };
16386
+ const result = skippedForQueuedMessage(skipped.name);
16387
+ step.toolResults.push({ call: skippedCall, result });
16388
+ this.pushToolResult(skipped, result);
16389
+ await this.checkpoint(handlers);
16062
16390
  }
16063
- );
16064
- const result = await this.mcpResultArchive.store(rawResult, this.options.maxToolResultChars);
16065
- handlers.onToolResult?.(displayCall, result);
16066
- step.toolResults.push({ call: displayCall, result });
16067
- this.pushToolResult(call, result);
16068
- await this.checkpoint(handlers);
16391
+ break;
16392
+ }
16069
16393
  }
16070
16394
  if (handlers.signal?.aborted) {
16071
16395
  stopReason = "aborted";
@@ -16121,7 +16445,7 @@ ${context}`
16121
16445
  mode: this.activeMode,
16122
16446
  purpose: "turn"
16123
16447
  }),
16124
- // The turn's own signal: a Ctrl-C abandons the summary, not the turn.
16448
+ // The turn's own signal: Esc abandons the summary, not the turn.
16125
16449
  ...handlers.signal ? { signal: handlers.signal } : {}
16126
16450
  }
16127
16451
  );
@@ -16285,6 +16609,7 @@ ${guidance.join("\n")}` : ""}`;
16285
16609
  constraintVersion: 1,
16286
16610
  userConstraints: [...this.userConstraints],
16287
16611
  mcpResultArchiveVersion: 1,
16612
+ ...this.messages.some(isCompactionSummary) ? { contextSnapshot: { version: 1, kind: "native", messages: snapshotWithConstraints(this.messages, this.userConstraints), completedSteps: this.steps.length } } : {},
16288
16613
  // How the last turn ended, so a saved interactive transcript says whether
16289
16614
  // its final message is an answer or the place it ran out of room - the
16290
16615
  // same question `ultrac run` already answers for a one-shot run.
@@ -16335,6 +16660,16 @@ function createSessionCheckpointWriter(workspace, onFailure) {
16335
16660
  return Object.assign(writeCheckpoint, { warnFailure });
16336
16661
  }
16337
16662
 
16663
+ // src/ui/ghost.ts
16664
+ var quietSessions = /* @__PURE__ */ new WeakSet();
16665
+ function isGhostEnabled(session) {
16666
+ return quietSessions.has(session);
16667
+ }
16668
+ function setGhostEnabled(session, enabled) {
16669
+ if (enabled) quietSessions.add(session);
16670
+ else quietSessions.delete(session);
16671
+ }
16672
+
16338
16673
  // src/ui/output.ts
16339
16674
  import chalk8 from "chalk";
16340
16675
  var stopReasonDetail = {
@@ -16348,7 +16683,7 @@ function stopReasonLine(stopReason) {
16348
16683
  const detail = stopReasonDetail[reason] ?? "partial answer";
16349
16684
  return `Stopped: ${reason} (${detail})`;
16350
16685
  }
16351
- function printSession(session, sessionPath2) {
16686
+ function printSession(session, sessionPath2, options = {}) {
16352
16687
  for (const step of session.steps) {
16353
16688
  if (step.parsed?.message) {
16354
16689
  console.log(chalk8.gray(`step ${step.index + 1}:`), step.parsed.message);
@@ -16356,7 +16691,7 @@ function printSession(session, sessionPath2) {
16356
16691
  for (const toolResult of step.toolResults) {
16357
16692
  const status = toolResult.result.ok ? chalk8.green("ok") : chalk8.red("failed");
16358
16693
  console.log(chalk8.cyan(toolResult.call.tool), status);
16359
- console.log(chalk8.gray(indent2(toolResult.result.content)));
16694
+ console.log(chalk8.gray(indent2(formatToolOutput(toolResult.result.content, { verbose: options.verboseTools, failed: !toolResult.result.ok, hint: "Use --verbose-tools or inspect the saved session for full output." }))));
16360
16695
  }
16361
16696
  }
16362
16697
  if (session.finalMessage) {
@@ -16379,17 +16714,31 @@ function printTools(tools) {
16379
16714
  console.log(chalk8.gray(indent2(tool.description)));
16380
16715
  }
16381
16716
  }
16382
- function printToolResult(result) {
16717
+ function printToolResult(result, options = {}) {
16383
16718
  const status = result.ok ? chalk8.green("ok") : chalk8.red("failed");
16384
16719
  console.log(`${chalk8.cyan(result.tool)} ${status}`);
16385
- console.log(chalk8.gray(indent2(result.content)));
16720
+ console.log(chalk8.gray(indent2(formatToolOutput(result.content, { verbose: options.verboseTools, failed: !result.ok, hint: "Use --verbose-tools or --result-json for full output." }))));
16386
16721
  }
16387
16722
  function indent2(value) {
16388
16723
  return value.split("\n").map((line) => ` ${line}`).join("\n");
16389
16724
  }
16725
+ function formatToolOutput(content, options = {}) {
16726
+ if (options.verbose) return content;
16727
+ const lines = content.split(/\r?\n/);
16728
+ const shown = lines.length <= 6 ? lines : options.failed ? [...lines.slice(0, 3), ...lines.slice(-3)] : lines.slice(0, 6);
16729
+ const clipped = shown.some((line) => line.length > 200);
16730
+ const preview = shown.map((line) => line.length > 200 ? `${line.slice(0, 199)}\u2026` : line).join("\n");
16731
+ const hidden = lines.length - shown.length;
16732
+ if (!hidden && !clipped) return preview;
16733
+ return `${preview}
16734
+ \u2026 ${hidden ? `${hidden} more lines` : "Long lines shortened"}. ${options.hint ?? "Full output remains available in the saved session."}`;
16735
+ }
16390
16736
 
16391
16737
  // src/skills/commands.ts
16392
16738
  var BUILTIN_SLASH_COMMANDS = [
16739
+ "logs",
16740
+ "ghost",
16741
+ "output",
16393
16742
  "usage",
16394
16743
  "max-steps",
16395
16744
  "resume",
@@ -16756,7 +17105,10 @@ var Spinner = class {
16756
17105
  label;
16757
17106
  timer;
16758
17107
  index = 0;
16759
- start() {
17108
+ holdWhile;
17109
+ held = false;
17110
+ start(holdWhile) {
17111
+ this.holdWhile = holdWhile;
16760
17112
  if (!stdout4.isTTY || this.timer) return;
16761
17113
  this.render();
16762
17114
  this.timer = setInterval(() => this.render(), 90);
@@ -16775,6 +17127,15 @@ var Spinner = class {
16775
17127
  }
16776
17128
  }
16777
17129
  render() {
17130
+ if (this.holdWhile?.()) {
17131
+ if (!this.held) {
17132
+ stdout4.clearLine(0);
17133
+ stdout4.cursorTo(0);
17134
+ this.held = true;
17135
+ }
17136
+ return;
17137
+ }
17138
+ this.held = false;
16778
17139
  this.index = (this.index + 1) % FRAMES.length;
16779
17140
  stdout4.clearLine(0);
16780
17141
  stdout4.cursorTo(0);
@@ -16890,7 +17251,7 @@ function renderWelcome(info, options = {}) {
16890
17251
  lines.push(cardLine(`logs ${shortenMiddle(info.diagnosticPath, Math.max(8, innerWidth - 5))}`, innerWidth, paint.gray));
16891
17252
  lines.push(paint.cyan(`\u2570${"\u2500".repeat(cardWidth - 2)}\u256F`));
16892
17253
  lines.push(
16893
- wide ? `${paint.gray(" /help commands")} ${paint.dim("\xB7")} ${paint.gray("/exit quit")} ${paint.dim("\xB7")} ${paint.gray("Ctrl-C interrupt")}` : paint.gray(" /help \xB7 /exit \xB7 Ctrl-C")
17254
+ wide ? `${paint.gray(" /help commands")} ${paint.dim("\xB7")} ${paint.gray("Esc stop")} ${paint.dim("\xB7")} ${paint.gray("Ctrl-C exit")}` : paint.gray(" /help \xB7 Esc \xB7 Ctrl-C")
16894
17255
  );
16895
17256
  return lines.join("\n");
16896
17257
  }
@@ -16928,14 +17289,14 @@ function shortenMiddle(value, width) {
16928
17289
  // src/update/check.ts
16929
17290
  import { spawn as spawn4 } from "child_process";
16930
17291
  import { randomBytes as randomBytes4 } from "crypto";
16931
- import { readFile as readFile13, rename as rename2, unlink as unlink3, writeFile as writeFile12 } from "fs/promises";
16932
- import { join as join13 } from "path";
17292
+ import { readFile as readFile13, rename as rename3, unlink as unlink4, writeFile as writeFile12 } from "fs/promises";
17293
+ import { join as join14 } from "path";
16933
17294
  var DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
16934
17295
  var QUERY_TIMEOUT_MS = 5e3;
16935
17296
  var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
16936
17297
  async function checkForUpdate(options) {
16937
17298
  const now = options.now ?? Date.now();
16938
- const cachePath = options.cachePath ?? join13(await ensureUltracHome(), "update-check.json");
17299
+ const cachePath = options.cachePath ?? join14(await ensureUltracHome(), "update-check.json");
16939
17300
  const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
16940
17301
  let latestVersion;
16941
17302
  if (!options.force) {
@@ -17019,9 +17380,9 @@ async function writeCache(path, cache) {
17019
17380
  try {
17020
17381
  await writeFile12(temporary, `${JSON.stringify(cache)}
17021
17382
  `, { encoding: "utf8", mode: 384, flag: "wx" });
17022
- await rename2(temporary, path);
17383
+ await rename3(temporary, path);
17023
17384
  } catch {
17024
- await unlink3(temporary).catch(() => void 0);
17385
+ await unlink4(temporary).catch(() => void 0);
17025
17386
  }
17026
17387
  }
17027
17388
  async function runNpm(args, options) {
@@ -17079,11 +17440,18 @@ var HELP = `Commands:
17079
17440
  /allow <perm> Grant a permission for this session (write, shell, network)
17080
17441
  /whoami Show sign-in state
17081
17442
  /tools List available tools
17443
+ /logs Show the private diagnostic log path
17444
+ /ghost [on|off|status] Quiet activity output; diagnostics stay enabled
17445
+ /output [number|list] Inspect saved tool output (latest by default)
17082
17446
  /memory List and inspect stored memories
17083
17447
  /skills List installed skills
17084
17448
  /update Show how to install an available CLI update
17085
17449
  /clear Start a fresh conversation
17086
17450
  /exit, /quit End the session
17451
+ Keys:
17452
+ Type during a turn Queue a follow-up for after the current tool call
17453
+ Esc Stop the current turn; the session stays open
17454
+ Ctrl-C Exit and save the session
17087
17455
  Custom commands come from skills that declare a 'command:' key in their
17088
17456
  frontmatter; built-ins always win a name clash. Type anything else to talk to Ultrac.`;
17089
17457
  var EXIT_REPL_NOT_STARTED = 1;
@@ -17295,12 +17663,21 @@ async function runRepl(options, initialTask = "") {
17295
17663
  });
17296
17664
  let running = true;
17297
17665
  let turnAbort;
17298
- rl.on("SIGINT", () => {
17299
- if (handleReplInterrupt(turnAbort) === "abort-turn") return;
17666
+ const exitSession = () => {
17667
+ handleReplExit(turnAbort);
17300
17668
  running = false;
17301
17669
  console.log(chalk11.gray("\nExiting ultrac\u2026"));
17302
17670
  rl.close();
17303
- });
17671
+ };
17672
+ rl.on("SIGINT", exitSession);
17673
+ const onKeypress = (_input, key) => {
17674
+ if (!key || key.name !== "escape" || key.ctrl || key.meta) return;
17675
+ handleReplEscape(turnAbort);
17676
+ };
17677
+ if (stdin5.isTTY) {
17678
+ emitKeypressEvents(stdin5, rl);
17679
+ stdin5.on("keypress", onKeypress);
17680
+ }
17304
17681
  const terminate = () => {
17305
17682
  running = false;
17306
17683
  turnAbort?.abort();
@@ -17319,7 +17696,7 @@ async function runRepl(options, initialTask = "") {
17319
17696
  if (initialTask) {
17320
17697
  console.log(chalk11.cyan(`
17321
17698
  \u203A ${initialTask}`));
17322
- await withInterrupt((signal) => runAndCheckpointTurn(session, initialTask, question, signal, persistSession, drainQueuedMessages));
17699
+ await withInterrupt((signal) => runAndCheckpointTurn(session, initialTask, question, signal, persistSession, drainQueuedMessages, () => reader.composing));
17323
17700
  }
17324
17701
  while (running) {
17325
17702
  if (stdout5.isTTY) {
@@ -17346,7 +17723,7 @@ async function runRepl(options, initialTask = "") {
17346
17723
  resetContextBudgetNotices();
17347
17724
  console.log(chalk11.gray("Started a fresh conversation."));
17348
17725
  }, async (prompt) => {
17349
- await runAndCheckpointTurn(session, prompt, question, signal, persistSession, drainQueuedMessages);
17726
+ await runAndCheckpointTurn(session, prompt, question, signal, persistSession, drainQueuedMessages, () => reader.composing);
17350
17727
  }, signal, async (id) => {
17351
17728
  const lineage = await loadSessionLineage(workspace, id);
17352
17729
  const restored = createSession({ model: session.model, mode: session.mode, permissions: session.currentPermissions });
@@ -17375,10 +17752,11 @@ async function runRepl(options, initialTask = "") {
17375
17752
  }
17376
17753
  continue;
17377
17754
  }
17378
- await withInterrupt((signal) => runAndCheckpointTurn(session, raw, question, signal, persistSession, drainQueuedMessages));
17755
+ await withInterrupt((signal) => runAndCheckpointTurn(session, raw, question, signal, persistSession, drainQueuedMessages, () => reader.composing));
17379
17756
  }
17380
17757
  } finally {
17381
17758
  process.off("SIGTERM", terminate);
17759
+ if (stdin5.isTTY) stdin5.off("keypress", onKeypress);
17382
17760
  setAskUserRuntime(void 0);
17383
17761
  rl.close();
17384
17762
  await saveTranscript(session, writeCheckpoint);
@@ -17386,9 +17764,11 @@ async function runRepl(options, initialTask = "") {
17386
17764
  }
17387
17765
  console.log(chalk11.gray("Goodbye."));
17388
17766
  }
17389
- async function runTurn(session, input, question, signal, drainQueuedMessages, persist) {
17767
+ async function runTurn(session, input, question, signal, drainQueuedMessages, persist, isComposing) {
17390
17768
  const spinner = new Spinner();
17391
- const detachProgress = attachProgressUi();
17769
+ const quiet = isGhostEnabled(session);
17770
+ const detachProgress = quiet ? () => {
17771
+ } : attachProgressUi();
17392
17772
  let deltaChars = 0;
17393
17773
  try {
17394
17774
  await session.ask(input, {
@@ -17404,11 +17784,11 @@ async function runTurn(session, input, question, signal, drainQueuedMessages, pe
17404
17784
  },
17405
17785
  onModelStart: () => {
17406
17786
  deltaChars = 0;
17407
- spinner.start();
17787
+ if (!quiet) spinner.start(isComposing);
17408
17788
  },
17409
17789
  onModelDelta: (text) => {
17410
17790
  deltaChars += text.length;
17411
- spinner.update(`thinking (${deltaChars} chars)`);
17791
+ if (!quiet) spinner.update(`thinking (${deltaChars} chars)`);
17412
17792
  },
17413
17793
  onModelSettled: () => spinner.stop(),
17414
17794
  onAssistantMessage: (message) => {
@@ -17418,14 +17798,14 @@ ${message.trim()}`);
17418
17798
  }
17419
17799
  },
17420
17800
  onToolStart: (call) => {
17421
- console.log(chalk11.dim(` ${toolActivityLabel(call)}`));
17801
+ if (!quiet) console.log(chalk11.dim(` ${toolActivityLabel(call)}`));
17422
17802
  },
17423
17803
  onToolResult: (_call, result) => printToolResult2(result),
17424
17804
  ...drainQueuedMessages ? {
17425
17805
  drainQueuedMessages,
17426
- onQueuedMessage: () => {
17806
+ onQueuedMessage: (message) => {
17427
17807
  spinner.stop();
17428
- console.log(chalk11.dim(" Queued message added to the current turn."));
17808
+ console.log(chalk11.dim(` Queued for after the current tool: ${message}`));
17429
17809
  }
17430
17810
  } : {},
17431
17811
  onToolDenied: (call, permission) => {
@@ -17470,9 +17850,9 @@ ${message.trim()}`);
17470
17850
  detachProgress();
17471
17851
  }
17472
17852
  }
17473
- async function runAndCheckpointTurn(session, input, question, signal, persist, drainQueuedMessages) {
17853
+ async function runAndCheckpointTurn(session, input, question, signal, persist, drainQueuedMessages, isComposing) {
17474
17854
  try {
17475
- return await runTurn(session, input, question, signal, drainQueuedMessages, persist);
17855
+ return await runTurn(session, input, question, signal, drainQueuedMessages, persist, isComposing);
17476
17856
  } finally {
17477
17857
  await persist(session);
17478
17858
  }
@@ -17544,6 +17924,21 @@ async function handleSlashCommand(raw, session, providerName, workspace, onClear
17544
17924
  const command = (name ?? "").toLowerCase();
17545
17925
  const argument = rest.join(" ").trim();
17546
17926
  switch (command) {
17927
+ case "logs": {
17928
+ console.log(currentDiagnosticLogPath() ?? "Diagnostic logging has not been configured for this process.");
17929
+ return "ok";
17930
+ }
17931
+ case "ghost": {
17932
+ if (argument && !["on", "off", "status"].includes(argument.toLowerCase())) {
17933
+ console.log("Usage: /ghost [on|off|status]");
17934
+ return "ok";
17935
+ }
17936
+ if (argument.toLowerCase() !== "status") {
17937
+ setGhostEnabled(session, argument ? argument.toLowerCase() === "on" : !isGhostEnabled(session));
17938
+ }
17939
+ console.log(`Ghost mode: ${isGhostEnabled(session) ? "on" : "off"}. Quiet activity output; private diagnostics remain enabled. Network routing and permissions are unchanged.`);
17940
+ return "ok";
17941
+ }
17547
17942
  case "help": {
17548
17943
  console.log(HELP);
17549
17944
  const index = await listSkillCommands(workspace);
@@ -17721,6 +18116,27 @@ ${extra}`);
17721
18116
  }
17722
18117
  return "ok";
17723
18118
  }
18119
+ case "output": {
18120
+ const results = session.toSession().steps.flatMap((step) => step.toolResults);
18121
+ if (!results.length) {
18122
+ console.log("No saved tool output in this session.");
18123
+ return "ok";
18124
+ }
18125
+ if (argument === "list") {
18126
+ for (const [index, entry2] of results.entries()) console.log(`${index + 1}. ${toolActivityLabel(entry2.call)} (${entry2.result.ok ? "ok" : "failed"})`);
18127
+ return "ok";
18128
+ }
18129
+ const selected = argument ? Number(argument) : results.length;
18130
+ if (!Number.isSafeInteger(selected) || selected < 1 || selected > results.length) {
18131
+ console.log(`Usage: /output [1-${results.length}|list]`);
18132
+ return "ok";
18133
+ }
18134
+ const entry = results[selected - 1];
18135
+ console.log(`${selected}. ${toolActivityLabel(entry.call)} (${entry.result.ok ? "ok" : "failed"})`);
18136
+ console.log(entry.result.content);
18137
+ for (const warning of getMcpArchiveWarnings(entry.result)) console.log(chalk11.yellow(`Warning: ${warning}`));
18138
+ return "ok";
18139
+ }
17724
18140
  case "tools": {
17725
18141
  const names = session.advertisedToolNames;
17726
18142
  console.log(`Advertised to the model this turn (${names.length}): ${names.join(", ")}`);
@@ -17801,10 +18217,8 @@ async function runSkillCommand(session, raw, workspace, question) {
17801
18217
  console.log(chalk11.red("Unknown command. Try /help."));
17802
18218
  }
17803
18219
  var LineReader = class {
17804
- queue = [];
17805
- waiters = [];
17806
- closed = false;
17807
18220
  constructor(rl) {
18221
+ this.rl = rl;
17808
18222
  rl.on("line", (line) => {
17809
18223
  const waiter = this.waiters.shift();
17810
18224
  if (waiter) {
@@ -17820,6 +18234,10 @@ var LineReader = class {
17820
18234
  }
17821
18235
  });
17822
18236
  }
18237
+ rl;
18238
+ queue = [];
18239
+ waiters = [];
18240
+ closed = false;
17823
18241
  next(signal) {
17824
18242
  return this.waitForLine(signal, true);
17825
18243
  }
@@ -17830,6 +18248,10 @@ var LineReader = class {
17830
18248
  nextFresh(signal) {
17831
18249
  return this.waitForLine(signal, false);
17832
18250
  }
18251
+ /** True while the user is typing a line that has not been submitted yet. */
18252
+ get composing() {
18253
+ return this.rl.line.length > 0;
18254
+ }
17833
18255
  /**
17834
18256
  * Drain ordinary messages in arrival order. A slash command is a control
17835
18257
  * boundary and remains queued for the main REPL loop along with everything
@@ -17875,11 +18297,15 @@ var LineReader = class {
17875
18297
  waiter.resolve(line);
17876
18298
  }
17877
18299
  };
17878
- function handleReplInterrupt(activeTurn) {
18300
+ function handleReplEscape(activeTurn) {
17879
18301
  if (activeTurn && !activeTurn.signal.aborted) {
17880
18302
  activeTurn.abort();
17881
18303
  return "abort-turn";
17882
18304
  }
18305
+ return "idle";
18306
+ }
18307
+ function handleReplExit(activeTurn) {
18308
+ activeTurn?.abort();
17883
18309
  return "exit-session";
17884
18310
  }
17885
18311
  function printBanner(version, providerName, model, mode, workspace, protocol, budget, diagnosticPath2) {
@@ -17946,23 +18372,142 @@ import { basename as basename3 } from "path";
17946
18372
  import chalk12 from "chalk";
17947
18373
 
17948
18374
  // src/core/autonomous.ts
18375
+ import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
17949
18376
  var AUTONOMOUS_CONTINUE_TASK = "Continue the task from where the previous round stopped. Check what is already done, finish the rest, and verify it.";
17950
18377
  var DEFAULT_AUTONOMOUS_MAX_ROUNDS = 5;
17951
18378
  var AUTONOMOUS_MAX_STEPS = 200;
18379
+ var DEFAULT_AUTONOMOUS_MAX_ELAPSED_MS = 6 * 60 * 60 * 1e3;
18380
+ var DEFAULT_AUTONOMOUS_BASE_DELAY_MS = 1e3;
18381
+ var DEFAULT_AUTONOMOUS_MAX_DELAY_MS = 3e4;
18382
+ var DEFAULT_AUTONOMOUS_STALL_ROUNDS = 3;
17952
18383
  function shouldResume(stopReason) {
17953
18384
  return stopReason === "max-steps" || stopReason === "error";
17954
18385
  }
17955
- async function runAutonomousLoop(options) {
17956
- if (!Number.isInteger(options.maxRounds) || options.maxRounds < 1) {
17957
- throw new Error("maxRounds must be a positive integer.");
18386
+ function boundedInteger(value, name, min, max) {
18387
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer from ${min} to ${max}.`);
18388
+ return value;
18389
+ }
18390
+ function abortableSleep(milliseconds, signal) {
18391
+ signal.throwIfAborted();
18392
+ return new Promise((resolve15, reject) => {
18393
+ const timer = setTimeout(() => {
18394
+ signal.removeEventListener("abort", abort);
18395
+ resolve15();
18396
+ }, milliseconds);
18397
+ const abort = () => {
18398
+ clearTimeout(timer);
18399
+ signal.removeEventListener("abort", abort);
18400
+ reject(signal.reason);
18401
+ };
18402
+ signal.addEventListener("abort", abort, { once: true });
18403
+ if (signal.aborted) abort();
18404
+ });
18405
+ }
18406
+ function progress(session) {
18407
+ const hash = createHash7("sha256");
18408
+ let completedTools = 0;
18409
+ hash.update(session.finalMessage ?? "");
18410
+ for (const step of session.steps ?? []) {
18411
+ hash.update(step.modelText ?? "");
18412
+ for (const tool of step.toolResults ?? []) {
18413
+ completedTools += 1;
18414
+ hash.update(tool.call.tool);
18415
+ hash.update(tool.result.ok ? "ok" : "failed");
18416
+ hash.update(tool.result.content ?? "");
18417
+ try {
18418
+ hash.update(JSON.stringify(tool.call.input));
18419
+ } catch {
18420
+ hash.update("unserializable-input");
18421
+ }
18422
+ }
17958
18423
  }
18424
+ return { fingerprint: hash.digest("hex"), completedTools };
18425
+ }
18426
+ async function runAutonomousLoop(options) {
18427
+ boundedInteger(options.maxRounds, "maxRounds", 1, 1e3);
18428
+ const maxElapsedMs = boundedInteger(options.maxElapsedMs ?? DEFAULT_AUTONOMOUS_MAX_ELAPSED_MS, "maxElapsedMs", 1, 7 * 24 * 60 * 60 * 1e3);
18429
+ const baseDelay = boundedInteger(options.baseDelayMs ?? DEFAULT_AUTONOMOUS_BASE_DELAY_MS, "baseDelayMs", 0, 3e5);
18430
+ const maxDelay = boundedInteger(options.maxDelayMs ?? DEFAULT_AUTONOMOUS_MAX_DELAY_MS, "maxDelayMs", baseDelay, 3e5);
18431
+ const stallLimit = boundedInteger(options.stallRounds ?? DEFAULT_AUTONOMOUS_STALL_ROUNDS, "stallRounds", 1, 1e3);
18432
+ const now = options.now ?? (() => performance.now());
18433
+ const started = now();
18434
+ const elapsed = () => Math.max(0, now() - started);
18435
+ const budget = new AbortController();
18436
+ const deadline = setTimeout(() => budget.abort(new DOMException("Autonomous elapsed budget reached.", "TimeoutError")), maxElapsedMs);
18437
+ deadline.unref?.();
18438
+ const signal = options.signal ? AbortSignal.any([options.signal, budget.signal]) : budget.signal;
18439
+ const sleep2 = options.sleep ?? abortableSleep;
18440
+ const id = randomUUID5();
17959
18441
  let previous;
17960
- for (let round = 1; ; round += 1) {
17961
- const session = await options.runRound(round, previous);
17962
- const willResume = shouldResume(session.stopReason) && round < options.maxRounds && !options.signal?.aborted;
17963
- await options.onRoundEnd?.(round, session, willResume);
17964
- if (!willResume) return { session, rounds: round };
17965
- previous = session;
18442
+ let lastFingerprint;
18443
+ let stalls = 0;
18444
+ let consecutiveErrors = 0;
18445
+ try {
18446
+ signal.throwIfAborted();
18447
+ for (let round = 1; ; round += 1) {
18448
+ const roundStart = now();
18449
+ diagnosticEvent("round.start", { id, round, maxRounds: options.maxRounds, elapsedMs: elapsed() });
18450
+ let session;
18451
+ try {
18452
+ session = await options.runRound(round, previous, signal);
18453
+ } catch (error) {
18454
+ diagnosticEvent("round.end", { id, round, reason: signal.aborted ? "aborted" : "exception", elapsedMs: elapsed(), durationMs: Math.max(0, now() - roundStart) });
18455
+ throw error;
18456
+ }
18457
+ const current = progress(session);
18458
+ const unchanged = lastFingerprint !== void 0 && current.fingerprint === lastFingerprint;
18459
+ stalls = unchanged ? stalls + 1 : 0;
18460
+ lastFingerprint = current.fingerprint;
18461
+ const classification = session.stopReason === "error" ? options.classifyFailure?.(session) : void 0;
18462
+ consecutiveErrors = session.stopReason === "error" ? consecutiveErrors + 1 : 0;
18463
+ let haltReason;
18464
+ if (session.stopReason === "completed") haltReason = "completed";
18465
+ else if (budget.signal.aborted || elapsed() >= maxElapsedMs) haltReason = "elapsed_budget";
18466
+ else if (signal.aborted || session.stopReason === "aborted") haltReason = "aborted";
18467
+ else if (classification && !classification.retryable) haltReason = "permanent_error";
18468
+ else if (!shouldResume(session.stopReason)) haltReason = "stopped";
18469
+ else if (stalls >= stallLimit) haltReason = "stalled";
18470
+ else if (round >= options.maxRounds) haltReason = "max_rounds";
18471
+ const retryDelay = Math.min(maxDelay, baseDelay * 2 ** Math.min(30, consecutiveErrors - 1));
18472
+ if (!haltReason && session.stopReason === "error" && elapsed() + retryDelay >= maxElapsedMs) haltReason = "elapsed_budget";
18473
+ diagnosticEvent("round.end", {
18474
+ id,
18475
+ round,
18476
+ sessionId: session.id,
18477
+ reason: haltReason ?? session.stopReason,
18478
+ failureKind: classification?.kind,
18479
+ attempt: consecutiveErrors,
18480
+ completedTools: current.completedTools,
18481
+ elapsedMs: elapsed(),
18482
+ durationMs: Math.max(0, now() - roundStart)
18483
+ });
18484
+ if (haltReason === "stalled") diagnosticEvent("round.stall", { id, round, repeats: stalls, completedTools: current.completedTools, elapsedMs: elapsed() });
18485
+ await options.onRoundEnd?.(round, session, !haltReason);
18486
+ if (haltReason) return { session, rounds: round, haltReason };
18487
+ if (session.stopReason === "error") {
18488
+ const delayMs = retryDelay;
18489
+ if (elapsed() + delayMs >= maxElapsedMs) return { session, rounds: round, haltReason: "elapsed_budget" };
18490
+ diagnosticEvent("round.retry", {
18491
+ id,
18492
+ round,
18493
+ nextRound: round + 1,
18494
+ attempt: consecutiveErrors,
18495
+ failureKind: classification?.kind ?? "unclassified",
18496
+ delayMs,
18497
+ elapsedMs: elapsed()
18498
+ });
18499
+ try {
18500
+ await sleep2(delayMs, signal);
18501
+ } catch (error) {
18502
+ if (!signal.aborted) throw error;
18503
+ return { session, rounds: round, haltReason: budget.signal.aborted ? "elapsed_budget" : "aborted" };
18504
+ }
18505
+ }
18506
+ if (signal.aborted || elapsed() >= maxElapsedMs) return { session, rounds: round, haltReason: budget.signal.aborted || elapsed() >= maxElapsedMs ? "elapsed_budget" : "aborted" };
18507
+ previous = session;
18508
+ }
18509
+ } finally {
18510
+ clearTimeout(deadline);
17966
18511
  }
17967
18512
  }
17968
18513
 
@@ -17975,7 +18520,6 @@ function parsePositiveInt(value, flag) {
17975
18520
  return parsed;
17976
18521
  }
17977
18522
  var EXIT_INCOMPLETE_RUN = 2;
17978
- var AUTONOMOUS_ERROR_BACKOFF_MS = 15e3;
17979
18523
  var NOTIFY_SUMMARY_CHARS = 1200;
17980
18524
  function registerRunCommand(program2) {
17981
18525
  program2.command("run").alias("chat").description("Run a one-shot coding agent task").argument("<task...>", "Task for the agent").option("-w, --workspace <path>", "Workspace path", ".").option("--provider <name>", "Provider: ultrac (default) or echo (offline test stub)").option("--model <model>", "Model name").option("--base-url <url>", "Ultrac API base URL (overrides ULTRAC_BASE_URL and the stored login)").option("--mode <name>", `Agent mode: ${agentModeNames.join(", ")}`).option("--max-steps <number>", "Maximum agent steps").option("--no-native-tools", "Use the legacy JSON tool protocol").option("--allow-write", "Allow file writes", false).option("--allow-shell", "Grant the model broad, unsandboxed shell access", false).option("--allow-network", "Allow HTTP(S) URL fetches", false).option("--continue <id>", "Continue from a saved session id").option("--tools <list>", "Tools to advertise: `all`, or a comma-separated list. Default: the mode's set").option("--context-tokens <number>", "Model context window in tokens (default 262144)").option("--max-tool-result-chars <number>", "Cap on one tool result in the transcript (default 6000)").option(
@@ -17986,7 +18530,7 @@ function registerRunCommand(program2) {
17986
18530
  "--autonomous",
17987
18531
  `Run with no human in the loop: write, shell and network are granted, each round may take up to ${AUTONOMOUS_MAX_STEPS} steps, and a round that runs out of steps or fails resumes on its own. With a phone channel configured (\`ultrac notify setup\`), questions go to your phone and you are told when it finishes.`,
17988
18532
  false
17989
- ).option("--max-rounds <number>", `With --autonomous: how many times a run may resume itself (default ${DEFAULT_AUTONOMOUS_MAX_ROUNDS})`).option("--notify", "Send ask_user questions and the final result to your phone (see `ultrac notify setup`)", false).option("--json", "Print the session JSON", false).action(async (taskParts, options) => {
18533
+ ).option("--max-rounds <number>", `With --autonomous: how many times a run may resume itself (default ${DEFAULT_AUTONOMOUS_MAX_ROUNDS})`).option("--notify", "Send ask_user questions and the final result to your phone (see `ultrac notify setup`)", false).option("--verbose-tools", "Print complete tool results instead of previews", false).option("--json", "Print the session JSON", false).action(async (taskParts, options) => {
17990
18534
  const task = taskParts.join(" ").trim();
17991
18535
  if (!task) {
17992
18536
  throw new Error("A task is required.");
@@ -18100,14 +18644,17 @@ function registerRunCommand(program2) {
18100
18644
  shell: options.allowShell || config.permissions.shell,
18101
18645
  network: options.allowNetwork || config.permissions.network
18102
18646
  };
18647
+ const roundFailures = /* @__PURE__ */ new Map();
18103
18648
  let result;
18104
18649
  try {
18105
18650
  result = await runAutonomousLoop({
18106
18651
  maxRounds,
18107
18652
  signal: controller.signal,
18108
- runRound: async (round, previous) => {
18653
+ classifyFailure: (session) => roundFailures.get(session.id),
18654
+ runRound: async (round, previous, roundSignal) => {
18109
18655
  const lineage = previous ? await loadSessionLineage(workspace, previous.id) : previousSessionLineage;
18110
18656
  let lastCheckpoint;
18657
+ let checkpointFailed = false;
18111
18658
  try {
18112
18659
  const { session } = await runner.run({
18113
18660
  task: previous ? AUTONOMOUS_CONTINUE_TASK : task,
@@ -18121,22 +18668,30 @@ function registerRunCommand(program2) {
18121
18668
  previousSessionLineage: lineage,
18122
18669
  ...toolSelection ? { tools: toolSelection } : {},
18123
18670
  permissions,
18124
- signal: controller.signal,
18671
+ signal: roundSignal,
18125
18672
  ...autonomous ? { autonomous: { phone: Boolean(notifier) } } : {},
18126
18673
  // Write the session after every step, atomically, so a run that dies
18127
18674
  // partway - a crash, a kill, a closed laptop mid-scan - is on disk up to
18128
18675
  // its last completed step and can be resumed with --continue rather than
18129
18676
  // restarted from nothing.
18130
18677
  onCheckpoint: async (session2) => {
18131
- lastCheckpoint = session2;
18132
- await writeCheckpoint(session2);
18678
+ try {
18679
+ const checkpointPath = await writeCheckpoint(session2);
18680
+ checkpointFailed = !checkpointPath;
18681
+ if (checkpointPath) lastCheckpoint = await loadSession(workspace, session2.id);
18682
+ } catch (error) {
18683
+ checkpointFailed = true;
18684
+ throw error;
18685
+ }
18133
18686
  }
18134
18687
  });
18135
18688
  return session;
18136
18689
  } catch (error) {
18137
- if (!autonomous || !lastCheckpoint || controller.signal.aborted) throw error;
18690
+ if (!autonomous || !lastCheckpoint || checkpointFailed || roundSignal.aborted) throw error;
18691
+ const classification = classifyProviderError(error);
18692
+ roundFailures.set(lastCheckpoint.id, classification);
18138
18693
  console.error(
18139
- chalk12.yellow(`Round ${round} failed: ${error.message}. Resuming from its last checkpoint.`)
18694
+ chalk12.yellow(`Round ${round} failed (${classification.kind}). The latest checkpoint is saved.`)
18140
18695
  );
18141
18696
  return { ...lastCheckpoint, stopReason: "error" };
18142
18697
  }
@@ -18149,7 +18704,6 @@ function registerRunCommand(program2) {
18149
18704
  `Round ${round} stopped (${session.stopReason}); resuming in round ${round + 1} of ${maxRounds}.`
18150
18705
  )
18151
18706
  );
18152
- if (session.stopReason === "error") await sleep(AUTONOMOUS_ERROR_BACKOFF_MS, controller.signal);
18153
18707
  }
18154
18708
  });
18155
18709
  } catch (error) {
@@ -18170,16 +18724,19 @@ function registerRunCommand(program2) {
18170
18724
  { cause: error }
18171
18725
  );
18172
18726
  }
18727
+ if (["stalled", "elapsed_budget", "permanent_error"].includes(result.haltReason ?? "")) {
18728
+ console.error(chalk12.yellow(`Autonomous recovery stopped: ${result.haltReason}. The latest checkpoint is saved.`));
18729
+ }
18173
18730
  const stopReason = result.session.stopReason;
18174
18731
  for (const warning of getSessionMcpArchiveWarnings(result.session)) {
18175
18732
  console.error(chalk12.yellow(`Warning: ${warning}`));
18176
18733
  }
18177
18734
  if (options.json) {
18178
18735
  console.log(
18179
- JSON.stringify({ sessionPath: sessionPath2, stopReason: stopReason ?? null, session: result.session }, null, 2)
18736
+ JSON.stringify({ sessionPath: sessionPath2, stopReason: stopReason ?? null, haltReason: result.haltReason ?? null, rounds: result.rounds, session: result.session }, null, 2)
18180
18737
  );
18181
18738
  } else {
18182
- printSession(result.session, sessionPath2);
18739
+ printSession(result.session, sessionPath2, { verboseTools: options.verboseTools });
18183
18740
  }
18184
18741
  if (notifier) {
18185
18742
  const outcome = stopReason === "completed" ? "finished" : `stopped without finishing (${stopReason ?? "unknown"})`;
@@ -18433,17 +18990,25 @@ function registerSkillsCommand(program2) {
18433
18990
  console.log("");
18434
18991
  console.log(skill.body);
18435
18992
  });
18436
- skills.command("create").description("Scaffold a new SKILL.md").argument("<name>", "Skill name (kebab-case)").requiredOption("--description <text>", "One-line description").option("--when <text>", "When the agent should use this skill").option("--permissions <list>", "Comma-separated: write,shell,network").option("--scope <scope>", "global or project", "project").option("-w, --workspace <path>", "Workspace path", ".").action(
18993
+ skills.command("create").description("Scaffold a new SKILL.md").argument("<name>", "Skill name (kebab-case)").requiredOption("--description <text>", "One-line description").option("--when <text>", "When the agent should use this skill").option("--permissions <list>", "Comma-separated: write,shell,network").option("--scope <scope>", "global or project", "project").option("--command <name>", "Expose this skill as a custom slash command").option("--argument-hint <text>", "Describe the slash command arguments").option("-w, --workspace <path>", "Workspace path", ".").action(
18437
18994
  async (name, options) => {
18995
+ const command = options.command ? normalizeCommandName(options.command) : void 0;
18996
+ if (command && isBuiltinSlashCommand(command)) throw new Error(`/${command} is a built-in command. Choose another skill command name.`);
18997
+ if (options.argumentHint && !command) throw new Error("--argument-hint requires --command.");
18998
+ const argumentHint = options.argumentHint?.trim();
18999
+ if (argumentHint && (argumentHint.length > 256 || /[\r\n]/.test(argumentHint))) throw new Error("Argument hints must be one line, at most 256 characters.");
18438
19000
  const skill = await createSkill(resolve13(options.workspace), {
18439
19001
  name,
18440
19002
  description: options.description,
18441
19003
  whenToUse: options.when,
18442
19004
  permissions: parsePermissions(options.permissions),
18443
- scope: parseScope2(options.scope)
19005
+ scope: parseScope2(options.scope),
19006
+ command,
19007
+ argumentHint
18444
19008
  });
18445
19009
  console.log(`Created ${skill.scope} skill "${skill.name}" at ${skill.path}`);
18446
19010
  console.log("Edit that file to add the step-by-step instructions.");
19011
+ if (command) console.log(`Invoke this skill with /${command}${argumentHint ? ` ${argumentHint}` : ""} in a trusted workspace.`);
18447
19012
  }
18448
19013
  );
18449
19014
  skills.command("path").description("Print the skills directory path for a scope").option("-w, --workspace <path>", "Workspace path", ".").option("--scope <scope>", "global or project", "project").action((options) => {
@@ -19384,7 +19949,7 @@ ${tools.length} of ${all.length} tools advertised in ${options.mode} mode, ${ser
19384
19949
  );
19385
19950
  }
19386
19951
  });
19387
- toolsCommand.command("run").description("Execute one built-in agent tool").argument("<tool>", "Tool name").argument("[input]", "JSON object input", "{}").option("-w, --workspace <path>", "Workspace path", ".").option("--allow-write", "Allow file writes", false).option("--allow-shell", "Allow this tool to execute unsandboxed shell commands", false).option("--allow-network", "Allow HTTP(S) URL fetches", false).option("--result-json", "Print the tool result as JSON", false).action(async (tool, inputText, options) => {
19952
+ toolsCommand.command("run").description("Execute one built-in agent tool").argument("<tool>", "Tool name").argument("[input]", "JSON object input", "{}").option("-w, --workspace <path>", "Workspace path", ".").option("--allow-write", "Allow file writes", false).option("--allow-shell", "Allow this tool to execute unsandboxed shell commands", false).option("--allow-network", "Allow HTTP(S) URL fetches", false).option("--verbose-tools", "Print the complete tool result", false).option("--result-json", "Print the tool result as JSON", false).action(async (tool, inputText, options) => {
19388
19953
  const input = parseToolInput(inputText);
19389
19954
  const result = await createDefaultToolRegistry().execute(
19390
19955
  { tool, input },
@@ -19400,7 +19965,7 @@ ${tools.length} of ${all.length} tools advertised in ${options.mode} mode, ${ser
19400
19965
  if (options.resultJson) {
19401
19966
  console.log(JSON.stringify({ result }, null, 2));
19402
19967
  } else {
19403
- printToolResult(result);
19968
+ printToolResult(result, { verboseTools: options.verboseTools });
19404
19969
  }
19405
19970
  if (!result.ok) {
19406
19971
  process.exitCode = 1;