@alisio/alisio-code 0.1.0-alpha.24 → 0.1.0-alpha.26

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.
@@ -0,0 +1,27 @@
1
+ import type { AnalysisStatus } from "@alisio/sdk";
2
+ type Common = {
3
+ db?: string;
4
+ python?: string;
5
+ cwd?: string;
6
+ config?: string;
7
+ trustProject?: boolean;
8
+ };
9
+ /** The unified runtime state and, when Python is missing, the install guidance for this system. */
10
+ export declare function analysisState(o: Common): Promise<{
11
+ status: AnalysisStatus;
12
+ guidance?: string;
13
+ ignored: string[];
14
+ }>;
15
+ /** Prints `alisio analysis status`. */
16
+ export declare function printAnalysisStatus(o: Common): Promise<void>;
17
+ /** `alisio analysis setup --extras analysis|science`: needs network; offline it fails cleanly. */
18
+ export declare function setupExtras(o: Common, extras: string | undefined): Promise<void>;
19
+ /**
20
+ * `alisio analysis setup --oci [--image <name>]`: pulls the image once and verifies it. With a
21
+ * digest-pinned reference the pull verifies the digest; with a tag it prints the digest to put in
22
+ * `analysis.oci.image` (Alisio never writes it: only you choose what runs your code).
23
+ */
24
+ export declare function setupOci(o: Common, image: string | undefined): Promise<void>;
25
+ /** `alisio analysis sweep [--force]`: the retention sweep now (it also runs by itself, daily). */
26
+ export declare function sweep(o: Common, force: boolean): Promise<void>;
27
+ export {};
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `alisio analysis …` and the analysis line of `alisio doctor`: the state of the Python runtime,
3
+ * the optional extras (`setup --extras`, hash-locked, needs network), the optional container
4
+ * runtime (`setup --oci`) and the retention sweep (`sweep`). None of these is needed to run
5
+ * `python_run`: the base runtime is the discovered Python with only the standard library.
6
+ */
7
+ import { dirname, join } from "node:path";
8
+ async function context(o) {
9
+ const core = await import("@alisio/core");
10
+ const workspace = await core.findWorkspace(o.cwd ?? process.cwd());
11
+ const { config, provenance } = await core.loadConfigWithProvenance(workspace, {
12
+ file: o.config,
13
+ trustProject: o.trustProject,
14
+ });
15
+ const stateDir = o.db && o.db !== ":memory:" ? dirname(o.db) : core.stateHome();
16
+ const manager = new core.AnalysisRuntimeManager({
17
+ stateDir,
18
+ ...(o.python ? { python: o.python } : {}),
19
+ });
20
+ const oci = () => new core.OciRuntime({ ...config.analysis.oci });
21
+ return { core, config, provenance, stateDir, manager, oci };
22
+ }
23
+ /** The unified runtime state and, when Python is missing, the install guidance for this system. */
24
+ export async function analysisState(o) {
25
+ const { core, config, provenance, stateDir, manager, oci } = await context(o);
26
+ const janitor = {
27
+ lastSweep: () => import("node:fs/promises")
28
+ .then((fs) => fs.readFile(join(stateDir, "analysis", ".last-sweep"), "utf8"))
29
+ .then((text) => {
30
+ const value = Number(text.trim());
31
+ return Number.isFinite(value) ? value : undefined;
32
+ })
33
+ .catch(() => undefined),
34
+ };
35
+ const status = await core.analysisStatus({
36
+ config: config.analysis,
37
+ runtime: manager,
38
+ oci,
39
+ janitor,
40
+ enabled: config.analysis.enabled,
41
+ readOnly: false,
42
+ });
43
+ const guidance = status.python.found
44
+ ? undefined
45
+ : (async () => {
46
+ const resolved = await manager.interpreter();
47
+ return resolved.ok
48
+ ? undefined
49
+ : core.installHintsText(core.pythonInstallHints(await core.detectHintHost(resolved.found)));
50
+ })();
51
+ return {
52
+ status,
53
+ ...(guidance ? { guidance: await guidance } : {}),
54
+ ignored: provenance.ignored ?? [],
55
+ };
56
+ }
57
+ /** Prints `alisio analysis status`. */
58
+ export async function printAnalysisStatus(o) {
59
+ const { status, guidance, ignored } = await analysisState(o);
60
+ console.log(JSON.stringify(status, null, 2));
61
+ if (guidance)
62
+ process.stderr.write(`\n${guidance}\n`);
63
+ if (status.mode === "oci" && status.oci.available === false)
64
+ process.stderr.write(`\nThe container runtime is configured but not usable: ${status.oci.reason ?? "unknown reason"}\n`);
65
+ for (const key of ignored)
66
+ process.stderr.write(`\nIgnored ${key} from the project configuration: only your user configuration decides it.\n`);
67
+ }
68
+ /** `alisio analysis setup --extras analysis|science`: needs network; offline it fails cleanly. */
69
+ export async function setupExtras(o, extras) {
70
+ if (extras !== "analysis" && extras !== "science")
71
+ throw new Error("Choose the extras to install: --extras analysis or --extras science");
72
+ const { core, manager } = await context(o);
73
+ const preview = core.installPreview(extras);
74
+ const controller = new AbortController();
75
+ process.on("SIGINT", () => controller.abort(new Error("Interrupted")));
76
+ process.stderr.write(`Installing the ${extras} extras: ${preview.packages.join(", ")} (+ dependencies, ${preview.packageCount} packages, about ${Math.round(preview.estimatedBytes / (1024 * 1024))} MB). ` +
77
+ "Wheels only, hashes required, needs network.\n");
78
+ try {
79
+ const state = await manager.installExtras(extras, {
80
+ signal: controller.signal,
81
+ onOutput: (text) => process.stderr.write(text),
82
+ });
83
+ console.log(JSON.stringify(state, null, 2));
84
+ }
85
+ catch (error) {
86
+ // A clean message and a failing exit code; the base runtime is untouched.
87
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
88
+ process.exitCode = 1;
89
+ }
90
+ }
91
+ /**
92
+ * `alisio analysis setup --oci [--image <name>]`: pulls the image once and verifies it. With a
93
+ * digest-pinned reference the pull verifies the digest; with a tag it prints the digest to put in
94
+ * `analysis.oci.image` (Alisio never writes it: only you choose what runs your code).
95
+ */
96
+ export async function setupOci(o, image) {
97
+ const { core, config } = await context(o);
98
+ const reference = image ?? config.analysis.oci.image;
99
+ if (!reference)
100
+ throw new Error("Pass --image <name[:tag]> (or set analysis.oci.image in your user configuration) to pull.");
101
+ const runtime = new core.OciRuntime({ ...config.analysis.oci, image: undefined });
102
+ const status = await runtime.status();
103
+ if (!status.ok)
104
+ throw new Error(`${status.reason}${status.alternative ? `; ${status.alternative} is installed (set analysis.oci.engine to "${status.alternative}")` : ""}. The container runtime is optional; python_run works without it.`);
105
+ process.stderr.write(`Pulling ${reference} with ${status.engine} ${status.version}…\n`);
106
+ const controller = new AbortController();
107
+ process.on("SIGINT", () => controller.abort(new Error("Interrupted")));
108
+ const { digest } = await runtime.pull(reference, controller.signal);
109
+ const pinned = core.OCI_IMAGE_PATTERN.test(reference);
110
+ if (pinned) {
111
+ const wanted = reference.slice(reference.indexOf("@") + 1);
112
+ if (digest && !digest.endsWith(wanted))
113
+ throw new Error(`The pulled image reports ${digest}, not the pinned ${wanted}`);
114
+ console.log(`Verified ${reference}. Set analysis.runtime to "oci" to use it.`);
115
+ }
116
+ else if (digest)
117
+ console.log(`Pulled ${reference}. To use it, put this in your user configuration (${core.configHome()}):\n` +
118
+ ` "analysis": { "runtime": "oci", "oci": { "engine": "${status.engine}", "image": "${digest}" } }`);
119
+ else
120
+ throw new Error(`${reference} has no repository digest (a locally built image?). Push it to a registry or pull a published image.`);
121
+ }
122
+ /** `alisio analysis sweep [--force]`: the retention sweep now (it also runs by itself, daily). */
123
+ export async function sweep(o, force) {
124
+ const { core, config, stateDir } = await context(o);
125
+ const store = new core.SQLiteStore(o.db ?? join(stateDir, "sessions.sqlite"));
126
+ try {
127
+ const artifacts = new core.ArtifactStore({ root: stateDir, db: store.db });
128
+ const datasets = new core.DatasetService({ root: stateDir, db: store.db });
129
+ const blobs = new core.BlobStore({ root: join(stateDir, "blobs"), db: store.db });
130
+ const janitor = new core.AnalysisJanitor({
131
+ root: stateDir,
132
+ db: store.db,
133
+ artifacts,
134
+ datasets,
135
+ blobs,
136
+ retention: () => config.analysis.retention,
137
+ });
138
+ const report = await janitor.sweep({ force });
139
+ console.log(JSON.stringify(report, null, 2));
140
+ datasets.close();
141
+ }
142
+ finally {
143
+ store.close();
144
+ }
145
+ }
package/dist/banner.js CHANGED
@@ -21,7 +21,8 @@ export function terminalCapabilities(input) {
21
21
  const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
22
22
  return {
23
23
  color: input.tty && !dumb && env.NO_COLOR === undefined,
24
- unicode: !dumb && !/^(C|POSIX)(\.|$)/i.test(locale),
24
+ // `C.UTF-8` is a UTF-8 locale (the default on CI runners); only bare C/POSIX is ASCII.
25
+ unicode: !dumb && (/utf-?8/i.test(locale) || !/^(C|POSIX)(\.|$)/i.test(locale)),
25
26
  columns: input.columns > 0 ? input.columns : 80,
26
27
  interactive: input.tty,
27
28
  };
package/dist/main.js CHANGED
@@ -35,6 +35,8 @@ program
35
35
  .option("--api-mode <mode>", "chat or responses")
36
36
  .option("--allow-write", "Allow file writes")
37
37
  .option("--allow-process", "Allow arbitrary subprocesses; not sandboxed")
38
+ .option("--allow-analysis", "Allow Python analysis (python_run) without asking; not sandboxed, never allows shell")
39
+ .option("--python <path>", "Python 3.10+ interpreter for python_run (default: discovered)")
38
40
  .option("--allow-external", "Allow network tools: webfetch, websearch and provider-native search")
39
41
  .option("--allow-mcp", "Allow configured MCP servers and remote tool calls")
40
42
  .option("--allow-agents", "Allow messaging neighboring agents through Herdr")
@@ -116,7 +118,7 @@ async function run(cmd, prompt, sessionId) {
116
118
  const trusted = await withProjectTrust(opts);
117
119
  return runTui({ ...trusted, ...(sessionId ? { session: sessionId } : {}) });
118
120
  }
119
- const { createApplication } = await import("@alisio/core");
121
+ const { createApplication, formatBytes, KIND_LABELS } = await import("@alisio/core");
120
122
  const app = await createApplication({
121
123
  ...(await cliDefaults()),
122
124
  ...opts,
@@ -127,8 +129,15 @@ async function run(cmd, prompt, sessionId) {
127
129
  process.stdout.write(String(event.data.delta));
128
130
  else if (event.type === "tool_started")
129
131
  process.stderr.write(`\n→ ${event.data.name}\n`);
132
+ else if (event.type === "artifact_published")
133
+ process.stderr.write(artifactLine(event));
130
134
  },
131
135
  });
136
+ /** `artifact: Dashboard site.zip (48 KB) → /abs/path` (nothing is opened automatically). */
137
+ function artifactLine(event) {
138
+ const data = event.data;
139
+ return `artifact: ${KIND_LABELS[data.artifact.kind] ?? "File"} ${data.artifact.fileName} (${formatBytes(data.artifact.bytes)}) → ${data.path}\n`;
140
+ }
132
141
  for (const failure of app.mcpStartupFailures())
133
142
  process.stderr.write(`[startup] ${failure}\n`);
134
143
  // The ACTIVE agent (built-in `build`/`plan` or a main-capable definition) drives the main
@@ -349,13 +358,61 @@ program.command("doctor").action(async (_opts, cmd) => {
349
358
  : config.provider.auth === "none" || !!process.env[config.provider.apiKeyEnv],
350
359
  },
351
360
  };
352
- console.log(JSON.stringify(status, null, 2));
361
+ const { analysisState } = await import("./analysis.js");
362
+ const analysisInfo = await analysisState(o);
363
+ const { hints: _hints, ...pythonInfo } = analysisInfo.status.python;
364
+ console.log(JSON.stringify({
365
+ ...status,
366
+ python: { mode: analysisInfo.status.mode, ...pythonInfo },
367
+ analysis: {
368
+ mode: analysisInfo.status.mode,
369
+ oci: analysisInfo.status.oci,
370
+ limits: analysisInfo.status.limits,
371
+ retention: analysisInfo.status.retention,
372
+ ...(analysisInfo.ignored.length ? { ignored: analysisInfo.ignored } : {}),
373
+ },
374
+ }, null, 2));
375
+ if (analysisInfo.guidance)
376
+ process.stderr.write(`\n${analysisInfo.guidance}\n`);
377
+ for (const key of provenance.ignored ?? [])
378
+ process.stderr.write(`\nIgnored ${key} from the project configuration: only your user configuration decides it.\n`);
353
379
  if (!status.ripgrep)
354
380
  process.stderr.write(`\nWarning: ripgrep (rg) is not installed; search_text and list_files will not work. ${RIPGREP_INSTALL_HINT}\n`);
355
381
  if (!model || model === "YOUR_MODEL_ID")
356
382
  console.error("\nNo model configured yet: set provider.model in your config, --model, or ALISIO_MODEL " +
357
383
  "before starting a real conversation (it will otherwise fail on the first turn).");
358
384
  });
385
+ const analysis = program
386
+ .command("analysis")
387
+ .description("Python analysis runtime (python_run): status, optional extras and containers");
388
+ analysis
389
+ .command("status")
390
+ .description("Show the runtime: Python, extras, container engine, limits and retention")
391
+ .action(async (_opts, cmd) => {
392
+ const { printAnalysisStatus } = await import("./analysis.js");
393
+ await printAnalysisStatus(options(cmd));
394
+ });
395
+ analysis
396
+ .command("setup")
397
+ .description("Optional: install hash-locked Python extras (--extras) or pull the container image (--oci). " +
398
+ "python_run never needs this for the standard library.")
399
+ .option("--extras <set>", "analysis (pandas, numpy, matplotlib…) or science (adds scipy…)")
400
+ .option("--oci", "Pull the container image once and verify its digest (Docker or Podman)")
401
+ .option("--image <name>", "With --oci: the image to pull (default: analysis.oci.image)")
402
+ .action(async (flags, cmd) => {
403
+ const { setupExtras, setupOci } = await import("./analysis.js");
404
+ if (flags.oci)
405
+ return setupOci(options(cmd), flags.image);
406
+ return setupExtras(options(cmd), flags.extras);
407
+ });
408
+ analysis
409
+ .command("sweep")
410
+ .description("Run the retention sweep now (it also runs by itself at most once a day)")
411
+ .option("--force", "Ignore the once-a-day mark")
412
+ .action(async (flags, cmd) => {
413
+ const { sweep } = await import("./analysis.js");
414
+ await sweep(options(cmd), !!flags.force);
415
+ });
359
416
  const trust = program.command("trust").description("Inspect or revoke per-directory project trust");
360
417
  trust.command("list").action(async () => {
361
418
  const { listTrust } = await import("@alisio/core");
package/dist/tui/app.js CHANGED
@@ -1,16 +1,20 @@
1
1
  import { homedir } from "node:os";
2
- import { AGENT_TEMPLATES, agentAuthoringGuidance, agentCommandDescriptors, agentIdFromCommand, agentModelCapabilities, agentModelLabels, agentModelOptions, generateAgentDraft, } from "@alisio/core";
2
+ import { join } from "node:path";
3
+ import { AGENT_TEMPLATES, agentAuthoringGuidance, agentCommandDescriptors, agentIdFromCommand, agentModelCapabilities, agentModelLabels, agentModelOptions, generateAgentDraft, NOT_SANDBOXED, toGrantWire, toRef, } from "@alisio/core";
3
4
  import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, MouseRegion, matchesKey, ProcessTerminal, ScrollView, SelectList, TuiAltScreen, truncateToWidth, VStack, } from "@earendil-works/pi-tui";
4
5
  import { loadVersion } from "../version.js";
5
6
  import { AGENT_PICKER_ACTIONS, agentEditorRows, agentFieldChoices, agentFieldSupported, agentPickerRows, applyDraft, decodeMultiline, emptyAgentForm, encodeMultiline, filterAgentRows, formErrors, formFromDefinition, formFromTemplate, formToInput, parseAgentsArgs, } from "./agent-manager.js";
6
7
  import { activeAgentCatalog, agentRunOptions, mainAgentFromRecord, resolveActiveAgent, } from "./agents.js";
8
+ import { ArtifactPreview, loadPreview } from "./artifact-preview.js";
9
+ import { ARTIFACTS_HINT, artifactActions, artifactRow, capabilityApprovalTitle, detailRows, installApprovalDetail, installApprovalTitle, matchArtifact, permissionRow, } from "./artifacts.js";
7
10
  import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard, removeLastAttachment, toApiAttachment, } from "./attachments.js";
8
11
  import { BTW_DESCRIPTION, BTW_USAGE, btwBrowser, btwPending } from "./btw.js";
9
12
  import { copyText, nodeSpawn } from "./clipboard.js";
10
- import { AttachmentsBar, BannerBlock, BtwPanel, ContentInset, clock, contentInnerWidth, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
13
+ import { AttachmentsBar, BannerBlock, BtwPanel, ContentInset, clock, contentInnerWidth, Footer, Header, QuestionPanel, Switch, setArtifactPathResolver, TranscriptSync, TreePanel, } from "./components.js";
11
14
  import { ConnectInputPrompt } from "./connect-input.js";
12
15
  import { bounded, EXIT_PENDING_CAP_MS, EXIT_SESSION_END_CAP_MS } from "./exit.js";
13
16
  import { BRANCH_REFRESH_MS, createBranchCache } from "./git-branch.js";
17
+ import { cannotOpenMessage, openPath, revealPath } from "./open-path.js";
14
18
  import { initialPanelState, reducePanel, visibleRows } from "./panel.js";
15
19
  import { summarizeAnswers } from "./questions.js";
16
20
  import { InteractiveQueue } from "./queue.js";
@@ -479,6 +483,12 @@ export async function runTui(options) {
479
483
  const error = (e) => push({ kind: "error", text: e instanceof Error ? e.message : String(e) });
480
484
  const info = (text) => push({ kind: "info", text });
481
485
  let terminalEvent = false;
486
+ let artifactsThisRun = 0;
487
+ // Artifact announcements show the real local file (resolved from the store, never the model).
488
+ setArtifactPathResolver((id) => {
489
+ const record = app.artifacts.get(id);
490
+ return record?.status === "ready" ? app.artifacts.localPath(record) : undefined;
491
+ });
482
492
  dispatch = (event) => {
483
493
  // The event stream stays text-only (headless/JSONL consumers see the projection); the TUI
484
494
  // pulls the persisted rich parts (ui/image) itself so native rendering survives resume.
@@ -497,15 +507,22 @@ export async function runTui(options) {
497
507
  tui.requestRender();
498
508
  return;
499
509
  }
500
- if ([
510
+ if (event.type === "artifact_published")
511
+ artifactsThisRun++;
512
+ const ended = [
501
513
  "run_completed",
502
514
  "run_failed",
503
515
  "run_cancelled",
504
516
  "run_turns_exceeded",
505
517
  "compaction_failed",
506
- ].includes(event.type))
518
+ ].includes(event.type);
519
+ if (ended)
507
520
  terminalEvent = true;
508
521
  view = reduceEvent(view, event);
522
+ if (ended && event.type !== "compaction_failed" && artifactsThisRun > 0) {
523
+ artifactsThisRun = 0;
524
+ view = addItem(view, { kind: "notice", text: ARTIFACTS_HINT });
525
+ }
509
526
  sync();
510
527
  };
511
528
  const refreshEstimate = () => {
@@ -562,6 +579,31 @@ export async function runTui(options) {
562
579
  };
563
580
  const onAbort = () => finish("deny");
564
581
  request.signal.addEventListener("abort", onAbort, { once: true });
582
+ if (request.capability === "analysis.install" && request.install) {
583
+ // Installing the optional packages: only once or deny (never stored), with what is
584
+ // installed, the download estimate and that it needs the network.
585
+ showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}${installApprovalTitle(request.install)}`, [
586
+ { value: "once", label: "Allow once" },
587
+ { value: "deny", label: "Deny" },
588
+ ], (item) => finish(item.value), () => finish("deny"), false, installApprovalDetail(request.install)));
589
+ return;
590
+ }
591
+ if (request.capability) {
592
+ // A capability (Python analysis): its own title, the script preview and the
593
+ // not-a-sandbox warning; "for this session" is persisted for the root session.
594
+ showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}${capabilityApprovalTitle(request.runtime)}`, [
595
+ { value: "once", label: "Allow once" },
596
+ { value: "session", label: "Allow for this session" },
597
+ { value: "deny", label: "Deny" },
598
+ ], (item) => finish(item.value), () => finish("deny"), false, [
599
+ request.preview ?? "",
600
+ "",
601
+ request.runtime === "oci"
602
+ ? "Runs in a container without network access. Repository files are not modified by this step."
603
+ : `${NOT_SANDBOXED} It could change repository files; Alisio itself does not modify them in this step.`,
604
+ ].join("\n")));
605
+ return;
606
+ }
565
607
  showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}Allow ${request.call.name} (${request.effect}): ${summarizeToolArgs(request.call.name, request.call.arguments)}?`, [
566
608
  { value: "once", label: "Allow once" },
567
609
  { value: "session", label: `Always allow ${request.effect} in this session` },
@@ -684,6 +726,156 @@ export async function runTui(options) {
684
726
  tui.requestRender();
685
727
  }
686
728
  };
729
+ /** `/artifacts [filter]`: the root session's artifacts, newest first, then their actions. */
730
+ const browseArtifacts = (filter) => {
731
+ const all = app.artifacts
732
+ .list(app.store.rootOf(session), { limit: 500, includeExpired: true })
733
+ .map(toRef);
734
+ const shown = filter ? all.filter((artifact) => matchArtifact(artifact, filter)) : all;
735
+ if (!shown.length)
736
+ return notice(all.length ? `No artifacts match "${filter}"` : "No artifacts in this session yet.");
737
+ const byId = new Map(all.map((artifact) => [artifact.id, artifact]));
738
+ const now = Date.now();
739
+ showPicker(new Picker(`Artifacts · ${shown.length}`, shown.map((artifact) => ({ value: artifact.id, label: artifactRow(artifact, now) })), (item) => artifactMenu(item.value, filter), () => closePicker(), true, undefined, (items, text) => items.filter((item) => {
740
+ const artifact = byId.get(item.value);
741
+ return !!artifact && matchArtifact(artifact, text);
742
+ })));
743
+ };
744
+ const artifactMenu = (id, filter) => {
745
+ const record = app.artifacts.get(id);
746
+ if (!record || (record.status !== "ready" && record.status !== "expired")) {
747
+ closePicker();
748
+ return notice("This artifact is no longer available.");
749
+ }
750
+ const artifact = toRef(record);
751
+ const path = app.artifacts.localPath(record);
752
+ const back = () => artifactMenu(id, filter);
753
+ const copyPath = async () => {
754
+ const result = await copy(path);
755
+ closePicker();
756
+ if (result.ok)
757
+ tui.flash("Path copied");
758
+ else
759
+ notice(`${copyMessage(result)}. Path: ${path}`);
760
+ };
761
+ /** Opens (or reveals) a store path; without a desktop, offers to copy it instead. */
762
+ const launch = async (target, reveal) => {
763
+ const outcome = await (reveal ? revealPath(target) : openPath(target));
764
+ if (outcome.ok)
765
+ return closePicker();
766
+ // No desktop (SSH, no display) or no opener: nothing was launched; offer the path.
767
+ return showPicker(new Picker(cannotOpenMessage(target, outcome), [
768
+ { value: "copy", label: "Copy path" },
769
+ { value: "close", label: "Close" },
770
+ ], (choice) => choice.value === "copy"
771
+ ? void copy(target).then((result) => {
772
+ closePicker();
773
+ if (result.ok)
774
+ tui.flash("Path copied");
775
+ else
776
+ notice(`${copyMessage(result)}. Path: ${target}`);
777
+ })
778
+ : closePicker(), () => closePicker()));
779
+ };
780
+ // `Copy to workspace…` needs artifact_export (absent under --read-only or with analysis off).
781
+ const canExport = !options.readOnly && app.registry.list().some((tool) => tool.name === "artifact_export");
782
+ const job = record.executionId ? app.analysis.jobs.get(record.executionId) : undefined;
783
+ // `Rerun` runs python_run { rerunOf } (absent under --read-only or with analysis disabled).
784
+ const canRerun = !options.readOnly && app.registry.list().some((tool) => tool.name === "python_run");
785
+ showPicker(new Picker(artifact.fileName, artifactActions(artifact, {
786
+ readOnly: !canExport,
787
+ sources: !!job,
788
+ rerun: !!job && canRerun,
789
+ }), (item) => void (async () => {
790
+ if (item.value === "copy")
791
+ return copyPath();
792
+ if (item.value === "open" || item.value === "reveal")
793
+ return launch(path, item.value === "reveal");
794
+ if (item.value === "preview") {
795
+ const preview = await loadPreview(artifact, path);
796
+ return showPicker(new ArtifactPreview(artifact, preview, {
797
+ height: () => Math.max(5, (process.stdout.rows ?? 24) - 12),
798
+ open: () => void launch(path, false).catch(error),
799
+ copy: () => void copyPath().catch(error),
800
+ close: back,
801
+ }));
802
+ }
803
+ if (item.value === "sources" && job)
804
+ // An explicit user action: the job folder (script, logs, work) of this execution.
805
+ return launch(join(app.artifacts.root, ...job.relDir.split(/[\\/]/)), false);
806
+ if (item.value === "details") {
807
+ const provenance = record.provenance;
808
+ return showPicker(new Picker(`Details · ${artifact.fileName}`, [{ value: "back", label: "Back" }], back, back, false, detailRows(artifact, provenance)
809
+ .map(([label, value]) => `${label}: ${value}`)
810
+ .join("\n")));
811
+ }
812
+ if (item.value === "rerun") {
813
+ if (busy) {
814
+ closePicker();
815
+ return notice("Wait until the current run finishes, then run the analysis again.");
816
+ }
817
+ closePicker();
818
+ info(`Run the analysis of ${artifact.fileName} again`);
819
+ // A tool call without the model: the same capability gate as the first run.
820
+ void task((signal) => app.runner.runToolCall(session, "python_run", { rerunOf: id }, { signal, display: `Run the analysis of ${artifact.fileName} again` }));
821
+ return;
822
+ }
823
+ if (item.value === "export") {
824
+ if (busy) {
825
+ closePicker();
826
+ return notice("Wait until the current run finishes, then copy the artifact.");
827
+ }
828
+ return showPicker(new ConnectInputPrompt({
829
+ provider: "",
830
+ title: `Copy ${artifact.fileName} to the workspace`,
831
+ label: "Folder in the workspace",
832
+ initial: ".",
833
+ secret: false,
834
+ placeholder: ".",
835
+ hint: "Relative to the workspace. Writing asks first unless --allow-write.",
836
+ step: 1,
837
+ steps: 1,
838
+ onSubmit: (target) => {
839
+ closePicker();
840
+ info(`Copy ${artifact.fileName} to the workspace (${target.trim() || "."})`);
841
+ // A tool call without the model: the write gate (approval) applies as usual.
842
+ void task((signal) => app.runner.runToolCall(session, "artifact_export", { id, target: target.trim() || "." }, {
843
+ signal,
844
+ display: `Copy ${artifact.fileName} to the workspace (${target.trim() || "."})`,
845
+ }));
846
+ },
847
+ onCancel: back,
848
+ }));
849
+ }
850
+ if (item.value === "delete")
851
+ return showPicker(new Picker(`Delete ${artifact.fileName}? This can't be undone.`, [
852
+ { value: "delete", label: "Delete" },
853
+ { value: "cancel", label: "Cancel" },
854
+ ], (choice) => void (async () => {
855
+ if (choice.value !== "delete")
856
+ return back();
857
+ await app.artifacts.delete(id);
858
+ closePicker();
859
+ notice(`Deleted ${artifact.fileName}`);
860
+ })(), back));
861
+ })().catch(error), () => browseArtifacts(filter)));
862
+ };
863
+ /** `/permissions`: persisted grants of the root session; choosing one offers to revoke it. */
864
+ const managePermissions = () => {
865
+ const grants = app.capabilityGrants.live(session).map(toGrantWire);
866
+ if (!grants.length)
867
+ return notice("No saved permissions in this session.");
868
+ showPicker(new Picker("Session permissions", grants.map((grant) => ({ value: grant.id, label: permissionRow(grant) })), (item) => showPicker(new Picker(`Revoke "${item.label}"?`, [
869
+ { value: "revoke", label: "Revoke" },
870
+ { value: "cancel", label: "Cancel" },
871
+ ], (choice) => {
872
+ if (choice.value !== "revoke")
873
+ return managePermissions();
874
+ app.capabilityGrants.revoke(session, item.value, "tui");
875
+ closePicker();
876
+ notice("Permission revoked: the next Python analysis asks again.");
877
+ }, () => managePermissions())), () => closePicker()));
878
+ };
687
879
  const runPrompt = (display, prompt, persistDisplay) => {
688
880
  // Any pending clipboard-pasted images ride along with the very next turn, then are cleared.
689
881
  const attachments = pendingAttachments.map(toApiAttachment);
@@ -1740,7 +1932,9 @@ export async function runTui(options) {
1740
1932
  .finally(settle);
1741
1933
  return;
1742
1934
  }
1743
- const stored = row.id === "limits.timeoutMs" ? Number(value) * 1000 : value;
1935
+ const stored = row.id === "limits.timeoutMs" || row.id === "analysis.limits.timeoutMs"
1936
+ ? Number(value) * 1000
1937
+ : value;
1744
1938
  void app
1745
1939
  // Setting ids are the exact SettableSettingKey paths SETTINGS_DEFINITIONS is built from.
1746
1940
  .updateSetting(row.id, stored)
@@ -2026,6 +2220,10 @@ export async function runTui(options) {
2026
2220
  }
2027
2221
  case "btw":
2028
2222
  return await sideQuestion(parsed.args);
2223
+ case "artifacts":
2224
+ return browseArtifacts(parsed.args);
2225
+ case "permissions":
2226
+ return managePermissions();
2029
2227
  case "ask": {
2030
2228
  if (!parsed.args)
2031
2229
  return notice("Usage: /ask <question>");
@@ -0,0 +1,52 @@
1
+ import type { ArtifactRef, UiBlock } from "@alisio/sdk";
2
+ import { type Component } from "@earendil-works/pi-tui";
3
+ export declare const PREVIEW_MAX_BYTES: number;
4
+ export declare const PREVIEW_MAX_LINES = 2000;
5
+ export declare const PREVIEW_MAX_ROWS = 200;
6
+ export declare const PREVIEW_MAX_COLUMNS = 20;
7
+ export type PreviewKind = "markdown" | "table" | "json" | "text";
8
+ /** How an artifact previews in the terminal, or undefined when it only opens externally. */
9
+ export declare function previewKind(artifact: ArtifactRef): PreviewKind | undefined;
10
+ export interface Preview {
11
+ block: UiBlock;
12
+ truncated: boolean;
13
+ /** Table legend: `showing 200 of 12,480 rows · 20 of 31 columns`. */
14
+ legend?: string;
15
+ }
16
+ /**
17
+ * RFC 4180 rows (quotes, doubled quotes, newlines inside quotes, CRLF/LF/CR), up to `limit`, with
18
+ * the same parser the dataset ingestion uses (so the preview and `data_inspect` agree).
19
+ */
20
+ export declare function parseDelimited(text: string, delimiter: string, limit?: number): {
21
+ rows: string[][];
22
+ total: number;
23
+ complete: boolean;
24
+ };
25
+ /** The preview of an artifact from its leading bytes (the caller reads them, bounded). */
26
+ export declare function buildPreview(artifact: ArtifactRef, bytes: Uint8Array, fileBytes?: number): Preview;
27
+ /** Reads the bytes a preview needs (never the whole of a large file). */
28
+ export declare function loadPreview(artifact: ArtifactRef, path: string): Promise<Preview>;
29
+ /**
30
+ * A bounded, scrollable preview panel: ↑/↓, PgUp/PgDn, Home/End scroll; `o` opens with the
31
+ * system app, `c` copies the path, `Esc` or `q` closes. The footer says `truncated` when cut.
32
+ */
33
+ export declare class ArtifactPreview implements Component {
34
+ private readonly artifact;
35
+ private readonly preview;
36
+ private readonly options;
37
+ private offset;
38
+ private lines;
39
+ private renderedWidth;
40
+ constructor(artifact: ArtifactRef, preview: Preview, options: {
41
+ /** Rows available for the body (the panel adds a title and a footer). */
42
+ height: () => number;
43
+ open: () => void;
44
+ copy: () => void;
45
+ close: () => void;
46
+ });
47
+ invalidate(): void;
48
+ private page;
49
+ private clamp;
50
+ handleInput(data: string): void;
51
+ render(width: number): string[];
52
+ }