@alisio/alisio-code 0.5.1 → 0.6.0

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.
@@ -1,4 +1,4 @@
1
- import type { AnalysisStatus } from "@alisio/sdk";
1
+ import type { AnalysisStatus, EngineDescriptor } from "@alisio/sdk";
2
2
  type Common = {
3
3
  db?: string;
4
4
  python?: string;
@@ -6,6 +6,10 @@ type Common = {
6
6
  config?: string;
7
7
  trustProject?: boolean;
8
8
  };
9
+ /** `alisio forge status` reuses this for its Python lines. */
10
+ export declare function pythonBackendDescriptors(o: Common): Promise<EngineDescriptor[]>;
11
+ /** A fixed-width table of the backends: readiness, isolation, profiles and why not. */
12
+ export declare function backendsTable(backends: readonly EngineDescriptor[]): string;
9
13
  /** The unified runtime state and, when Python is missing, the install guidance for this system. */
10
14
  export declare function analysisState(o: Common): Promise<{
11
15
  status: AnalysisStatus;
@@ -24,4 +28,23 @@ export declare function setupExtras(o: Common, extras: string | undefined): Prom
24
28
  export declare function setupOci(o: Common, image: string | undefined): Promise<void>;
25
29
  /** `alisio analysis sweep [--force]`: the retention sweep now (it also runs by itself, daily). */
26
30
  export declare function sweep(o: Common, force: boolean): Promise<void>;
31
+ export interface WasmSetupDeps {
32
+ /** Replaceable in tests; the global `fetch` otherwise. */
33
+ fetch?: typeof fetch;
34
+ manifest?: import("@alisio/core").PyodideManifest;
35
+ /** Whether someone can answer a question (a terminal). */
36
+ interactive?: boolean;
37
+ ask?: (question: string) => Promise<boolean>;
38
+ write?: (text: string) => void;
39
+ }
40
+ /**
41
+ * `alisio analysis setup --wasm [--profile analysis|science] [--yes]`: the only place the wasm
42
+ * backend uses the network. Downloads the pinned Pyodide distribution into
43
+ * `<analysis dir>/pyodide/<version>/`, verifies every hash and size, and on any mismatch deletes
44
+ * what it fetched and leaves the backend not ready. It never touches the venv or the OCI image.
45
+ */
46
+ export declare function setupWasm(o: Common, flags: {
47
+ profile?: string;
48
+ yes?: boolean;
49
+ }, deps?: WasmSetupDeps): Promise<void>;
27
50
  export {};
package/dist/analysis.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * `python_run`: the base runtime is the discovered Python with only the standard library.
6
6
  */
7
7
  import { dirname, join } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
8
9
  async function context(o) {
9
10
  const core = await import("@alisio/core");
10
11
  const workspace = await core.findWorkspace(o.cwd ?? process.cwd());
@@ -20,9 +21,41 @@ async function context(o) {
20
21
  const oci = () => new core.OciRuntime({ ...config.analysis.oci });
21
22
  return { core, config, provenance, stateDir, manager, oci };
22
23
  }
24
+ /** The Python backends of this configuration, described with their live readiness. */
25
+ function describeBackends(c) {
26
+ const backends = c.core.createPythonBackends({
27
+ runtime: c.manager,
28
+ oci: { runtime: c.oci, mode: () => c.config.analysis.runtime },
29
+ wasm: { stateRoot: c.stateDir, settings: () => c.config.analysis.wasm },
30
+ });
31
+ return (signal) => c.core.describePythonBackends(backends, signal);
32
+ }
33
+ /** `alisio forge status` reuses this for its Python lines. */
34
+ export async function pythonBackendDescriptors(o) {
35
+ return describeBackends(await context(o))();
36
+ }
37
+ /** A fixed-width table of the backends: readiness, isolation, profiles and why not. */
38
+ export function backendsTable(backends) {
39
+ const rows = backends.map((b) => [
40
+ b.id.replace(/^python-/, ""),
41
+ b.isolation,
42
+ b.ready ? "ready" : "not ready",
43
+ b.profiles?.length ? b.profiles.join(",") : "-",
44
+ b.capabilities.networkDeniedByOS ? "denied" : "open",
45
+ b.reason ?? (b.isolation === "none" ? "host (not sandboxed)" : ""),
46
+ ]);
47
+ const head = ["BACKEND", "ISOLATION", "STATE", "PROFILES", "NETWORK", "NOTE"];
48
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
49
+ const line = (cells) => cells
50
+ .map((c, i) => c.padEnd(widths[i] ?? 0))
51
+ .join(" ")
52
+ .trimEnd();
53
+ return [line(head), ...rows.map(line)].join("\n");
54
+ }
23
55
  /** The unified runtime state and, when Python is missing, the install guidance for this system. */
24
56
  export async function analysisState(o) {
25
- const { core, config, provenance, stateDir, manager, oci } = await context(o);
57
+ const ctx = await context(o);
58
+ const { core, config, provenance, stateDir, manager, oci } = ctx;
26
59
  const janitor = {
27
60
  lastSweep: () => import("node:fs/promises")
28
61
  .then((fs) => fs.readFile(join(stateDir, "analysis", ".last-sweep"), "utf8"))
@@ -37,6 +70,7 @@ export async function analysisState(o) {
37
70
  runtime: manager,
38
71
  oci,
39
72
  janitor,
73
+ backends: describeBackends(ctx),
40
74
  enabled: config.analysis.enabled,
41
75
  readOnly: false,
42
76
  });
@@ -58,6 +92,8 @@ export async function analysisState(o) {
58
92
  export async function printAnalysisStatus(o) {
59
93
  const { status, guidance, ignored } = await analysisState(o);
60
94
  console.log(JSON.stringify(status, null, 2));
95
+ if (status.backends?.length)
96
+ process.stderr.write(`\nPython backends (analysis.runtime=${status.mode}, analysis.isolation=${status.isolation ?? "preferred"}):\n${backendsTable(status.backends)}\n`);
61
97
  if (guidance)
62
98
  process.stderr.write(`\n${guidance}\n`);
63
99
  if (status.mode === "oci" && status.oci.available === false)
@@ -143,3 +179,66 @@ export async function sweep(o, force) {
143
179
  store.close();
144
180
  }
145
181
  }
182
+ const megabytes = (bytes) => `${Math.max(1, Math.round(bytes / (1024 * 1024)))} MB`;
183
+ async function confirm(question) {
184
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
185
+ try {
186
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
187
+ }
188
+ finally {
189
+ rl.close();
190
+ }
191
+ }
192
+ /**
193
+ * `alisio analysis setup --wasm [--profile analysis|science] [--yes]`: the only place the wasm
194
+ * backend uses the network. Downloads the pinned Pyodide distribution into
195
+ * `<analysis dir>/pyodide/<version>/`, verifies every hash and size, and on any mismatch deletes
196
+ * what it fetched and leaves the backend not ready. It never touches the venv or the OCI image.
197
+ */
198
+ export async function setupWasm(o, flags, deps = {}) {
199
+ const profile = flags.profile ?? "analysis";
200
+ if (profile !== "analysis" && profile !== "science")
201
+ throw new Error("Choose the profile: --profile analysis (default) or --profile science");
202
+ const { core, stateDir } = await context(o);
203
+ const manifest = deps.manifest ?? core.PYODIDE_MANIFEST;
204
+ const write = deps.write ?? ((text) => void process.stderr.write(text));
205
+ const dir = core.pyodideDir(stateDir, manifest);
206
+ const bytes = core.pyodideDownloadSize(manifest, profile);
207
+ const hosts = [
208
+ ...new Set([...manifest.core, ...Object.values(manifest.packages)].map((f) => new URL(f.url).host)),
209
+ ];
210
+ write(`Pyodide ${manifest.version} (Python ${manifest.python}), profile ${profile}: about ${megabytes(bytes)} from ${hosts.join(", ")}.\n` +
211
+ `It goes to ${dir}. Every file is checked against a pinned SHA-256; this command is the only time the wasm backend uses the network.\n`);
212
+ if (!flags.yes) {
213
+ const interactive = deps.interactive ?? (!!process.stdin.isTTY && !!process.stderr.isTTY);
214
+ if (!interactive)
215
+ throw new Error("This download needs your confirmation: pass --yes in a non-interactive run.");
216
+ const ok = await (deps.ask ?? confirm)("Download now? [y/N] ");
217
+ if (!ok) {
218
+ write("Cancelled; nothing was downloaded.\n");
219
+ return;
220
+ }
221
+ }
222
+ const controller = new AbortController();
223
+ process.on("SIGINT", () => controller.abort(new Error("Interrupted")));
224
+ try {
225
+ const result = await core.installPyodide({
226
+ dir,
227
+ profile,
228
+ manifest,
229
+ signal: controller.signal,
230
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
231
+ onProgress: (e) => write(` [${e.index}/${e.total}] ${e.file}\n`),
232
+ });
233
+ write(`Pyodide ${manifest.version} is ready (profile ${result.ok ? result.profile : profile}). ` +
234
+ 'With analysis.runtime "auto" it is used when no container is ready; set analysis.runtime to "wasm" to require it.\n');
235
+ }
236
+ catch (error) {
237
+ const integrity = error instanceof core.PyodideIntegrityError;
238
+ write(`${error instanceof Error ? error.message : String(error)}\n` +
239
+ (integrity
240
+ ? "Nothing from this download was kept. The wasm backend is not ready.\n"
241
+ : "Nothing from this download was kept.\n"));
242
+ process.exitCode = 1;
243
+ }
244
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `alisio forge status`: what `GET /api/forge` will report, for the terminal. It builds the engine
3
+ * from the configuration alone (no application, no database): the WASM is checked against its
4
+ * pinned hash, the host files are looked for, and `--probe` runs a real isolate end to end.
5
+ */
6
+ type Common = {
7
+ cwd?: string;
8
+ config?: string;
9
+ trustProject?: boolean;
10
+ json?: boolean;
11
+ };
12
+ export declare function printForgeStatus(o: Common, probe: boolean): Promise<void>;
13
+ export {};
package/dist/forge.js ADDED
@@ -0,0 +1,46 @@
1
+ const mib = (bytes) => `${Math.round(bytes / (1024 * 1024))} MiB`;
2
+ export async function printForgeStatus(o, probe) {
3
+ const core = await import("@alisio/core");
4
+ const workspace = await core.findWorkspace(o.cwd ?? process.cwd());
5
+ const { config } = await core.loadConfigWithProvenance(workspace, {
6
+ file: o.config,
7
+ trustProject: o.trustProject,
8
+ });
9
+ const engine = core.forgeEngineFor(config.forge);
10
+ const { pythonBackendDescriptors } = await import("./analysis.js");
11
+ const python = await pythonBackendDescriptors(o);
12
+ const base = await core.describeForge(config.forge, engine);
13
+ const status = {
14
+ ...base,
15
+ engines: [...base.engines, ...python],
16
+ python: { runtime: config.analysis.runtime, isolation: config.analysis.isolation },
17
+ };
18
+ const probed = probe ? await engine.probe() : undefined;
19
+ if (o.json) {
20
+ process.stdout.write(`${JSON.stringify({ ...status, ...(probed ? { probe: probed } : {}) })}\n`);
21
+ return;
22
+ }
23
+ const js = status.javascript;
24
+ const descriptor = status.engines[0];
25
+ const lines = [
26
+ `JavaScript ${js.enabled ? "enabled" : "disabled"} ${descriptor?.ready ? "ready" : "not ready"}`,
27
+ ` engine ${descriptor?.id ?? "javascript-quickjs"} ${js.version}`,
28
+ ` isolation ${descriptor?.isolation ?? "none"} (${js.host} host${js.hostSetting === "auto" ? "" : `, forced by forge.javascript.host=${js.hostSetting}`})`,
29
+ ` wasm sha256 ${js.wasmSha256}${js.verified ? " verified" : ""}`,
30
+ ];
31
+ if (!descriptor?.ready && descriptor?.reason)
32
+ lines.push(` reason ${descriptor.reason}`);
33
+ if (js.verification)
34
+ lines.push(` problem ${js.verification}`);
35
+ lines.push(` limits timeout ${js.limits.timeoutMs} ms, heap ${mib(js.limits.memoryBytes)}, stack ${Math.round(js.limits.stackBytes / 1024)} KiB, ${js.limits.maxToolCalls} calls (${js.limits.maxConcurrency} at once), ${js.limits.maxOutputChars} output chars`, ` state ${status.state.enabled ? "on" : "off"} scratchpad ${status.scratch.enabled ? "on" : "off"}`);
36
+ lines.push("", `Python analysis.runtime=${config.analysis.runtime} isolation=${config.analysis.isolation}`);
37
+ for (const backend of python)
38
+ lines.push(` ${backend.id.replace(/^python-/, "").padEnd(10)} ${backend.isolation.padEnd(13)} ${backend.ready ? "ready " : "not ready"} network ${backend.capabilities.networkDeniedByOS ? "denied by the OS" : "not blocked"}${backend.profiles?.length ? ` profiles ${backend.profiles.join(",")}` : ""}${backend.reason ? ` (${backend.reason})` : ""}`);
39
+ if (probed)
40
+ lines.push(probed.ok
41
+ ? ` probe ok, a ${probed.host} isolate ran a script in ${probed.bootMs} ms`
42
+ : ` probe FAILED (${probed.host} host): ${probed.reason}`);
43
+ process.stdout.write(`${lines.join("\n")}\n`);
44
+ if (probed && !probed.ok)
45
+ process.exitCode = 1;
46
+ }
package/dist/main.js CHANGED
@@ -419,6 +419,8 @@ program.command("doctor").action(async (_opts, cmd) => {
419
419
  python: { mode: analysisInfo.status.mode, ...pythonInfo },
420
420
  analysis: {
421
421
  mode: analysisInfo.status.mode,
422
+ ...(analysisInfo.status.isolation ? { isolation: analysisInfo.status.isolation } : {}),
423
+ ...(analysisInfo.status.backends ? { backends: analysisInfo.status.backends } : {}),
422
424
  oci: analysisInfo.status.oci,
423
425
  limits: analysisInfo.status.limits,
424
426
  retention: analysisInfo.status.retention,
@@ -447,13 +449,21 @@ analysis
447
449
  });
448
450
  analysis
449
451
  .command("setup")
450
- .description("Optional: install hash-locked Python extras (--extras) or pull the container image (--oci). " +
451
- "python_run never needs this for the standard library.")
452
+ .description("Optional: install hash-locked Python extras (--extras), pull the container image (--oci) or " +
453
+ "download the WebAssembly runtime (--wasm). python_run never needs this for the standard library.")
452
454
  .option("--extras <set>", "analysis (pandas, numpy, matplotlib…) or science (adds scipy…)")
453
455
  .option("--oci", "Pull the container image once and verify its digest (Docker or Podman)")
454
456
  .option("--image <name>", "With --oci: the image to pull (default: analysis.oci.image)")
457
+ .option("--wasm", "Download the pinned Pyodide runtime for the wasm backend (needs network)")
458
+ .option("--profile <name>", "With --wasm: analysis (default) or science (adds scipy, scikit-learn…)")
459
+ .option("--yes", "With --wasm: do not ask for confirmation (required when not interactive)")
455
460
  .action(async (flags, cmd) => {
456
- const { setupExtras, setupOci } = await import("./analysis.js");
461
+ const { setupExtras, setupOci, setupWasm } = await import("./analysis.js");
462
+ if (flags.wasm)
463
+ return setupWasm(options(cmd), {
464
+ ...(flags.profile ? { profile: flags.profile } : {}),
465
+ yes: !!flags.yes,
466
+ });
457
467
  if (flags.oci)
458
468
  return setupOci(options(cmd), flags.image);
459
469
  return setupExtras(options(cmd), flags.extras);
@@ -466,6 +476,17 @@ analysis
466
476
  const { sweep } = await import("./analysis.js");
467
477
  await sweep(options(cmd), !!flags.force);
468
478
  });
479
+ const forge = program
480
+ .command("forge")
481
+ .description("Forja: isolated code execution (js_run). Shows the engine and why it is or is not ready");
482
+ forge
483
+ .command("status")
484
+ .description("Show the engines: JavaScript (version, WASM hash, isolation, host, limits) and the Python backends")
485
+ .option("--probe", "Also start a real isolate and run a trivial script through the protocol")
486
+ .action(async (flags, cmd) => {
487
+ const { printForgeStatus } = await import("./forge.js");
488
+ await printForgeStatus(options(cmd), !!flags.probe);
489
+ });
469
490
  const trust = program.command("trust").description("Inspect or revoke per-directory project trust");
470
491
  trust.command("list").action(async () => {
471
492
  const { listTrust } = await import("@alisio/core");
@@ -665,7 +686,14 @@ program
665
686
  await cliUpdateAll(common);
666
687
  });
667
688
  try {
668
- await program.parseAsync();
689
+ // The compiled binary starts its own Forja process isolates with this hidden command (spec D-21).
690
+ // It is dispatched before any configuration or database is loaded and is not part of the CLI.
691
+ if (process.argv.slice(2)[0] === "__forge-isolate") {
692
+ const { runProcessIsolate } = await import("@alisio/core/forge-isolate");
693
+ await runProcessIsolate();
694
+ }
695
+ else
696
+ await program.parseAsync();
669
697
  }
670
698
  catch (error) {
671
699
  console.error(error instanceof Error ? error.message : String(error));
@@ -51,4 +51,4 @@ export declare const installApprovalTitle: (install: InstallPreview) => string;
51
51
  /** Body of the `analysis.install` approval: the packages, the download estimate and the network. */
52
52
  export declare function installApprovalDetail(install: InstallPreview): string;
53
53
  /** Title of a capability approval in the TUI picker. */
54
- export declare const capabilityApprovalTitle: (runtime: "managed" | "oci" | undefined) => string;
54
+ export declare const capabilityApprovalTitle: (runtime: "auto" | "managed" | "oci" | "wasm" | undefined) => string;
@@ -204,4 +204,8 @@ export function installApprovalDetail(install) {
204
204
  /** Title of a capability approval in the TUI picker. */
205
205
  export const capabilityApprovalTitle = (runtime) => runtime === "oci"
206
206
  ? "Run Python analysis (container · no network)?"
207
- : "Run Python analysis (managed · not sandboxed)?";
207
+ : runtime === "wasm"
208
+ ? "Run Python analysis (wasm · restricted process, network not blocked)?"
209
+ : runtime === "auto"
210
+ ? "Run Python analysis (most isolated backend ready; the host only if none · see the engine line)?"
211
+ : "Run Python analysis (managed · not sandboxed)?";
@@ -8,7 +8,7 @@ import { branchDisplay } from "./git-branch.js";
8
8
  import { initialPlanReviewState, reducePlanReview, } from "./plan-review.js";
9
9
  import { initialQuestionState, reduceQuestions, } from "./questions.js";
10
10
  import { scrollWindow } from "./scroll.js";
11
- import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, quietFor, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, runPhase, STALLED_MS, } from "./state.js";
11
+ import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, forgeLabel, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, quietFor, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, runPhase, STALLED_MS, } from "./state.js";
12
12
  import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, sanitizeCode, style, } from "./theme.js";
13
13
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14
14
  /** Shared animation clock advanced by the app while work is running. */
@@ -643,7 +643,9 @@ export class ToolBlock {
643
643
  ? style.gray(formatDuration(i.durationMs))
644
644
  : "";
645
645
  const progress = i.status === "running" && i.progress ? ` ${style.dim(`· ${i.progress}`)}` : "";
646
- const head = `${marker}${icon} ${style.bold(humanizeToolName(i.name))} ${style.gray(i.summary)}${progress}`;
646
+ const forge = forgeLabel(i.forge);
647
+ const forgePart = forge ? ` ${style.dim(`· ${forge}`)}` : "";
648
+ const head = `${marker}${icon} ${style.bold(humanizeToolName(i.name))} ${style.gray(i.summary)}${forgePart}${progress}`;
647
649
  const gap = width - visibleWidth(head) - visibleWidth(right) - 1;
648
650
  const lines = [
649
651
  gap > 0 ? `${head}${" ".repeat(gap + 1)}${right}` : truncateToWidth(head, width),
@@ -728,7 +730,7 @@ export class ToolGroupBlock {
728
730
  : `${style.bold(member.humanName)} ${style.dim(`(${member.name})`)}`;
729
731
  lines.push(` ${micon} ${name}${member.durationMs !== undefined
730
732
  ? ` ${style.gray(`· ${formatDuration(member.durationMs)}`)}`
731
- : ""}${member.summary ? ` ${style.gray(member.summary)}` : ""}`);
733
+ : ""}${member.summary ? ` ${style.gray(member.summary)}` : ""}${member.forge ? ` ${style.dim(`· ${member.forge}`)}` : ""}`);
732
734
  const rich = richBodyLines(member, width);
733
735
  if (rich.lines.length) {
734
736
  lines.push(...rich.lines.map((line) => ` ${line}`));
@@ -1,4 +1,4 @@
1
- import type { Message, ModelInfo, RunEvent, ToolResult, TreeNode, UiBlock } from "@alisio/sdk";
1
+ import type { EngineId, Message, ModelInfo, RunEvent, ToolResult, TreeNode, UiBlock } from "@alisio/sdk";
2
2
  export type Level = "ok" | "warn" | "danger";
3
3
  export declare function formatTokens(n: number): string;
4
4
  /** Prefixes every model with its owning provider so identical model ids are never ambiguous. */
@@ -317,6 +317,8 @@ export type TranscriptItem = {
317
317
  toolKind?: ToolKind;
318
318
  /** Last non-empty `tool_progress` line while the tool runs (cleared on completion). */
319
319
  progress?: string;
320
+ /** What a Forja execution (`js_run`, `python_run`) reported; kept after completion. */
321
+ forge?: ForgeLine;
320
322
  /** Exit code derived safely from the JSON preview when the tool reports one (run_process/shell/search_text). */
321
323
  exitCode?: number;
322
324
  } | {
@@ -377,6 +379,18 @@ export declare function reasoningDurationMs(item: {
377
379
  reasoningStartedAt?: number;
378
380
  reasoningDoneAt?: number;
379
381
  }): number | undefined;
382
+ /** What the row of a Forja execution shows: nested calls, the script's own note and the engine. */
383
+ export interface ForgeLine {
384
+ engine: EngineId;
385
+ /** Highest nested call number seen. */
386
+ started: number;
387
+ /** Nested calls that ended (succeeded, failed or denied). */
388
+ finished: number;
389
+ /** The script's own `progress({ message, completed, total })`, bounded. */
390
+ note?: string;
391
+ }
392
+ /** `page 2 (2/9) · 2/3 calls · wasm`; empty when there is nothing to say yet. */
393
+ export declare function forgeLabel(forge: ForgeLine | undefined): string;
380
394
  export declare function reduceEvent(state: ViewState, event: RunEvent): ViewState;
381
395
  /** No event for this long: the footer says how long the run has been quiet. */
382
396
  export declare const QUIET_MS = 15000;
@@ -460,6 +474,8 @@ export interface ToolItemView {
460
474
  summary: string;
461
475
  /** Last progress line while running. */
462
476
  progress?: string;
477
+ /** `2/3 calls · wasm` of a Forja execution (running or finished). */
478
+ forge?: string;
463
479
  status: ToolStatus;
464
480
  durationMs?: number;
465
481
  preview?: string;
package/dist/tui/state.js CHANGED
@@ -580,6 +580,54 @@ function updateTool(state, id, update) {
580
580
  items[index] = { ...item, ...update };
581
581
  return { ...state, items };
582
582
  }
583
+ const FORGE_NOTE_MAX = 40;
584
+ const isForgeProgress = (value) => !!value &&
585
+ typeof value === "object" &&
586
+ value.kind === "forge" &&
587
+ typeof value.engine === "string";
588
+ /** Folds one `ForgeProgress` into the line (terminal call events are never coalesced away). */
589
+ function foldForgeProgress(current, progress) {
590
+ const next = current
591
+ ? { ...current, engine: progress.engine }
592
+ : { engine: progress.engine, started: 0, finished: 0 };
593
+ if (progress.call) {
594
+ next.started = Math.max(next.started, progress.call.callId);
595
+ if (progress.call.status !== "started")
596
+ next.finished += 1;
597
+ }
598
+ else if (progress.message || progress.total !== undefined) {
599
+ const fraction = progress.completed !== undefined && progress.total !== undefined
600
+ ? ` (${progress.completed}/${progress.total})`
601
+ : "";
602
+ const message = (progress.message ?? "").slice(0, FORGE_NOTE_MAX);
603
+ next.note = `${message}${fraction}`.trim();
604
+ }
605
+ return next;
606
+ }
607
+ /** The isolation tag of a Python engine; nothing for JavaScript (it is always isolated). */
608
+ const FORGE_ENGINE_TAG = {
609
+ "python-oci": "oci",
610
+ "python-wasm": "wasm",
611
+ "python-managed": "host!",
612
+ };
613
+ /** `page 2 (2/9) · 2/3 calls · wasm`; empty when there is nothing to say yet. */
614
+ export function forgeLabel(forge) {
615
+ if (!forge)
616
+ return "";
617
+ return [
618
+ forge.note,
619
+ forge.started > 0 ? `${forge.finished}/${forge.started} calls` : undefined,
620
+ FORGE_ENGINE_TAG[forge.engine],
621
+ ]
622
+ .filter(Boolean)
623
+ .join(" · ");
624
+ }
625
+ const ENGINE_LINE = /^engine: (python-oci|python-wasm|python-managed) \(/m;
626
+ /** The Python engine a result text names on its last line (PD11), for rows without progress. */
627
+ const engineOfResult = (text) => {
628
+ const engine = typeof text === "string" ? ENGINE_LINE.exec(text)?.[1] : undefined;
629
+ return engine ? { engine: engine, started: 0, finished: 0 } : undefined;
630
+ };
583
631
  /** Longest progress line kept on a running tool row. */
584
632
  const PROGRESS_LINE_MAX = 80;
585
633
  /** The last non-empty line of a `tool_progress` payload, bounded; empty when it has none. */
@@ -733,6 +781,11 @@ function reduceEventItems(state, event) {
733
781
  });
734
782
  }
735
783
  case "tool_progress": {
784
+ if (isForgeProgress(d.data)) {
785
+ const row = state.items.findLast((i) => i.kind === "tool" && i.id === d.id);
786
+ const before = row?.kind === "tool" ? row.forge : undefined;
787
+ return updateTool(state, d.id, { forge: foldForgeProgress(before, d.data) });
788
+ }
736
789
  const line = lastProgressLine(d.data);
737
790
  return line ? updateTool(state, d.id, { progress: line }) : state;
738
791
  }
@@ -757,6 +810,9 @@ function reduceEventItems(state, event) {
757
810
  status: d.isError ? "error" : "ok",
758
811
  ...(typeof d.durationMs === "number" ? { durationMs: d.durationMs } : {}),
759
812
  ...(typeof d.preview === "string" ? { preview: d.preview } : {}),
813
+ ...(running?.kind === "tool" && !running.forge && engineOfResult(d.preview)
814
+ ? { forge: engineOfResult(d.preview) }
815
+ : {}),
760
816
  ...exitCodePart(d.preview),
761
817
  ...(isUiBlock(d.ui) ? { ui: d.ui } : {}),
762
818
  ...(isImagePart(d.image) ? { image: d.image } : {}),
@@ -1186,6 +1242,7 @@ export function toolItemView(item) {
1186
1242
  kind: item.toolKind ?? toolKindOf(item.name),
1187
1243
  summary: item.summary,
1188
1244
  ...(item.progress ? { progress: item.progress } : {}),
1245
+ ...(forgeLabel(item.forge) ? { forge: forgeLabel(item.forge) } : {}),
1189
1246
  status: item.status,
1190
1247
  ...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}),
1191
1248
  ...(item.preview !== undefined ? { preview: item.preview } : {}),
@@ -1351,11 +1408,13 @@ export function itemsFromHistory(messages) {
1351
1408
  items.push({ kind: "assistant", text: m.text, reasoning: "", done: true });
1352
1409
  for (const c of m.calls) {
1353
1410
  const r = results.get(c.id);
1354
- const preview = r?.result.content
1411
+ const text = r?.result.content
1355
1412
  .filter((p) => p.type === "text")
1356
1413
  .map((x) => x.text)
1357
- .join("\n")
1358
- .slice(0, 2_000);
1414
+ .join("\n");
1415
+ const preview = text?.slice(0, 2_000);
1416
+ // The engine line closes a python_run result (PD11); read it before the preview clips it.
1417
+ const forge = c.name === "python_run" ? engineOfResult(text) : undefined;
1359
1418
  items.push({
1360
1419
  kind: "tool",
1361
1420
  id: c.id,
@@ -1365,6 +1424,7 @@ export function itemsFromHistory(messages) {
1365
1424
  status: r?.result.isError ? "error" : "ok",
1366
1425
  preview,
1367
1426
  toolKind: toolKindOf(c.name),
1427
+ ...(forge ? { forge } : {}),
1368
1428
  ...exitCodePart(preview),
1369
1429
  ...richPartsOf(r?.result, c.name),
1370
1430
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/alisio-code",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Alisio: an extensible, provider-agnostic coding-agent harness for your terminal. TUI, OpenAI-compatible providers, permissioned local tools, context compaction, persistent memory and a typed plugin SDK.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -45,12 +45,12 @@
45
45
  "alisio": "./dist/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/core": "0.5.1",
49
- "@alisio/plugin-memory": "0.5.1",
50
- "@alisio/plugin-openai-compatible": "0.5.1",
51
- "@alisio/plugin-subagents": "0.5.1",
52
- "@alisio/sdk": "0.5.1",
53
- "@alisio/server": "0.5.1",
48
+ "@alisio/core": "0.6.0",
49
+ "@alisio/plugin-memory": "0.6.0",
50
+ "@alisio/plugin-openai-compatible": "0.6.0",
51
+ "@alisio/plugin-subagents": "0.6.0",
52
+ "@alisio/sdk": "0.6.0",
53
+ "@alisio/server": "0.6.0",
54
54
  "@earendil-works/pi-tui": "0.87.1",
55
55
  "commander": "15.0.0"
56
56
  },