@spinalfluid/ultrac 0.1.14 → 0.1.20

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 +29 -4
  2. package/dist/cli.js +424 -137
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -143,9 +143,12 @@ Inside a session:
143
143
  - Type to talk to Ultrac; it inspects the repository with tools as needed.
144
144
  - On supported terminals, the full-width input box stays at the bottom while tools and replies
145
145
  run above it. The footer shows only the current model. `/usage` and `/context` show token and context details.
146
- Thinking and tool activity lines show their own live elapsed timers and retain the measured
147
- duration when finished. Sub-second durations are hidden. Tool actions stay in a collapsed activity group;
148
- click it or press Ctrl+O to expand, then click an action for command and output details. Green and red
146
+ Working and tool activity lines show their own live elapsed timers and retain the measured
147
+ duration when finished. Sub-second durations are hidden. Tool calls stay in a collapsed group directly below the live working timer;
148
+ click it or press Ctrl+O to expand, then click an action for command and output details. When the
149
+ task ends, the group moves below its result in the transcript. Working time accumulates across
150
+ model calls and continuation rounds. Long-running tasks emit brief progress updates based on
151
+ completed calls and the active tool or model wait. Green and red
149
152
  dots show success and failure. Use `/output` for full saved results. Slash suggestions use aligned
150
153
  command and description columns on the terminal background.
151
154
  - Type `/` to browse built-in commands and available skills. Type to filter, use arrow keys
@@ -167,7 +170,8 @@ Inside a session:
167
170
  - Gated tools (file writes, shell, network) prompt for approval: `once` / `always` / `no`.
168
171
  - `/mcp` shows the connected servers in this session; `/doctor` checks local setup.
169
172
  - `/continue [focus]` continues an unfinished task using its current transcript. `/autonomous on`
170
- enables up to five rounds when a task reaches its step ceiling. Checkpoints, a six-hour elapsed
173
+ allows up to five rounds when a task reaches its step ceiling (enabled by default; `/autonomous off`
174
+ disables continuation). Checkpoints, a six-hour elapsed
171
175
  limit, stall detection, and Esc cancellation remain active. `/autonomous off` restores one round.
172
176
  Failed rounds are not automatically replayed; provider retry/failover handles request failures.
173
177
  - The agent can use `create_skill` in default/coding modes (or with `--tools all`) to create a
@@ -750,3 +754,24 @@ ultrac skills create project-check --description "Run project checks" --command
750
754
  Edit the generated `SKILL.md` instructions, approve the project workspace, and invoke
751
755
  `/project-check` in the terminal. The agent can also write a new `SKILL.md` with its file tools and load it with
752
756
  `skill`; normal write and tool permissions still apply.
757
+
758
+
759
+ Tool approval presets can be chosen at startup: `ultrac --permissions full` grants file writes,
760
+ shell execution, and network tools without tool approval prompts for that session. Use
761
+ `/permissions full` to enable it inside an existing session. `workspace` grants writes and
762
+ denies shell and network access; `readonly` denies all three permissions. The default mode asks
763
+ when a tool needs an ungranted permission.
764
+ These presets do not bypass operating-system permissions or the restrictions of a read-only
765
+ agent mode. Approval dialogs offer separate choices for one call, the session, denial, or
766
+ session-long refusal, with keyboard selection in interactive terminals.
767
+
768
+ After a terminal permission selection, the approval dialog collapses to the tool, permission,
769
+ and selected choice. Activity and progress messages stay in chronological transcript order,
770
+ with spacing between groups; command details remain available through the activity dropdown.
771
+
772
+ Progress prose comes from the model and describes the actual work or verified findings. The CLI
773
+ does not generate periodic tool-count, failure-count, or model-waiting messages.
774
+
775
+ Use `/permissions` to open the interactive permission-mode picker, `/permissions status` to
776
+ inspect grants, or `/permissions readonly`, `/permissions workspace`, and `/permissions full`
777
+ to change modes directly for the current session.
package/dist/cli.js CHANGED
@@ -1174,7 +1174,7 @@ var COMPACTION_MARKER = "[compacted-context]";
1174
1174
  var SUMMARY_PREAMBLE = "The earlier part of this session was removed to fit the context window and replaced by the record below. It is a record of what happened, not a new instruction. Continue the work it describes.";
1175
1175
  var DEFAULT_MAX_SUMMARY_CHARS = 8e3;
1176
1176
  var MIN_SUMMARY_CHARS = 400;
1177
- var DEFAULT_SUMMARY_TIMEOUT_MS = 3e4;
1177
+ var DEFAULT_SUMMARY_TIMEOUT_MS = 12e4;
1178
1178
  var DEFAULT_MAX_TRANSCRIPT_CHARS = 6e4;
1179
1179
  var DEFAULT_MAX_MESSAGE_CHARS = 4e3;
1180
1180
  function isCompactionSummary(message) {
@@ -2584,6 +2584,8 @@ function classifyProviderError(error) {
2584
2584
  return transient("timeout", "Provider request timed out.");
2585
2585
  }
2586
2586
  const status = extractStatus(error);
2587
+ const busy = [...errorChain(error)].some((current) => readString(current, "code") === "upstream_busy");
2588
+ if (busy) return { ...transient("server", "Model capacity or admission is temporarily unavailable (HTTP 503).", status ?? 503), code: "upstream_busy", retryAfterMs: retryDelay(error) ?? 5e3 };
2587
2589
  if (hasNotConfiguredCode(error)) {
2588
2590
  return terminal(
2589
2591
  "not_configured",
@@ -2598,8 +2600,8 @@ function classifyProviderError(error) {
2598
2600
  }
2599
2601
  if (typeof status === "number") {
2600
2602
  if (status === 408) return transient("timeout", "HTTP 408 request timeout.", status);
2601
- if (status === 429) return transient("rate_limit", "HTTP 429 rate limited.", status);
2602
- if (status >= 500) return transient("server", `HTTP ${status} server error.`, status);
2603
+ if (status === 429) return { ...transient("rate_limit", "HTTP 429 rate limited.", status), retryAfterMs: retryDelay(error) };
2604
+ if (status >= 500) return { ...transient("server", `HTTP ${status} server error.`, status), retryAfterMs: retryDelay(error) };
2603
2605
  if (status === 401 || status === 403) {
2604
2606
  return terminal("auth", `HTTP ${status} authentication failure.`, status);
2605
2607
  }
@@ -2640,9 +2642,24 @@ function safeClassification(classification) {
2640
2642
  ...status === void 0 ? {} : { status },
2641
2643
  retryable: classification.retryable,
2642
2644
  failoverable: classification.failoverable,
2643
- reason
2645
+ reason: classification.code === "upstream_busy" ? "Model capacity or admission is temporarily unavailable (HTTP 503)." : reason,
2646
+ ...classification.code ? { code: classification.code } : {},
2647
+ ...classification.retryAfterMs === void 0 ? {} : { retryAfterMs: classification.retryAfterMs }
2644
2648
  };
2645
2649
  }
2650
+ function retryDelay(error) {
2651
+ for (const current of errorChain(error)) {
2652
+ if (!isRecord2(current)) continue;
2653
+ const headers = current.headers;
2654
+ if (typeof headers?.get !== "function") continue;
2655
+ const raw = headers.get("retry-after");
2656
+ if (!raw) continue;
2657
+ const seconds = Number(raw);
2658
+ const ms = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(raw) - Date.now();
2659
+ if (Number.isFinite(ms) && ms >= 0) return Math.min(ms, 3e5);
2660
+ }
2661
+ return void 0;
2662
+ }
2646
2663
  function classificationReason(kind, status) {
2647
2664
  switch (kind) {
2648
2665
  case "aborted":
@@ -3043,7 +3060,7 @@ var FallbackProvider = class {
3043
3060
  }
3044
3061
  }
3045
3062
  async waitBeforeRetry(operation, target, attempt, classification, signal) {
3046
- const delayMs = computeBackoffDelay(attempt, this.backoff);
3063
+ const delayMs = Math.max(computeBackoffDelay(attempt, this.backoff), classification.retryAfterMs ?? 0);
3047
3064
  const safe = safeClassification(classification);
3048
3065
  this.onRetry?.({
3049
3066
  operation,
@@ -10817,6 +10834,8 @@ ${mode.instructions.map((instruction) => `- ${instruction}`).join("\n")}
10817
10834
  ${autonomyLines}
10818
10835
  ${UNTRUSTED_CONTENT_GUIDANCE}
10819
10836
 
10837
+ For long tasks, occasionally give a brief user-facing update about the current work or verified findings. Avoid repetitive updates, tool-call counts, failure counts, and boilerplate about waiting for the model. Do not invent findings or claim work that has not happened.
10838
+
10820
10839
  ${nativeTools ? "Write complete code files or append complete sections across tool calls for large implementations. Verify files with tests or a compiler. Never replace requested code with placeholders or ellipses. Keep final explanations concise. Use the provided native function tools for actions. Do not put a JSON tool-call protocol in your answer. When the requested work is complete, give the final answer without calling more tools." : `Respond only with a JSON object:
10821
10840
  {
10822
10841
  "message": "short user-facing status or final answer",
@@ -16646,6 +16665,9 @@ ${context}`
16646
16665
  this.userConstraints.push({ kind: "message", content: input });
16647
16666
  let finalMessage = "";
16648
16667
  let toolCallsUsed = 0;
16668
+ let consecutiveEmptyResponses = 0;
16669
+ const turnStarted = performance.now();
16670
+ const firstStep = this.steps.length;
16649
16671
  let stopReason = "max-steps";
16650
16672
  try {
16651
16673
  for (let index = 0; index < this.options.maxSteps; index += 1) {
@@ -16698,6 +16720,7 @@ ${context}`
16698
16720
  throw budgetError;
16699
16721
  }
16700
16722
  toolCallsUsed += response.toolCalls.length;
16723
+ if (response.content.trim() || response.toolCalls.length) consecutiveEmptyResponses = 0;
16701
16724
  if (response.content.trim()) {
16702
16725
  finalMessage = response.content;
16703
16726
  handlers.onAssistantMessage?.(response.content);
@@ -16717,6 +16740,21 @@ ${context}`
16717
16740
  handlers.onInterrupted?.();
16718
16741
  break;
16719
16742
  }
16743
+ if (!response.content.trim()) {
16744
+ consecutiveEmptyResponses += 1;
16745
+ const willRetry = consecutiveEmptyResponses <= 2 && index + 1 < this.options.maxSteps;
16746
+ diagnosticEvent("session.empty-response", {
16747
+ sessionId: this.id,
16748
+ attempt: consecutiveEmptyResponses,
16749
+ willRetry,
16750
+ maxRecoveries: 2,
16751
+ stepCount: this.steps.length
16752
+ });
16753
+ if (!willRetry) {
16754
+ throw Object.assign(new Error("The model returned an empty response without completing the task. Empty-response recovery was exhausted; the session checkpoint is preserved."), { status: 422 });
16755
+ }
16756
+ continue;
16757
+ }
16720
16758
  if (this.appendQueuedMessages(handlers) === 0) {
16721
16759
  stopReason = "completed";
16722
16760
  break;
@@ -16784,6 +16822,15 @@ ${context}`
16784
16822
  throw error;
16785
16823
  } finally {
16786
16824
  this.lastStopReason = stopReason;
16825
+ diagnosticEvent("session.turn.end", {
16826
+ sessionId: this.id,
16827
+ reason: stopReason,
16828
+ stepCount: this.steps.length,
16829
+ turnSteps: this.steps.length - firstStep,
16830
+ maxSteps: this.options.maxSteps,
16831
+ emptyResponses: consecutiveEmptyResponses,
16832
+ durationMs: Math.max(0, performance.now() - turnStarted)
16833
+ });
16787
16834
  this.subagents.push(...drainSubagentTranscripts());
16788
16835
  }
16789
16836
  handlers.onStopped?.(stopReason);
@@ -16960,6 +17007,8 @@ ${mode.instructions.map((instruction) => `- ${instruction}`).join("\n")}
16960
17007
 
16961
17008
  ${UNTRUSTED_CONTENT_GUIDANCE}
16962
17009
 
17010
+ For long tasks, occasionally give a brief user-facing update about the current work or verified findings. Avoid repetitive updates, tool-call counts, failure counts, and boilerplate about waiting for the model. Do not invent findings or claim work that has not happened.
17011
+
16963
17012
  For large code generation, write complete files or append complete sections across multiple tool calls. Verify the files with the project tests or compiler. Never use ellipses or placeholders for requested implementation, and do not declare success with unfinished code. Keep final explanations concise; code completeness takes priority over response brevity.
16964
17013
 
16965
17014
  Use the provided tools to inspect and change the workspace. Prefer narrow, reversible changes and
@@ -17087,6 +17136,11 @@ async function runAutonomousLoop(options) {
17087
17136
  let lastStepCount = 0;
17088
17137
  let stalls = 0;
17089
17138
  let consecutiveErrors = 0;
17139
+ const finish = (session, rounds, haltReason) => {
17140
+ diagnosticEvent("autonomous.end", { id, sessionId: session.id, rounds, reason: haltReason, elapsedMs: elapsed() });
17141
+ return { session, rounds, haltReason };
17142
+ };
17143
+ diagnosticEvent("autonomous.start", { id, maxRounds: options.maxRounds, maxElapsedMs });
17090
17144
  try {
17091
17145
  signal.throwIfAborted();
17092
17146
  for (let round = 1; ; round += 1) {
@@ -17117,8 +17171,8 @@ async function runAutonomousLoop(options) {
17117
17171
  else if (!shouldResume(session.stopReason)) haltReason = "stopped";
17118
17172
  else if (stalls >= stallLimit) haltReason = "stalled";
17119
17173
  else if (round >= options.maxRounds) haltReason = "max_rounds";
17120
- const retryDelay = Math.min(maxDelay, baseDelay * 2 ** Math.min(30, consecutiveErrors - 1));
17121
- if (!haltReason && session.stopReason === "error" && elapsed() + retryDelay >= maxElapsedMs) haltReason = "elapsed_budget";
17174
+ const retryDelay2 = Math.min(maxDelay, baseDelay * 2 ** Math.min(30, consecutiveErrors - 1));
17175
+ if (!haltReason && session.stopReason === "error" && elapsed() + retryDelay2 >= maxElapsedMs) haltReason = "elapsed_budget";
17122
17176
  diagnosticEvent("round.end", {
17123
17177
  id,
17124
17178
  round,
@@ -17132,10 +17186,10 @@ async function runAutonomousLoop(options) {
17132
17186
  });
17133
17187
  if (haltReason === "stalled") diagnosticEvent("round.stall", { id, round, repeats: stalls, completedTools: current.completedTools, elapsedMs: elapsed() });
17134
17188
  await options.onRoundEnd?.(round, session, !haltReason);
17135
- if (haltReason) return { session, rounds: round, haltReason };
17189
+ if (haltReason) return finish(session, round, haltReason);
17136
17190
  if (session.stopReason === "error") {
17137
- const delayMs = retryDelay;
17138
- if (elapsed() + delayMs >= maxElapsedMs) return { session, rounds: round, haltReason: "elapsed_budget" };
17191
+ const delayMs = retryDelay2;
17192
+ if (elapsed() + delayMs >= maxElapsedMs) return finish(session, round, "elapsed_budget");
17139
17193
  diagnosticEvent("round.retry", {
17140
17194
  id,
17141
17195
  round,
@@ -17149,12 +17203,15 @@ async function runAutonomousLoop(options) {
17149
17203
  await sleep2(delayMs, signal);
17150
17204
  } catch (error) {
17151
17205
  if (!signal.aborted) throw error;
17152
- return { session, rounds: round, haltReason: budget.signal.aborted ? "elapsed_budget" : "aborted" };
17206
+ return finish(session, round, budget.signal.aborted ? "elapsed_budget" : "aborted");
17153
17207
  }
17154
17208
  }
17155
- if (signal.aborted || elapsed() >= maxElapsedMs) return { session, rounds: round, haltReason: budget.signal.aborted || elapsed() >= maxElapsedMs ? "elapsed_budget" : "aborted" };
17209
+ if (signal.aborted || elapsed() >= maxElapsedMs) return finish(session, round, budget.signal.aborted || elapsed() >= maxElapsedMs ? "elapsed_budget" : "aborted");
17156
17210
  previous = session;
17157
17211
  }
17212
+ } catch (error) {
17213
+ diagnosticEvent("autonomous.end", { id, reason: budget.signal.aborted ? "elapsed_budget" : signal.aborted ? "aborted" : "exception", elapsedMs: elapsed() });
17214
+ throw error;
17158
17215
  } finally {
17159
17216
  clearTimeout(deadline);
17160
17217
  }
@@ -17442,7 +17499,9 @@ var VERBS = {
17442
17499
  run_tests: "Run tests",
17443
17500
  diagnostics: "Check diagnostics",
17444
17501
  task: "Delegate",
17445
- fetch_url: "Fetch",
17502
+ fetch_url: "Opened",
17503
+ web_search: "Searched the web for",
17504
+ search_web: "Searched the web for",
17446
17505
  git_status: "Check Git status",
17447
17506
  git_diff: "Read Git diff",
17448
17507
  git_log: "Read Git log",
@@ -17470,7 +17529,7 @@ function toolTarget(call) {
17470
17529
  if (paths.length > 1) return `${paths.length} files`;
17471
17530
  if (call.tool === "run_shell") return compact2(stringValue(input.command), 72);
17472
17531
  if (call.tool === "run_package_script") return compact2(stringValue(input.script), 50);
17473
- if (call.tool === "search_files") return quote(stringValue(input.query) || stringValue(input.pattern));
17532
+ if (["search_files", "web_search", "search_web"].includes(call.tool)) return quote(stringValue(input.query) || stringValue(input.pattern));
17474
17533
  if (call.tool === "fetch_url") return compact2(stringValue(input.url), 72);
17475
17534
  if (call.tool === "task") return compact2(stringValue(input.description) || stringValue(input.task), 64);
17476
17535
  if (call.tool === "git_show") return compact2(stringValue(input.ref), 40);
@@ -17499,10 +17558,12 @@ function humanize(value) {
17499
17558
  import { stdout as stdout4 } from "process";
17500
17559
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
17501
17560
  var Spinner = class {
17502
- constructor(label = "thinking") {
17561
+ constructor(label = "working", renderLine) {
17503
17562
  this.label = label;
17563
+ this.renderLine = renderLine;
17504
17564
  }
17505
17565
  label;
17566
+ renderLine;
17506
17567
  timer;
17507
17568
  index = 0;
17508
17569
  holdWhile;
@@ -17528,8 +17589,11 @@ var Spinner = class {
17528
17589
  this.timer = void 0;
17529
17590
  }
17530
17591
  if (stdout4.isTTY && elapsed !== void 0) {
17531
- stdout4.clearLine(0);
17532
- stdout4.cursorTo(0);
17592
+ if (this.renderLine) this.renderLine("");
17593
+ else {
17594
+ stdout4.clearLine(0);
17595
+ stdout4.cursorTo(0);
17596
+ }
17533
17597
  if (completion && elapsed >= 1e3) stdout4.write(` ${completion} ${formatSeconds(elapsed)}.
17534
17598
  `);
17535
17599
  }
@@ -17537,16 +17601,21 @@ var Spinner = class {
17537
17601
  render() {
17538
17602
  if (this.holdWhile?.()) {
17539
17603
  if (!this.held) {
17540
- stdout4.clearLine(0);
17541
- stdout4.cursorTo(0);
17604
+ if (this.renderLine) this.renderLine("");
17605
+ else {
17606
+ stdout4.clearLine(0);
17607
+ stdout4.cursorTo(0);
17608
+ }
17542
17609
  this.held = true;
17543
17610
  }
17544
17611
  return;
17545
17612
  }
17546
17613
  this.held = false;
17547
17614
  this.index = (this.index + 1) % FRAMES.length;
17548
- stdout4.clearLine(0);
17549
- stdout4.cursorTo(0);
17615
+ if (!this.renderLine) {
17616
+ stdout4.clearLine(0);
17617
+ stdout4.cursorTo(0);
17618
+ }
17550
17619
  const milliseconds = performance.now() - (this.startedAt ?? performance.now());
17551
17620
  const elapsed = milliseconds >= 1e3 ? formatElapsed(milliseconds) : "";
17552
17621
  const limit = Math.max(0, (stdout4.columns ?? 80) - elapsed.length - 10);
@@ -17558,7 +17627,9 @@ var Spinner = class {
17558
17627
  label += character;
17559
17628
  cells2 += size;
17560
17629
  }
17561
- stdout4.write(` ${FRAMES[this.index]} ${label}\u2026${elapsed ? ` \xB7 ${elapsed}` : ""}`);
17630
+ const line = ` ${FRAMES[this.index]} ${label}\u2026${elapsed ? ` \xB7 ${elapsed}` : ""}`;
17631
+ if (this.renderLine) this.renderLine(line);
17632
+ else stdout4.write(line);
17562
17633
  }
17563
17634
  };
17564
17635
  function formatSeconds(milliseconds) {
@@ -17569,6 +17640,23 @@ function formatSeconds(milliseconds) {
17569
17640
 
17570
17641
  // src/ui/activity.ts
17571
17642
  var sink;
17643
+ var messageSink;
17644
+ function setActivityMessageSink(value) {
17645
+ messageSink = value;
17646
+ }
17647
+ function writeActivityMessage(text) {
17648
+ if (messageSink) messageSink(text);
17649
+ else console.log(`
17650
+ ${text}
17651
+ `);
17652
+ }
17653
+ var workingSink;
17654
+ function setWorkingSink(value) {
17655
+ workingSink = value;
17656
+ }
17657
+ function getWorkingSink() {
17658
+ return workingSink;
17659
+ }
17572
17660
  function setActivitySink(value) {
17573
17661
  sink = value;
17574
17662
  }
@@ -17592,10 +17680,16 @@ function activityEntry(call, result, durationMs) {
17592
17680
  }
17593
17681
  function activityRows(snapshot, expanded, selected, end) {
17594
17682
  const failures = snapshot.entries.filter((entry) => !entry.ok).length;
17595
- const elapsed = snapshot.thinkingMs >= 1e3 ? ` \xB7 Thought for ${formatSeconds(snapshot.thinkingMs)}` : "";
17596
- const summary = `${expanded ? "\u25BE" : "\u25B8"} \u25CF ${snapshot.entries.length} actions${failures ? ` \xB7 ${failures} failed` : ""}${snapshot.finished ? elapsed : ""} \xB7 Ctrl+O details`;
17683
+ const elapsed = snapshot.thinkingMs >= 1e3 ? ` \xB7 Worked for ${formatSeconds(snapshot.thinkingMs)}` : "";
17684
+ const summary = `${expanded ? "\u25BE" : "\u25B8"} \u25CF ${snapshot.entries.length} ${snapshot.entries.length === 1 ? "tool call" : "tool calls"}${failures ? ` \xB7 ${failures} failed` : ""}${snapshot.finished ? elapsed : ""} \xB7 Ctrl+O details`;
17597
17685
  const rows = [{ text: summary, ok: failures || snapshot.failed ? false : snapshot.finished ? true : void 0 }];
17598
- if (!expanded) return rows;
17686
+ if (!expanded) {
17687
+ if (!snapshot.finished || snapshot.preview) for (let index = Math.max(0, snapshot.entries.length - 3); index < snapshot.entries.length; index += 1) {
17688
+ const entry = snapshot.entries[index];
17689
+ rows.push({ text: ` \u25CF ${entry.label}`, ok: entry.ok, entry: index });
17690
+ }
17691
+ return rows;
17692
+ }
17599
17693
  const last = Math.min(snapshot.entries.length, end);
17600
17694
  for (let index = Math.max(0, last - 5); index < last; index += 1) {
17601
17695
  const entry = snapshot.entries[index];
@@ -18011,6 +18105,9 @@ var TerminalLayout = class {
18011
18105
  prompt = "\u203A ";
18012
18106
  inputFormatter;
18013
18107
  transcriptRow = 1;
18108
+ transientBlocks = /* @__PURE__ */ new Set();
18109
+ activityOffset = 0;
18110
+ totalActivityEntries = 0;
18014
18111
  transcriptColumn = 1;
18015
18112
  wrapPending = false;
18016
18113
  savedTranscript;
@@ -18018,9 +18115,8 @@ var TerminalLayout = class {
18018
18115
  suggestions = [];
18019
18116
  selected = 0;
18020
18117
  activity;
18021
- activityExpanded = false;
18022
- activitySelected = -1;
18023
- activityEnd = 0;
18118
+ liveActivity;
18119
+ completedActivities = [];
18024
18120
  restoreActivityInput;
18025
18121
  installed = false;
18026
18122
  closed = false;
@@ -18030,6 +18126,7 @@ var TerminalLayout = class {
18030
18126
  resize = () => {
18031
18127
  if (this.closed || !this.enabled) return;
18032
18128
  this.clearPreviousFooter();
18129
+ this.completedActivities = this.completedActivities.filter((item) => item.row >= 1 && item.row + item.height - 1 <= this.contentRows);
18033
18130
  this.transcriptRow = Math.min(this.transcriptRow, this.contentRows);
18034
18131
  this.transcriptColumn = Math.min(this.transcriptColumn, this.columns);
18035
18132
  this.rawWrite(`${CSI}1;${this.contentRows}r`);
@@ -18059,12 +18156,8 @@ var TerminalLayout = class {
18059
18156
  get suggestionRows() {
18060
18157
  return Math.min(8, this.suggestions.length, Math.max(0, this.rows - BASE_FOOTER_ROWS - 2));
18061
18158
  }
18062
- get activityLines() {
18063
- if (!this.activity) return [];
18064
- return activityRows(this.activity, this.activityExpanded, this.activitySelected, this.activityEnd).slice(0, Math.max(1, Math.min(10, this.rows - BASE_FOOTER_ROWS - this.suggestionRows - 2)));
18065
- }
18066
18159
  get contentRows() {
18067
- return this.rows - BASE_FOOTER_ROWS - this.suggestionRows - this.activityLines.length;
18160
+ return this.rows - BASE_FOOTER_ROWS - this.suggestionRows;
18068
18161
  }
18069
18162
  get inputRow() {
18070
18163
  return this.rows - 2;
@@ -18094,7 +18187,7 @@ var TerminalLayout = class {
18094
18187
  let sequence = "";
18095
18188
  let sequenceTimer;
18096
18189
  const wrapper = (text, key) => {
18097
- if (this.activity && !this.suspended && key?.ctrl && key.name === "o") {
18190
+ if ((this.activity || this.completedActivities.length) && !this.suspended && key?.ctrl && key.name === "o") {
18098
18191
  this.toggleActivity();
18099
18192
  return;
18100
18193
  }
@@ -18128,51 +18221,172 @@ var TerminalLayout = class {
18128
18221
  };
18129
18222
  this.paintFooter();
18130
18223
  }
18131
- setActivity(snapshot) {
18132
- this.clearPreviousFooter();
18133
- const previous = this.activity?.entries.length ?? 0;
18134
- if (!snapshot.entries.length && !snapshot.finished) {
18135
- this.activityExpanded = false;
18136
- this.activitySelected = -1;
18137
- }
18138
- this.activity = snapshot;
18139
- if (this.activityEnd >= previous || !previous) this.activityEnd = snapshot.entries.length;
18140
- this.reflowActivity();
18224
+ beginTransientBlock() {
18225
+ const anchor = { row: this.transcriptRow };
18226
+ this.transientBlocks.add(anchor);
18227
+ return anchor;
18228
+ }
18229
+ collapseTransientBlock(anchor, summary) {
18230
+ if (!this.transientBlocks.delete(anchor) || this.closed) return;
18231
+ const start = Math.max(1, Math.min(anchor.row, this.transcriptRow));
18232
+ const removed = Math.max(0, this.transcriptRow - start);
18233
+ if (removed) this.rawWrite(`${cup(start)}${CSI}${removed}M`);
18234
+ this.rawWrite(`${cup(start)}${CSI}2K`);
18235
+ for (const item of this.completedActivities) if (item.row >= start) item.row -= removed;
18236
+ this.completedActivities = this.completedActivities.filter((item) => item.row >= 1);
18237
+ this.transcriptRow = start;
18238
+ this.transcriptColumn = 1;
18239
+ this.wrapPending = false;
18240
+ this.writeTranscript(`${summary}
18241
+
18242
+ `);
18243
+ this.paintFooter();
18141
18244
  }
18142
- toggleActivity() {
18143
- if (!this.activity) return;
18144
- this.clearPreviousFooter();
18145
- this.activityExpanded = !this.activityExpanded;
18146
- this.reflowActivity();
18245
+ writeActivityMessage(text) {
18246
+ const item = this.liveActivity;
18247
+ if (item) {
18248
+ if (item.snapshot.entries.length) {
18249
+ item.snapshot = { ...item.snapshot, finished: true, preview: true };
18250
+ if (item.row >= 2) this.rawWrite(`${cup(item.row - 1)}${CSI}2K`);
18251
+ this.redrawCompleted(item);
18252
+ } else {
18253
+ const start = Math.max(1, item.row - 1);
18254
+ const removed = item.row >= 1 ? Math.max(0, Math.min(item.height + (item.row >= 2 ? 1 : 0), this.transcriptRow - start)) : 0;
18255
+ if (removed) this.rawWrite(`${cup(start)}${CSI}${removed}M`);
18256
+ this.transcriptRow -= removed;
18257
+ this.completedActivities = this.completedActivities.filter((entry) => entry !== item);
18258
+ }
18259
+ this.liveActivity = void 0;
18260
+ }
18261
+ this.activityOffset = this.totalActivityEntries;
18262
+ this.writeTranscript(`
18263
+ ${text}
18264
+
18265
+ `);
18266
+ if (item) {
18267
+ const snapshot = { ...item.snapshot, entries: [], finished: false, preview: false };
18268
+ this.writeTranscript(`
18269
+ ${this.colorActivityRow(activityRows(snapshot, false, -1, 0)[0])}
18270
+ `);
18271
+ this.liveActivity = { snapshot, row: this.transcriptRow - 1, height: 1, expanded: false, selected: -1, end: 0 };
18272
+ this.completedActivities.push(this.liveActivity);
18273
+ }
18147
18274
  }
18148
- reflowActivity() {
18275
+ setActivity(snapshot) {
18276
+ if (!this.liveActivity && snapshot.entries.length < this.totalActivityEntries) this.activityOffset = 0;
18277
+ this.totalActivityEntries = snapshot.entries.length;
18278
+ snapshot = { ...snapshot, entries: snapshot.entries.slice(this.activityOffset) };
18279
+ this.activity = snapshot;
18149
18280
  if (!this.enabled || !this.installed || this.closed) return;
18150
- this.transcriptRow = Math.min(this.transcriptRow, this.contentRows);
18151
- this.rawWrite(`${CSI}1;${this.contentRows}r`);
18281
+ if (!this.liveActivity && !snapshot.finished) {
18282
+ this.writeTranscript(`
18283
+ ${this.colorActivityRow(activityRows(snapshot, false, -1, snapshot.entries.length)[0])}
18284
+ `);
18285
+ this.liveActivity = { snapshot, row: this.transcriptRow - 1, height: 1, expanded: false, selected: -1, end: snapshot.entries.length };
18286
+ this.completedActivities.push(this.liveActivity);
18287
+ }
18288
+ const item = this.liveActivity;
18289
+ if (!item) return;
18290
+ const previous = item.snapshot.entries.length;
18291
+ item.snapshot = snapshot;
18292
+ if (item.end >= previous) item.end = snapshot.entries.length;
18293
+ if (!snapshot.finished) {
18294
+ this.redrawCompleted(item);
18295
+ return;
18296
+ }
18297
+ if (item.row >= 2) {
18298
+ const removed = item.height + 1;
18299
+ this.rawWrite(`${cup(item.row - 1)}${CSI}${removed}M`);
18300
+ if (this.transcriptRow > item.row) this.transcriptRow -= removed;
18301
+ for (const other of this.completedActivities) if (other !== item && other.row > item.row) other.row -= removed;
18302
+ }
18303
+ this.completedActivities = this.completedActivities.filter((entry) => entry !== item);
18304
+ this.liveActivity = void 0;
18305
+ this.activityOffset = 0;
18306
+ this.totalActivityEntries = 0;
18307
+ if (snapshot.entries.length) {
18308
+ this.writeTranscript(`${this.colorActivityRow(activityRows(snapshot, false, -1, snapshot.entries.length)[0])}
18309
+ `);
18310
+ this.completedActivities.push({ ...item, row: this.transcriptRow - 1, height: 1, expanded: false, selected: -1 });
18311
+ }
18152
18312
  this.paintFooter();
18153
18313
  }
18314
+ setWorkingStatus(text) {
18315
+ const item = this.liveActivity;
18316
+ if (!item) return;
18317
+ if (item.row < 2 || item.row + item.height > this.contentRows) {
18318
+ if (item.row >= 1 && item.row <= this.contentRows) {
18319
+ this.rawWrite(`${cup(item.row)}${CSI}${item.height}M`);
18320
+ if (this.transcriptRow > item.row) this.transcriptRow -= item.height;
18321
+ for (const other of this.completedActivities) if (other !== item && other.row > item.row) other.row -= item.height;
18322
+ }
18323
+ this.completedActivities = this.completedActivities.filter((entry) => entry !== item);
18324
+ this.writeTranscript(`
18325
+ ${this.colorActivityRow(activityRows(item.snapshot, false, -1, item.end)[0])}
18326
+ `);
18327
+ item.row = this.transcriptRow - 1;
18328
+ item.height = 1;
18329
+ item.expanded = false;
18330
+ this.completedActivities.push(item);
18331
+ }
18332
+ this.rawWrite(`${cup(item.row - 1)}${CSI}0m${CSI}2K${fit(text, this.columns)}`);
18333
+ this.placeInputCursor();
18334
+ }
18335
+ colorActivityRow(row) {
18336
+ const text = fit(row.text, this.columns);
18337
+ const color = process.env.NO_COLOR || row.ok === void 0 ? "" : `${CSI}${row.ok ? 32 : 31}m`;
18338
+ const dot = text.indexOf("\u25CF");
18339
+ return dot >= 0 && color ? `${text.slice(0, dot)}${color}\u25CF${CSI}39m${text.slice(dot + 1)}` : text;
18340
+ }
18341
+ shiftCompleted(rows) {
18342
+ for (const anchor of this.transientBlocks) anchor.row -= rows;
18343
+ for (const item of this.completedActivities) item.row -= rows;
18344
+ this.completedActivities = this.completedActivities.filter((item) => item.row >= 1);
18345
+ }
18346
+ redrawCompleted(item) {
18347
+ const rows = activityRows(item.snapshot, item.expanded, item.selected, item.end).slice(0, Math.max(1, this.contentRows - 2));
18348
+ const delta = rows.length - item.height;
18349
+ const scroll = Math.max(0, this.transcriptRow + delta - this.contentRows);
18350
+ if (scroll) {
18351
+ this.rawWrite(`${cup(this.contentRows)}${CSI}${scroll}S`);
18352
+ this.shiftCompleted(scroll);
18353
+ this.transcriptRow -= scroll;
18354
+ }
18355
+ if (item.row < 1) return;
18356
+ if (delta > 0) this.rawWrite(`${cup(item.row + item.height)}${CSI}${delta}L`);
18357
+ if (delta < 0) this.rawWrite(`${cup(item.row + rows.length)}${CSI}${-delta}M`);
18358
+ for (const other of this.completedActivities) if (other !== item && other.row > item.row) other.row += delta;
18359
+ if (this.transcriptRow > item.row) this.transcriptRow += delta;
18360
+ item.height = rows.length;
18361
+ rows.forEach((row, index) => this.rawWrite(`${cup(item.row + index)}${CSI}0m${CSI}2K${this.colorActivityRow(row)}`));
18362
+ this.paintFooter();
18363
+ }
18364
+ toggleActivity() {
18365
+ if (this.liveActivity && (this.liveActivity.row < 2 || this.liveActivity.row + this.liveActivity.height > this.contentRows)) this.setWorkingStatus("working\u2026");
18366
+ const item = this.liveActivity?.snapshot.entries.length ? this.liveActivity : [...this.completedActivities].reverse().find((entry) => entry.snapshot.entries.length > 0) ?? this.liveActivity;
18367
+ if (item) {
18368
+ item.expanded = !item.expanded;
18369
+ this.redrawCompleted(item);
18370
+ }
18371
+ }
18154
18372
  activityMouse(button, row, press) {
18155
- if (!this.activity || !press) return;
18156
- if (button === 64 || button === 65) {
18157
- if (!this.activityExpanded) return;
18158
- this.clearPreviousFooter();
18159
- this.activityEnd = Math.max(Math.min(5, this.activity.entries.length), Math.min(this.activity.entries.length, this.activityEnd + (button === 64 ? -1 : 1)));
18160
- this.activitySelected = -1;
18161
- this.reflowActivity();
18373
+ if (!press) return;
18374
+ const completed = this.completedActivities.find((item) => row >= item.row && row < item.row + item.height);
18375
+ if (completed && (button === 64 || button === 65)) {
18376
+ if (!completed.expanded) return;
18377
+ completed.end = Math.max(Math.min(5, completed.snapshot.entries.length), Math.min(completed.snapshot.entries.length, completed.end + (button === 64 ? -1 : 1)));
18378
+ completed.selected = -1;
18379
+ this.redrawCompleted(completed);
18162
18380
  return;
18163
18381
  }
18164
- if (button !== 0) return;
18165
- const index = row - this.contentRows - 1;
18166
- if (index === 0) {
18167
- this.toggleActivity();
18382
+ if (completed && button === 0) {
18383
+ const rows = activityRows(completed.snapshot, completed.expanded, completed.selected, completed.end);
18384
+ const entry = rows[row - completed.row]?.entry;
18385
+ if (row === completed.row) completed.expanded = !completed.expanded;
18386
+ else if (entry !== void 0) completed.selected = completed.selected === entry ? -1 : entry;
18387
+ this.redrawCompleted(completed);
18168
18388
  return;
18169
18389
  }
18170
- const entry = this.activityLines[index]?.entry;
18171
- if (entry !== void 0) {
18172
- this.clearPreviousFooter();
18173
- this.activitySelected = this.activitySelected === entry ? -1 : entry;
18174
- this.reflowActivity();
18175
- }
18176
18390
  }
18177
18391
  setInputFormatter(formatter) {
18178
18392
  this.inputFormatter = formatter;
@@ -18238,6 +18452,7 @@ var TerminalLayout = class {
18238
18452
  if (this.wrapPending && !/^[\r\n]/.test(stripVTControlCharacters2(formatted))) {
18239
18453
  this.rawWrite(`${cup(this.transcriptRow, this.columns)}\r
18240
18454
  `);
18455
+ if (this.transcriptRow === this.contentRows) this.shiftCompleted(1);
18241
18456
  this.transcriptRow = Math.min(this.contentRows, this.transcriptRow + 1);
18242
18457
  this.transcriptColumn = 1;
18243
18458
  this.wrapPending = false;
@@ -18302,6 +18517,7 @@ var TerminalLayout = class {
18302
18517
  this.wrapPending = true;
18303
18518
  }
18304
18519
  }
18520
+ if (this.transcriptRow > this.contentRows) this.shiftCompleted(this.transcriptRow - this.contentRows);
18305
18521
  this.transcriptRow = Math.min(this.contentRows, this.transcriptRow);
18306
18522
  }
18307
18523
  }
@@ -18351,13 +18567,10 @@ var TerminalLayout = class {
18351
18567
  const model = /^cosmic[- ]1$/i.test(this.details.model) ? "Cosmic 1" : this.details.model;
18352
18568
  const metadata = fit(model, this.columns);
18353
18569
  let frame = "";
18354
- const activity = this.activityLines;
18355
- if (activity.length && !this.suspended) frame += `${CSI}?1006h${CSI}?1000h`;
18570
+ const activity = [];
18571
+ if ((activity.length || this.completedActivities.length) && !this.suspended) frame += `${CSI}?1006h${CSI}?1000h`;
18356
18572
  activity.forEach((row, index) => {
18357
- const text = fit(row.text, this.columns);
18358
- const color = process.env.NO_COLOR || row.ok === void 0 ? "" : `${CSI}${row.ok ? 32 : 31}m`;
18359
- const dot = text.indexOf("\u25CF");
18360
- const rendered = dot >= 0 && color ? `${text.slice(0, dot)}${color}\u25CF${CSI}39m${text.slice(dot + 1)}` : text;
18573
+ const rendered = this.colorActivityRow(row);
18361
18574
  frame += `${cup(this.contentRows + 1 + index)}${CSI}0m${CSI}2K${rendered}`;
18362
18575
  });
18363
18576
  const suggestions = this.suggestionRows;
@@ -18763,7 +18976,7 @@ var HELP = `Commands:
18763
18976
  /autonomous [on|off|status] Continue step-limited turns (up to five rounds)
18764
18977
  /model [name] Show or set the model
18765
18978
  /mode [name] Show or set the agent mode (${agentModeNames.join(", ")})
18766
- /permissions [readonly|workspace|full] Show or set permissions
18979
+ /permissions [readonly|workspace|full|status] Choose or set tool access
18767
18980
  /deny <perm> Revoke a permission for this session
18768
18981
  /plan [task] Plan without workspace edits
18769
18982
  /review [focus] Review current changes without editing
@@ -18800,11 +19013,12 @@ Custom commands come from skills that declare a 'command:' key in their
18800
19013
  frontmatter; built-ins always win a name clash. Type anything else to talk to Ultrac.`;
18801
19014
  var EXIT_REPL_NOT_STARTED = 1;
18802
19015
  function registerReplCommand(program2) {
18803
- program2.command("interactive", { isDefault: true }).alias("repl").description("Start an interactive ultrac session").argument("[task...]", "Optional initial prompt to send on start").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 per turn").option("--allow-write", "Allow file writes without prompting", false).option("--allow-shell", "Grant broad, unsandboxed shell access without prompting", false).option("--allow-network", "Allow network access without prompting", false).option("--no-native-tools", "Use the JSON tool protocol instead of native tool calling").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)").action(async (taskParts, options, command) => {
19016
+ program2.command("interactive", { isDefault: true }).alias("repl").description("Start an interactive ultrac session").argument("[task...]", "Optional initial prompt to send on start").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 per turn").option("--permissions <mode>", "Tool permission preset: readonly, workspace, or full (no tool approval prompts)").option("--allow-write", "Allow file writes without prompting", false).option("--allow-shell", "Grant broad, unsandboxed shell access without prompting", false).option("--allow-network", "Allow network access without prompting", false).option("--no-native-tools", "Use the JSON tool protocol instead of native tool calling").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)").action(async (taskParts, options, command) => {
18804
19017
  const task = (taskParts ?? []).join(" ").trim();
18805
19018
  const invokedByName = namesReplCommand(command, command.parent?.args[0]);
18806
19019
  const mistyped = invokedByName ? void 0 : mistypedCommand(task, knownCommandNames(program2));
18807
19020
  if (mistyped) throw new Error(mistyped);
19021
+ if (options.permissions && !["readonly", "workspace", "full"].includes(options.permissions)) throw new Error("--permissions must be readonly, workspace, or full.");
18808
19022
  await runRepl(options, task);
18809
19023
  });
18810
19024
  }
@@ -18912,7 +19126,7 @@ async function runRepl(options, initialTask = "") {
18912
19126
  diagnosticEvent("provider.retry", event);
18913
19127
  console.log(
18914
19128
  chalk11.yellow(
18915
- ` retrying ${event.target.provider} (${event.target.model}) in ${event.delayMs}ms after ${event.classification.reason}`
19129
+ ` ${event.classification.reason} Retrying ${event.target.model} in ${(event.delayMs / 1e3).toFixed(1)} seconds (retry ${event.attempt}).`
18916
19130
  )
18917
19131
  );
18918
19132
  }
@@ -18923,9 +19137,9 @@ async function runRepl(options, initialTask = "") {
18923
19137
  return;
18924
19138
  }
18925
19139
  const basePermissions = {
18926
- write: options.allowWrite || config.permissions.write,
18927
- shell: options.allowShell || config.permissions.shell,
18928
- network: options.allowNetwork || config.permissions.network
19140
+ write: options.permissions ? options.permissions !== "readonly" : options.allowWrite || config.permissions.write,
19141
+ shell: options.permissions ? options.permissions === "full" : options.allowShell || config.permissions.shell,
19142
+ network: options.permissions ? options.permissions === "full" : options.allowNetwork || config.permissions.network
18929
19143
  };
18930
19144
  const registry = createDefaultToolRegistry();
18931
19145
  const mcpRuntime = await connectWorkspaceMcpServers(config.mcpServers, { workspace, registry });
@@ -18942,7 +19156,11 @@ async function runRepl(options, initialTask = "") {
18942
19156
  ...toolSelection ? { tools: toolSelection } : {}
18943
19157
  };
18944
19158
  setSubagentRuntime({ provider, model: init.model, mode: init.mode, registry });
18945
- return useNativeTools ? new NativeSession(opts) : new InteractiveSession(opts);
19159
+ const created = useNativeTools ? new NativeSession(opts) : new InteractiveSession(opts);
19160
+ if (options.permissions) for (const permission of ["write", "shell", "network"]) {
19161
+ created.setPermission(permission, init.permissions[permission]);
19162
+ }
19163
+ return created;
18946
19164
  };
18947
19165
  let session = createSession({
18948
19166
  model,
@@ -18956,6 +19174,8 @@ async function runRepl(options, initialTask = "") {
18956
19174
  details: { provider: provider.name, model: session.model, mode: session.mode, status: "Idle" }
18957
19175
  }) : void 0;
18958
19176
  setActivitySink(layout?.enabled ? (snapshot) => layout.setActivity(snapshot) : void 0);
19177
+ setWorkingSink(layout?.enabled ? (text) => layout.setWorkingStatus(text) : void 0);
19178
+ setActivityMessageSink(layout?.enabled ? (text) => layout.writeActivityMessage(text) : void 0);
18959
19179
  const refreshDetails = () => {
18960
19180
  if (!layout?.enabled) return;
18961
19181
  const steps = session.toSession().steps;
@@ -19039,37 +19259,45 @@ async function runRepl(options, initialTask = "") {
19039
19259
  }
19040
19260
  };
19041
19261
  const drainQueuedMessages = () => reader.drainQueuedMessages();
19042
- let autonomousRounds = 1;
19043
- setAskUserRuntime(async (text, options2, signal) => {
19044
- console.log(chalk11.white(`
19262
+ let autonomousRounds = 5;
19263
+ setAskUserRuntime(async (text, options2, signal, presentation) => {
19264
+ const block = presentation && layout?.enabled ? layout.beginTransientBlock() : void 0;
19265
+ let resolvedAnswer = null;
19266
+ try {
19267
+ console.log(chalk11.white(`
19045
19268
  ? ${text}`));
19046
- let rawAnswer;
19047
- if (options2?.length && layout?.enabled) {
19048
- slashCompletion?.suspend();
19049
- layout.setPrompt("\u203A ");
19050
- let choices;
19051
- try {
19052
- choices = attachInteractiveChoices(rl, options2, { signal, output: stdout5, prompt: "\u203A ", maxRow: () => layout.contentRows });
19053
- rawAnswer = await reader.nextFresh(signal);
19054
- } finally {
19055
- choices?.cleanup();
19269
+ let rawAnswer;
19270
+ if (options2?.length && layout?.enabled) {
19271
+ slashCompletion?.suspend();
19056
19272
  layout.setPrompt("\u203A ");
19057
- slashCompletion?.resume();
19273
+ let choices;
19274
+ try {
19275
+ choices = attachInteractiveChoices(rl, options2, { signal, output: stdout5, prompt: "\u203A ", maxRow: () => layout.contentRows });
19276
+ rawAnswer = await reader.nextFresh(signal);
19277
+ } finally {
19278
+ choices?.cleanup();
19279
+ layout.setPrompt("\u203A ");
19280
+ slashCompletion?.resume();
19281
+ }
19282
+ } else {
19283
+ if (options2?.length) {
19284
+ options2.forEach((option, index) => console.log(chalk11.gray(` ${index + 1}. ${option}`)));
19285
+ console.log(chalk11.gray(" (type a number, or answer in your own words)"));
19286
+ }
19287
+ rawAnswer = await question(chalk11.white(" \u203A "), signal);
19058
19288
  }
19059
- } else {
19060
- if (options2?.length) {
19061
- options2.forEach((option, index) => console.log(chalk11.gray(` ${index + 1}. ${option}`)));
19062
- console.log(chalk11.gray(" (type a number, or answer in your own words)"));
19289
+ if (signal?.aborted) return null;
19290
+ const answer = (rawAnswer ?? "").trim();
19291
+ const picked = Number.parseInt(answer, 10);
19292
+ if (options2?.length && String(picked) === answer && picked >= 1 && picked <= options2.length) {
19293
+ resolvedAnswer = options2[picked - 1];
19294
+ return resolvedAnswer;
19063
19295
  }
19064
- rawAnswer = await question(chalk11.white(" \u203A "), signal);
19065
- }
19066
- if (signal?.aborted) return null;
19067
- const answer = (rawAnswer ?? "").trim();
19068
- const picked = Number.parseInt(answer, 10);
19069
- if (options2?.length && String(picked) === answer && picked >= 1 && picked <= options2.length) {
19070
- return options2[picked - 1];
19296
+ resolvedAnswer = rawAnswer === null ? null : answer;
19297
+ return resolvedAnswer;
19298
+ } finally {
19299
+ if (block && presentation) layout?.collapseTransientBlock(block, presentation.collapseSummary(signal?.aborted ? null : resolvedAnswer));
19071
19300
  }
19072
- return answer;
19073
19301
  });
19074
19302
  let running = true;
19075
19303
  let turnAbort;
@@ -19196,6 +19424,8 @@ async function runRepl(options, initialTask = "") {
19196
19424
  if (stdin5.isTTY) stdin5.off("keypress", onKeypress);
19197
19425
  setAskUserRuntime(void 0);
19198
19426
  setActivitySink(void 0);
19427
+ setWorkingSink(void 0);
19428
+ setActivityMessageSink(void 0);
19199
19429
  rl.close();
19200
19430
  try {
19201
19431
  await saveTranscript(session, writeCheckpoint);
@@ -19206,16 +19436,22 @@ async function runRepl(options, initialTask = "") {
19206
19436
  }
19207
19437
  console.log(chalk11.gray("Goodbye."));
19208
19438
  }
19209
- async function runTurn(session, input, question, signal, drainQueuedMessages, persist, isComposing, allowImageDrop = true) {
19439
+ async function runTurn(session, input, question, signal, drainQueuedMessages, persist, isComposing, allowImageDrop = true, activityState) {
19210
19440
  const activity = isGhostEnabled(session) ? void 0 : getActivitySink();
19211
- const entries = [];
19212
- let thinkingMs = 0;
19441
+ const entries = activityState?.entries ?? [];
19442
+ let thinkingMs = activityState?.thinkingMs ?? 0;
19213
19443
  let thinkingStarted;
19214
19444
  let turnFailed = false;
19215
- const refreshActivity = (finished = false) => activity?.({ entries: [...entries], thinkingMs, finished, failed: turnFailed });
19445
+ const refreshActivity = (finished = false) => {
19446
+ if (activityState) {
19447
+ activityState.thinkingMs = thinkingMs;
19448
+ activityState.failed ||= turnFailed;
19449
+ }
19450
+ activity?.({ entries: [...entries], thinkingMs, finished: finished && !activityState, failed: turnFailed });
19451
+ };
19216
19452
  refreshActivity();
19217
- const spinner = new Spinner();
19218
- const workSpinner = new Spinner("working");
19453
+ const spinner = new Spinner("working", activity ? getWorkingSink() : void 0);
19454
+ const workSpinner = new Spinner("working", activity ? getWorkingSink() : void 0);
19219
19455
  const activeTools = /* @__PURE__ */ new Map();
19220
19456
  let permissionPrompts = 0;
19221
19457
  const renderWorking = () => {
@@ -19247,24 +19483,23 @@ async function runTurn(session, input, question, signal, drainQueuedMessages, pe
19247
19483
  deltaChars = 0;
19248
19484
  thinkingStarted = performance.now();
19249
19485
  if (!quiet) {
19250
- spinner.update("thinking");
19251
- spinner.start(isComposing);
19486
+ spinner.update("working");
19487
+ spinner.start(isComposing, performance.now() - thinkingMs);
19252
19488
  }
19253
19489
  },
19254
19490
  onModelDelta: (text) => {
19255
19491
  deltaChars += text.length;
19256
- if (!quiet) spinner.update(`thinking (${deltaChars} chars)`);
19492
+ if (!quiet) spinner.update(`working (${deltaChars} chars)`);
19257
19493
  },
19258
19494
  onModelSettled: () => {
19259
19495
  if (thinkingStarted !== void 0) thinkingMs += performance.now() - thinkingStarted;
19260
19496
  thinkingStarted = void 0;
19261
- spinner.stop(activity ? void 0 : signal?.aborted ? "Thinking stopped after" : "Thought for");
19497
+ spinner.stop(activity ? void 0 : signal?.aborted ? "Work stopped after" : "Worked for");
19262
19498
  refreshActivity();
19263
19499
  },
19264
19500
  onAssistantMessage: (message) => {
19265
19501
  if (message.trim()) {
19266
- console.log(`
19267
- ${message.trim()}`);
19502
+ writeActivityMessage(message.trim());
19268
19503
  }
19269
19504
  },
19270
19505
  onToolStart: (call) => {
@@ -19300,7 +19535,7 @@ ${message.trim()}`);
19300
19535
  console.log(chalk11.yellow(` ! ${notice}`));
19301
19536
  },
19302
19537
  onStopped: (reason) => {
19303
- if (reason === "aborted") return;
19538
+ if (reason === "aborted" || reason === "max-steps" && activityState?.continuing) return;
19304
19539
  const line = stopReasonLine(reason);
19305
19540
  const hint = turnStopHint(reason, session.maxSteps);
19306
19541
  if (!line || !hint) return;
@@ -19312,13 +19547,29 @@ ${message.trim()}`);
19312
19547
  permissionPrompts += 1;
19313
19548
  workSpinner.stop();
19314
19549
  try {
19315
- const prompt = permission === "shell" && call.tool === "run_shell" ? shellApprovalPrompt(call, session.toSession().workspace) : permission === "shell" ? ` ${call.tool} wants shell access. [o]nce (this call only) / [a]lways (all shell tools for this session) / [n]o (this call) / n[e]ver (this session) \u203A ` : ` ${call.tool} wants ${permission} access. [o]nce / [a]lways / [n]o / n[e]ver \u203A `;
19316
- const rawAnswer = await question(chalk11.yellow(prompt), signal);
19550
+ const prompt = permission === "shell" && call.tool === "run_shell" ? shellApprovalPrompt(call, session.toSession().workspace) : permission === "shell" ? ` ${call.tool} wants shell access.
19551
+ 1. [o]nce (this call only)
19552
+ 2. [a]lways (this session)
19553
+ 3. [n]o (this call)
19554
+ 4. n[e]ver (this session)
19555
+ Answer: ` : ` ${call.tool} wants ${permission} access.
19556
+ 1. [o]nce
19557
+ 2. [a]lways (this session)
19558
+ 3. [n]o
19559
+ 4. n[e]ver (this session)
19560
+ Answer: `;
19561
+ const choices = ["Allow once", "Allow for this session", "Deny this call", "Never allow this permission this session"];
19562
+ const asker = getAskUserRuntime();
19563
+ const rawAnswer = asker ? await asker(prompt.split("\n").slice(0, -5).join("\n"), choices, signal, { collapseSummary: (value) => {
19564
+ const answer2 = value?.trim().toLowerCase();
19565
+ const label = !answer2 ? "Cancelled" : ["a", "always", "2", choices[1].toLowerCase()].includes(answer2) ? choices[1] : ["o", "once", "y", "yes", "1", choices[0].toLowerCase()].includes(answer2) ? choices[0] : ["e", "never", "4", choices[3].toLowerCase()].includes(answer2) ? choices[3] : choices[2];
19566
+ return ` ${call.tool} \xB7 ${permission} permission: ${label}`;
19567
+ } }) : await question(chalk11.yellow(prompt), signal);
19317
19568
  if (signal?.aborted || rawAnswer === null) return "deny";
19318
19569
  const answer = rawAnswer.trim().toLowerCase();
19319
- if (answer === "a" || answer === "always") return "always";
19320
- if (answer === "o" || answer === "once" || answer === "y" || answer === "yes") return "once";
19321
- if (answer === "e" || answer === "never") {
19570
+ if (answer === "a" || answer === "always" || answer === "2" || answer === choices[1].toLowerCase()) return "always";
19571
+ if (answer === "o" || answer === "once" || answer === "y" || answer === "yes" || answer === "1" || answer === choices[0].toLowerCase()) return "once";
19572
+ if (answer === "e" || answer === "never" || answer === "4" || answer === choices[3].toLowerCase()) {
19322
19573
  console.log(
19323
19574
  chalk11.gray(
19324
19575
  ` ${permission} refused for the rest of the session \u2014 nothing will ask again. /allow ${permission} undoes it.`
@@ -19349,15 +19600,16 @@ ${message.trim()}`);
19349
19600
  }
19350
19601
  }
19351
19602
  async function runAndCheckpointTurn(session, input, question, signal, persist, drainQueuedMessages, isComposing, maxRounds = 1, allowImageDrop = true) {
19603
+ const activityState = { entries: [], thinkingMs: 0, continuing: maxRounds > 1 };
19352
19604
  try {
19353
- if (maxRounds === 1) return await runTurn(session, input, question, signal, drainQueuedMessages, persist, isComposing, allowImageDrop);
19605
+ if (maxRounds === 1) return await runTurn(session, input, question, signal, drainQueuedMessages, persist, isComposing, allowImageDrop, activityState);
19354
19606
  let successful = true;
19355
19607
  const result = await runAutonomousLoop({
19356
19608
  maxRounds,
19357
19609
  signal,
19358
19610
  classifyFailure: () => ({ kind: "unknown", retryable: false, failoverable: false, reason: "Interactive provider retries already exhausted." }),
19359
19611
  runRound: async (round, _previous, roundSignal) => {
19360
- successful = await runTurn(session, round === 1 ? input : AUTONOMOUS_CONTINUE_TASK, question, roundSignal, drainQueuedMessages, persist, isComposing, allowImageDrop);
19612
+ successful = await runTurn(session, round === 1 ? input : AUTONOMOUS_CONTINUE_TASK, question, roundSignal, drainQueuedMessages, persist, isComposing, allowImageDrop, activityState);
19361
19613
  await persist(session);
19362
19614
  const snapshot = session.toSession();
19363
19615
  return successful ? snapshot : { ...snapshot, stopReason: roundSignal.aborted ? "aborted" : "error" };
@@ -19369,6 +19621,7 @@ async function runAndCheckpointTurn(session, input, question, signal, persist, d
19369
19621
  if (result.haltReason !== "completed" && result.haltReason !== "aborted") console.log(chalk11.gray(`Automatic continuation stopped: ${result.haltReason}. Session preserved.`));
19370
19622
  return successful;
19371
19623
  } finally {
19624
+ if (!isGhostEnabled(session)) getActivitySink()?.({ ...activityState, entries: [...activityState.entries], finished: true });
19372
19625
  await persist(session);
19373
19626
  }
19374
19627
  }
@@ -19620,15 +19873,43 @@ ${extra}`);
19620
19873
  return "ok";
19621
19874
  }
19622
19875
  case "permissions": {
19623
- if (argument) {
19624
- if (!["readonly", "workspace", "full"].includes(argument)) {
19625
- console.log("Usage: /permissions [readonly|workspace|full]");
19876
+ let selectedMode = argument.toLowerCase();
19877
+ const modes = ["readonly", "workspace", "full"];
19878
+ const choices = ["Readonly: no write, shell, or network access", "Workspace: file writes; shell and network denied", "Full access: no tool approval prompts"];
19879
+ const resolveMode = (value) => {
19880
+ if (value === null) return void 0;
19881
+ const text = value.trim().toLowerCase();
19882
+ const index = choices.findIndex((choice) => choice.toLowerCase() === text);
19883
+ if (index >= 0) return modes[index];
19884
+ if (["1", "2", "3"].includes(text)) return modes[Number(text) - 1];
19885
+ return modes.includes(text) ? text : void 0;
19886
+ };
19887
+ const asker = getAskUserRuntime();
19888
+ if (!selectedMode && asker) {
19889
+ const answer = await asker("Choose tool permissions for this session.", choices, signal, {
19890
+ collapseSummary: (value) => {
19891
+ const mode2 = resolveMode(value);
19892
+ return mode2 ? ` Permission mode: ${mode2}` : " Permission mode unchanged.";
19893
+ }
19894
+ });
19895
+ if (signal?.aborted || answer === null) return "ok";
19896
+ const mode = resolveMode(answer);
19897
+ if (!mode) {
19898
+ console.log("Permission mode unchanged. Use /permissions readonly|workspace|full.");
19899
+ return "ok";
19900
+ }
19901
+ selectedMode = mode;
19902
+ }
19903
+ if (selectedMode && selectedMode !== "status") {
19904
+ if (!modes.includes(selectedMode)) {
19905
+ console.log("Usage: /permissions [readonly|workspace|full|status]");
19626
19906
  return "ok";
19627
19907
  }
19628
19908
  for (const permission of ["write", "shell", "network"]) {
19629
- session.setPermission(permission, argument === "full" || argument === "workspace" && permission === "write");
19909
+ session.setPermission(permission, selectedMode === "full" || selectedMode === "workspace" && permission === "write");
19630
19910
  }
19631
- console.log("Permissions updated. These are tool grants; shell access is not an operating-system sandbox.");
19911
+ if (argument) console.log(selectedMode === "full" ? "Full access: file, shell, and network tools run without approval prompts for this session." : `Permission mode: ${selectedMode}. Ungranted tools are denied without prompting.`);
19912
+ return "ok";
19632
19913
  }
19633
19914
  const p = session.currentPermissions;
19634
19915
  console.log(`write: ${p.write} shell: ${p.shell} network: ${p.network}`);
@@ -19919,11 +20200,16 @@ function shellApprovalPrompt(call, workspace) {
19919
20200
  const timeoutMs = typeof call.input.timeoutMs === "number" ? call.input.timeoutMs : 3e4;
19920
20201
  return [
19921
20202
  " run_shell wants shell access.",
19922
- " WARNING: this command runs unsandboxed as your user. It can read or write files available to your account and use your network connection.",
20203
+ " Runs unsandboxed as your user.",
20204
+ " Can read or write your files and use your network connection.",
19923
20205
  ` Command: ${JSON.stringify(command)}`,
19924
20206
  ` Resolved cwd: ${JSON.stringify(resolvedCwd)}`,
19925
20207
  ` Timeout: ${timeoutMs.toLocaleString("en-US")} ms`,
19926
- " [o]nce runs only this command / [a]lways grants shell access for this whole session, including other shell-using tools / [n]o (this call) / n[e]ver (this session) \u203A "
20208
+ " 1. [o]nce runs only this command",
20209
+ " 2. [a]lways grants shell access for this whole session, including other shell-using tools",
20210
+ " 3. [n]o (this call)",
20211
+ " 4. n[e]ver (this session)",
20212
+ " Answer: "
19927
20213
  ].join("\n");
19928
20214
  }
19929
20215
  function indent3(value) {
@@ -19943,7 +20229,7 @@ function parsePositiveInt(value, flag) {
19943
20229
  var EXIT_INCOMPLETE_RUN = 2;
19944
20230
  var NOTIFY_SUMMARY_CHARS = 1200;
19945
20231
  function registerRunCommand(program2) {
19946
- 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(
20232
+ 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("--permissions <mode>", "Tool permission preset: readonly, workspace, or full").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(
19947
20233
  "--allow-incomplete",
19948
20234
  `Exit 0 even when the run stops without finishing. By default an unfinished run (step cap, abort, error) exits ${EXIT_INCOMPLETE_RUN} so a script or a \`&&\` chain can tell a partial answer from a real one; pass this when hitting --max-steps on a long task is an acceptable outcome. The stop reason is still printed either way.`,
19949
20235
  false
@@ -20060,7 +20346,8 @@ function registerRunCommand(program2) {
20060
20346
  const abortRun = () => controller.abort();
20061
20347
  process.on("SIGINT", abortRun);
20062
20348
  process.on("SIGTERM", abortRun);
20063
- const permissions = autonomous ? { write: true, shell: true, network: true } : {
20349
+ if (options.permissions && !["readonly", "workspace", "full"].includes(options.permissions)) throw new Error("--permissions must be readonly, workspace, or full.");
20350
+ const permissions = options.permissions ? { write: options.permissions !== "readonly", shell: options.permissions === "full", network: options.permissions === "full" } : autonomous ? { write: true, shell: true, network: true } : {
20064
20351
  write: options.allowWrite || config.permissions.write,
20065
20352
  shell: options.allowShell || config.permissions.shell,
20066
20353
  network: options.allowNetwork || config.permissions.network
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spinalfluid/ultrac",
3
- "version": "0.1.14",
3
+ "version": "0.1.20",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "Ultrac terminal agent harness for Cosmic 1, with local tools, browser sign-in, streamed answers, and resumable sessions.",
6
6
  "author": "Aditya Pundir",