@nteract/pi 0.1.1 → 0.1.4

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.
package/README.md CHANGED
@@ -1,29 +1,53 @@
1
1
  # @nteract/pi
2
2
 
3
- Pi extensions for running Python through the local nteract `runtimed` daemon.
4
- The package provides a persistent notebook-backed REPL for coding agents and
5
- terminal workflows that need stateful Python execution.
3
+ **Persistent notebook-backed Python REPL for Pi coding agents.** Stateful execution, hot dependency sync, zero cold starts.
6
4
 
7
- ## Extensions
5
+ Run Python in a real IPython runtime. State (variables, imports, matplotlib figures) persists between agent turns. Perfect for data analysis, plotting, and multi-step workflows.
8
6
 
9
- - `extensions/repl.ts` registers a persistent `python` tool backed by
10
- `@runtimed/node`.
11
- - `python_add_dependencies` records notebook UV dependencies.
12
- - `python_save_notebook` saves the backing notebook.
13
- - `/python-reset` starts a fresh notebook session.
7
+ ## What you get
8
+
9
+ - **`python_repl`** — Execute Python in your persistent REPL. Backed by a real IPython runtime. Variables, imports, and state stick around between calls. The last expression is the result; use `print()` or `display()` for intermediate output. Images (matplotlib, PIL, widgets) are returned inline.
10
+
11
+ - **`python_add_dependencies`** — Install packages mid-session without restarting the kernel. Or pass `dependencies` on the first `python_repl` call to pre-install before kernel start.
12
+
13
+ - **`python_save_notebook`** — Save your session as an `.ipynb` file you can open in Jupyter or nteract.
14
+
15
+ - **`/python-reset`** — Start fresh: new kernel, clean slate.
14
16
 
15
17
  ## Install
16
18
 
17
19
  ```bash
18
- pi install npm:@nteract/pi@next
20
+ pi install npm:@nteract/pi
19
21
  ```
20
22
 
21
- Use the `next` tag for prerelease builds until the package is promoted to the
22
- default npm tag.
23
+ ## How it works
24
+
25
+ This extension uses **`@runtimed/node`**, the Node.js bindings for the nteract daemon (the same runtime that powers the nteract desktop app). If you have nteract installed, you already have everything you need. The daemon manages isolated Jupyter kernels and environments per working directory, handles dependency installation via `uv`, and keeps your Python state hot between agent calls.
26
+
27
+ ## Building your own
28
+
29
+ This extension is a starting point. Want more control? Build your own Pi extensions using `@runtimed/node`.
30
+
31
+ See [`packages/runtimed-node/README.md`](../../packages/runtimed-node/README.md) for the full API. The source for this extension ([`extensions/repl.ts`](./extensions/repl.ts)) shows how to wire it up to Pi's tool registration.
32
+
33
+ ### Inspecting and managing sessions
34
+
35
+ The daemon is controlled by the `runt` CLI (installed with nteract). Use it to inspect active sessions, open notebooks in the desktop app, or troubleshoot:
36
+
37
+ ```bash
38
+ # List active Python sessions
39
+ runt list
40
+
41
+ # Open a session in nteract Desktop
42
+ runt show <notebook-id>
43
+
44
+ # Check daemon status
45
+ runt daemon status
46
+ ```
23
47
 
24
- ## Local Use
48
+ ## Local development
25
49
 
26
- From this checkout:
50
+ From this repo:
27
51
 
28
52
  ```bash
29
53
  pi --extension ./plugins/nteract/pi/extensions/repl.ts
@@ -7,7 +7,11 @@
7
7
  *
8
8
  * Config (env vars):
9
9
  * NTERACT_RUNTIMED_NODE_PATH override the runtimed-node package path.
10
- * NTERACT_SOCKET_PATH override the daemon socket path.
10
+ *
11
+ * Daemon socket selection follows the runtimed-node contract:
12
+ * `RUNTIMED_SOCKET_PATH` overrides outright, `RUNTIMED_WORKSPACE_PATH` (or
13
+ * `RUNTIMED_DEV=1` plus a git checkout) selects the per-worktree dev daemon,
14
+ * otherwise the running channel is auto-detected.
11
15
  *
12
16
  * After editing, run `/reload` in pi.
13
17
  */
@@ -25,16 +29,19 @@ import { Type } from "typebox";
25
29
 
26
30
  type RuntimedNode = {
27
31
  defaultSocketPath(): string;
28
- socketPathForChannel(channel: "stable" | "nightly"): string;
32
+ PackageManager?: { Uv: "uv"; Conda: "conda"; Pixi: "pixi" };
29
33
  createNotebook(opts?: {
30
34
  runtime?: string;
31
35
  workingDir?: string;
32
36
  socketPath?: string;
33
37
  peerLabel?: string;
38
+ description?: string;
39
+ dependencies?: string[];
40
+ packageManager?: "uv" | "conda" | "pixi";
34
41
  }): Promise<Session>;
35
42
  openNotebook(
36
43
  notebookId: string,
37
- opts?: { socketPath?: string; peerLabel?: string },
44
+ opts?: { socketPath?: string; peerLabel?: string; description?: string },
38
45
  ): Promise<Session>;
39
46
  readParquetFile(
40
47
  filePath: string,
@@ -68,18 +75,44 @@ type JsOutput = {
68
75
 
69
76
  type CellResult = {
70
77
  cellId: string;
78
+ executionId: string;
71
79
  executionCount?: number;
72
80
  status: string; // "done" | "error" | "timeout" | "kernel_error"
73
81
  success: boolean;
74
- outputs: JsOutput[];
82
+ outputs?: JsOutput[];
83
+ };
84
+
85
+ type QueuedExecution = {
86
+ cellId: string;
87
+ executionId: string;
75
88
  };
76
89
 
77
90
  type Session = {
78
91
  readonly notebookId: string;
79
- runCell(source: string, opts?: { timeoutMs?: number; cellType?: string }): Promise<CellResult>;
80
- addUvDependency(pkg: string): Promise<void>;
92
+ runCell(
93
+ source: string,
94
+ opts?: { timeoutMs?: number; cellType?: string; onUpdate?: (progress: CellResult) => void },
95
+ ): Promise<CellResult>;
96
+ queueCell(source: string, opts?: { cellType?: string }): Promise<QueuedExecution>;
97
+ waitForExecution(
98
+ executionId: string,
99
+ opts?: { timeoutMs?: number; cellId?: string; onUpdate?: (progress: CellResult) => void },
100
+ ): Promise<CellResult>;
101
+ addUvDependency?(pkg: string): Promise<void>;
102
+ addDependencies?(
103
+ packages: string[],
104
+ opts?: { packageManager?: "uv" | "conda" | "pixi" },
105
+ ): Promise<void>;
106
+ getDependencyStatus?(): Promise<{ uv?: { dependencies: string[] }; fingerprint?: string }>;
107
+ getRuntimeStatus?(): Promise<{
108
+ status: string;
109
+ lifecycle: string;
110
+ errorReason?: string;
111
+ errorDetails?: string;
112
+ }>;
81
113
  syncEnvironment(): Promise<void>;
82
114
  saveNotebook(path?: string): Promise<void>;
115
+ shutdownNotebook?(): Promise<boolean>;
83
116
  close(): Promise<void>;
84
117
  };
85
118
 
@@ -105,15 +138,42 @@ function loadRuntimedNode(): RuntimedNode | null {
105
138
  return null;
106
139
  }
107
140
 
108
- function resolveSocketPath(rn: RuntimedNode): string {
109
- if (process.env.NTERACT_SOCKET_PATH) return process.env.NTERACT_SOCKET_PATH;
141
+ // --- bootstrap detection -----------------------------------------------------
142
+
143
+ const INSTALL_HINT =
144
+ "The nteract daemon (runtimed) isn't installed yet.\n" +
145
+ "Quick install: curl -fsSL https://sh.nteract.io | bash\n" +
146
+ "Or visit: https://nteract.io";
110
147
 
111
- for (const channel of ["nightly", "stable"] as const) {
112
- const socketPath = rn.socketPathForChannel(channel);
113
- if (existsSync(socketPath)) return socketPath;
148
+ type BootstrapStatus =
149
+ | { kind: "ready"; rn: RuntimedNode }
150
+ | { kind: "missing"; reason: "binding" | "daemon" };
151
+
152
+ function findOnPath(cmd: string): string | undefined {
153
+ const dirs = (process.env.PATH ?? "").split(path.delimiter);
154
+ for (const dir of dirs) {
155
+ if (!dir) continue;
156
+ const candidate = path.join(dir, cmd);
157
+ if (existsSync(candidate)) return candidate;
114
158
  }
159
+ return undefined;
160
+ }
115
161
 
116
- return rn.defaultSocketPath();
162
+ function detectBootstrap(): BootstrapStatus {
163
+ const rn = loadRuntimedNode();
164
+ if (!rn) return { kind: "missing", reason: "binding" };
165
+ // defaultSocketPath() already honors RUNTIMED_SOCKET_PATH and the dev /
166
+ // worktree-aware resolution from runt-workspace, so this is the single point
167
+ // of truth. The socket file only exists while the daemon is running, but the
168
+ // parent cache dir survives shutdowns, so dir-existence covers "installed
169
+ // but stopped".
170
+ const socket = rn.defaultSocketPath();
171
+ const installed =
172
+ existsSync(socket) ||
173
+ existsSync(path.dirname(socket)) ||
174
+ Boolean(findOnPath("runt")) ||
175
+ Boolean(findOnPath("runt-nightly"));
176
+ return installed ? { kind: "ready", rn } : { kind: "missing", reason: "daemon" };
117
177
  }
118
178
 
119
179
  // --- DataTable TUI component --------------------------------------------------
@@ -148,6 +208,63 @@ function formatStat(stats: ColumnStats | null): string {
148
208
  }
149
209
  }
150
210
 
211
+ const SPARK_CHARS = "▁▂▃▄▅▆▇█";
212
+
213
+ function sparkline(values: number[], width: number): string {
214
+ if (values.length === 0 || width <= 0) return "";
215
+ const min = Math.min(...values);
216
+ const max = Math.max(...values);
217
+ const bins = new Array(Math.min(width, 8)).fill(0);
218
+ if (min === max) {
219
+ return SPARK_CHARS[3].repeat(bins.length);
220
+ }
221
+ const range = max - min;
222
+ for (const v of values) {
223
+ const bi = Math.min(Math.floor(((v - min) / range) * bins.length), bins.length - 1);
224
+ bins[bi]++;
225
+ }
226
+ const maxCount = Math.max(...bins);
227
+ return bins
228
+ .map(
229
+ (c) =>
230
+ SPARK_CHARS[maxCount === 0 ? 0 : Math.round((c / maxCount) * (SPARK_CHARS.length - 1))],
231
+ )
232
+ .join("");
233
+ }
234
+
235
+ function sparklineForColumn(
236
+ rows: string[][],
237
+ ci: number,
238
+ stats: ColumnStats | null,
239
+ colType: string,
240
+ width: number,
241
+ ): string {
242
+ if (stats?.kind === "boolean") {
243
+ const t = stats.true_count ?? 0;
244
+ const f = stats.false_count ?? 0;
245
+ const total = t + f;
246
+ if (total === 0) return "";
247
+ const barW = Math.min(width, 8);
248
+ const filled = Math.round((t / total) * barW);
249
+ return (
250
+ SPARK_CHARS[SPARK_CHARS.length - 1].repeat(filled) + SPARK_CHARS[0].repeat(barW - filled)
251
+ );
252
+ }
253
+ if (stats?.kind === "numeric" || /int|float|decimal|uint/.test(colType)) {
254
+ const nums: number[] = [];
255
+ for (const row of rows) {
256
+ const n = parseFloat(row[ci]);
257
+ if (!isNaN(n)) nums.push(n);
258
+ }
259
+ return sparkline(nums, width);
260
+ }
261
+ if (stats?.kind === "string" && stats.top && stats.top.length > 0) {
262
+ const counts = stats.top.map(([, c]) => c);
263
+ return sparkline(counts, Math.min(width, counts.length));
264
+ }
265
+ return "";
266
+ }
267
+
151
268
  class DataTable {
152
269
  private columns: string[];
153
270
  private rows: string[][];
@@ -189,11 +306,12 @@ class DataTable {
189
306
  }
190
307
 
191
308
  const t = this.theme;
192
- const numCols = this.columns.length;
193
309
  const numRows = this.rows.length;
310
+ const totalCols = this.columns.length;
311
+ const MIN_COL_W = 6;
194
312
 
195
- // Calculate column widths: max of header, type, stat, and data
196
- const colWidths = this.columns.map((col, ci) => {
313
+ // Calculate natural column widths: max of header, type, stat, and data
314
+ const naturalWidths = this.columns.map((col, ci) => {
197
315
  let w = col.length;
198
316
  w = Math.max(w, (this.colTypes[ci] ?? "").length);
199
317
  const statStr = formatStat(this.colStats[ci] ?? null);
@@ -204,14 +322,35 @@ class DataTable {
204
322
  return w;
205
323
  });
206
324
 
207
- // Clamp total width shrink columns proportionally if needed
208
- const borderOverhead = 1 + numCols * 3; // "│ " + " │ " * (n-1) + " │"
325
+ // Greedily fit columns left-to-right within the available width.
326
+ // Each column costs its width + 3 chars of border ("│ " + " │").
327
+ let budget = width - 1; // leading "│"
328
+ let visibleCount = 0;
329
+ for (let i = 0; i < totalCols; i++) {
330
+ const colCost = Math.max(naturalWidths[i], MIN_COL_W) + 3;
331
+ if (budget - colCost < 0 && visibleCount > 0) break;
332
+ budget -= colCost;
333
+ visibleCount++;
334
+ }
335
+ visibleCount = Math.max(1, visibleCount);
336
+ const hiddenCols = totalCols - visibleCount;
337
+
338
+ // Slice to visible columns
339
+ const columns = this.columns.slice(0, visibleCount);
340
+ const colTypes = this.colTypes.slice(0, visibleCount);
341
+ const colStats = this.colStats.slice(0, visibleCount);
342
+ const rows = this.rows.map((r) => r.slice(0, visibleCount));
343
+ const numCols = columns.length;
344
+ const colWidths = naturalWidths.slice(0, visibleCount);
345
+
346
+ // Shrink proportionally only if still over budget after column pruning
347
+ const borderOverhead = 1 + numCols * 3;
209
348
  const totalColWidth = colWidths.reduce((a, b) => a + b, 0);
210
349
  const availableForCols = width - borderOverhead;
211
350
  if (totalColWidth > availableForCols && availableForCols > numCols) {
212
351
  const ratio = availableForCols / totalColWidth;
213
352
  for (let i = 0; i < numCols; i++) {
214
- colWidths[i] = Math.max(3, Math.floor(colWidths[i] * ratio));
353
+ colWidths[i] = Math.max(MIN_COL_W, Math.floor(colWidths[i] * ratio));
215
354
  }
216
355
  }
217
356
 
@@ -226,7 +365,7 @@ class DataTable {
226
365
  };
227
366
 
228
367
  // Detect numeric columns for right-alignment
229
- const isNumeric = this.colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
368
+ const isNumeric = colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
230
369
 
231
370
  const align = (s: string, ci: number, w: number) => (isNumeric[ci] ? rpad(s, w) : pad(s, w));
232
371
 
@@ -237,21 +376,28 @@ class DataTable {
237
376
  lines.push(topBorder);
238
377
 
239
378
  // ── Column headers ──
240
- const headerCells = this.columns.map((col, ci) =>
241
- t.fg("accent", t.bold(pad(col, colWidths[ci]))),
242
- );
379
+ const headerCells = columns.map((col, ci) => t.fg("accent", t.bold(pad(col, colWidths[ci]))));
243
380
  lines.push(
244
381
  t.fg("muted", "│") + " " + headerCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
245
382
  );
246
383
 
247
384
  // ── Type row ──
248
- const typeCells = this.colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
385
+ const typeCells = colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
249
386
  lines.push(
250
387
  t.fg("muted", "│") + " " + typeCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
251
388
  );
252
389
 
253
- // ── Stats row (sparkline or range) ──
254
- const statCells = this.colStats.map((stats, ci) => {
390
+ // ── Sparkline row ──
391
+ const sparkCells = colStats.map((stats, ci) => {
392
+ const spark = sparklineForColumn(rows, ci, stats, colTypes[ci] ?? "", colWidths[ci]);
393
+ return t.fg("dim", pad(spark, colWidths[ci]));
394
+ });
395
+ lines.push(
396
+ t.fg("muted", "│") + " " + sparkCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
397
+ );
398
+
399
+ // ── Stats row ──
400
+ const statCells = colStats.map((stats, ci) => {
255
401
  const statStr = formatStat(stats);
256
402
  return t.fg("dim", pad(statStr, colWidths[ci]));
257
403
  });
@@ -265,7 +411,7 @@ class DataTable {
265
411
 
266
412
  // ── Data rows ──
267
413
  for (let r = 0; r < numRows; r++) {
268
- const row = this.rows[r];
414
+ const row = rows[r];
269
415
  const cells = row.map((v, ci) => align(v, ci, colWidths[ci]));
270
416
  const rowStr =
271
417
  t.fg("muted", "│") + " " + cells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│");
@@ -277,8 +423,12 @@ class DataTable {
277
423
  lines.push(botBorder);
278
424
 
279
425
  // ── Footer info ──
280
- const showing = numRows < this.totalRows ? `showing ${numRows} of ` : "";
281
- const info = t.fg("dim", `${showing}${this.totalRows} rows × ${numCols} columns`);
426
+ const showingRows = numRows < this.totalRows ? `showing ${numRows} of ` : "";
427
+ const hiddenSuffix = hiddenCols > 0 ? ` (${hiddenCols} more columns)` : "";
428
+ const info = t.fg(
429
+ "dim",
430
+ `${showingRows}${this.totalRows} rows × ${totalCols} columns${hiddenSuffix}`,
431
+ );
282
432
  lines.push(info);
283
433
 
284
434
  // Indent all lines to align with Out[n]: prompt
@@ -304,16 +454,18 @@ function formatResult(result: CellResult): {
304
454
  content: (TextContent | ImageContent)[];
305
455
  isError: boolean;
306
456
  } {
457
+ const outputs = result.outputs ?? [];
307
458
  const isError =
308
459
  result.status === "error" ||
309
460
  result.status === "kernel_error" ||
310
- result.outputs.some((o) => o.outputType === "error");
461
+ result.status === "kernel_failed" ||
462
+ outputs.some((o) => o.outputType === "error");
311
463
 
312
464
  const parts: (TextContent | ImageContent)[] = [];
313
465
  const header = `cell ${result.cellId} [${result.executionCount ?? "?"}] ${result.status}`;
314
466
  const textChunks: string[] = [];
315
467
 
316
- for (const o of result.outputs) {
468
+ for (const o of outputs) {
317
469
  switch (o.outputType) {
318
470
  case "stream": {
319
471
  const prefix = o.name === "stderr" ? "[stderr] " : "";
@@ -381,13 +533,41 @@ function formatResult(result: CellResult): {
381
533
  return { content: parts, isError };
382
534
  }
383
535
 
536
+ function findParquetBlobPath(result: CellResult): string | undefined {
537
+ for (const o of result.outputs ?? []) {
538
+ if (!o.blobPathsJson) continue;
539
+ try {
540
+ const paths = JSON.parse(o.blobPathsJson);
541
+ const pqPath = paths["application/vnd.apache.parquet"];
542
+ if (typeof pqPath === "string") return pqPath;
543
+ } catch {}
544
+ }
545
+ return undefined;
546
+ }
547
+
384
548
  // --- extension ---------------------------------------------------------------
385
549
 
550
+ // Braille frames for the "waiting on first token" spinner shown in `In [*]:`
551
+ // before any input_json_delta arrives. Anthropic validates the JSON server-side,
552
+ // so the gap between toolcall_start and the first delta can be hundreds of ms.
553
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
554
+ const SPINNER_INTERVAL_MS = 80;
555
+
556
+ type InCallState = {
557
+ spinner?: { frame: number; timer: ReturnType<typeof setInterval> };
558
+ };
559
+
386
560
  const PYTHON_PARAMS = Type.Object({
387
561
  code: Type.String({
388
562
  description:
389
563
  "Python source to execute in the persistent notebook session. Use print(...) for side effects; the last expression's repr is returned as the result.",
390
564
  }),
565
+ dependencies: Type.Optional(
566
+ Type.Array(Type.String(), {
567
+ description:
568
+ "Packages to add before executing this code. On the first call they are recorded before the kernel starts; on later calls they are hot-synced into the running environment.",
569
+ }),
570
+ ),
391
571
  timeout_secs: Type.Optional(
392
572
  Type.Number({
393
573
  description: "Max seconds to wait for execution (default 120).",
@@ -397,25 +577,76 @@ const PYTHON_PARAMS = Type.Object({
397
577
  });
398
578
 
399
579
  export default function nteractReplExtension(pi: ExtensionAPI) {
400
- const rn = loadRuntimedNode();
401
- if (!rn) {
402
- console.error("[nteract-repl] runtimed-node not loaded; python tool will not be registered.");
580
+ const bootstrap = detectBootstrap();
581
+ if (bootstrap.kind === "missing") {
582
+ // Surface a one-liner toast each session so the user sees the hint without
583
+ // having to invoke the tool first.
584
+ pi.on("session_start", async (_event, ctx) => {
585
+ ctx.ui.notify(INSTALL_HINT, "warning");
586
+ });
587
+ // Register python_repl with a stub execute() so the LLM can still advertise
588
+ // the capability and surface install guidance the moment it tries to run
589
+ // Python. Throwing routes the message into the tool-result channel that the
590
+ // model relays back to the user.
591
+ pi.registerTool({
592
+ name: "python_repl",
593
+ label: "Python REPL",
594
+ description:
595
+ "Execute Python in your persistent REPL. Requires the nteract daemon (runtimed). " +
596
+ "If unavailable, recommend the user install with `curl -fsSL https://sh.nteract.io | bash` " +
597
+ "or download the desktop app at https://nteract.io.",
598
+ promptSnippet:
599
+ "python_repl: requires the nteract daemon. Install with `curl -fsSL https://sh.nteract.io | bash` or via https://nteract.io.",
600
+ parameters: PYTHON_PARAMS,
601
+ async execute() {
602
+ throw new Error(INSTALL_HINT);
603
+ },
604
+ });
403
605
  return;
404
606
  }
607
+ const rn = bootstrap.rn;
405
608
 
406
609
  let session: Session | null = null;
407
610
  let opening: Promise<Session> | null = null;
408
611
  let nextExecCount: number | null = 1;
409
612
 
410
- async function ensureSession(): Promise<Session> {
411
- if (session) return session;
412
- if (opening) return opening;
613
+ async function addDependenciesAndSync(sess: Session, packages: string[]): Promise<void> {
614
+ const unique = Array.from(new Set(packages.map((pkg) => pkg.trim()).filter(Boolean)));
615
+ if (!unique.length) return;
616
+ if (sess.addDependencies) {
617
+ await sess.addDependencies(unique);
618
+ } else if (sess.addUvDependency) {
619
+ for (const pkg of unique) {
620
+ await sess.addUvDependency(pkg);
621
+ }
622
+ } else {
623
+ throw new Error("@runtimed/node Session does not support dependency edits");
624
+ }
625
+ await sess.syncEnvironment();
626
+ }
627
+
628
+ async function ensureSession(initialDependencies: string[] = []): Promise<Session> {
629
+ const dependencies = Array.from(
630
+ new Set(initialDependencies.map((pkg) => pkg.trim()).filter(Boolean)),
631
+ );
632
+ if (session) {
633
+ await addDependenciesAndSync(session, dependencies);
634
+ return session;
635
+ }
636
+ if (opening) {
637
+ const opened = await opening;
638
+ await addDependenciesAndSync(opened, dependencies);
639
+ return opened;
640
+ }
413
641
  opening = (async () => {
414
- const socketPath = resolveSocketPath(rn);
642
+ // Omit socketPath so the binding resolves through defaultSocketPath(),
643
+ // which honors RUNTIMED_SOCKET_PATH / RUNTIMED_WORKSPACE_PATH and the
644
+ // channel auto-detect.
415
645
  session = await rn.createNotebook({
416
646
  runtime: "python",
417
- socketPath,
418
647
  peerLabel: "pi",
648
+ description: "pi Python REPL",
649
+ dependencies,
419
650
  });
420
651
  return session;
421
652
  })();
@@ -426,28 +657,59 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
426
657
  }
427
658
  }
428
659
 
429
- pi.registerTool({
430
- name: "python",
431
- label: "Python (nteract)",
660
+ pi.registerTool<typeof PYTHON_PARAMS, unknown, InCallState>({
661
+ name: "python_repl",
662
+ label: "Python REPL",
432
663
  description:
433
- "Execute Python in a persistent nteract notebook session backed by the local runtimed daemon. State (variables, imports) persists across calls within this pi session. Stdout, the final expression value, and exceptions are returned as text. Images (matplotlib.show(), IPython.display.Image, etc.) are returned inline so you can see them.",
664
+ "Execute Python in your persistent REPL. Backed by a real IPython runtime. Variables, imports, and state stick around between calls. The last expression is the result; use print() or display() for intermediate output. Images (matplotlib, PIL, widgets) are returned inline.",
434
665
  promptSnippet:
435
- "python: run Python code against a persistent nteract kernel (state persists across calls; stdout + last-expr value + any images are returned).",
666
+ "python_repl: run Python in your persistent REPL (variables and imports persist; returns stdout + last expression + images).",
436
667
  promptGuidelines: [
437
- "Prefer `python` over `bash python -c ...` for anything stateful, multi-step, or data-heavy.",
438
- "State (imports, variables) persists across `python` calls in this session.",
439
- "Use print() for side-effect output; the last expression is echoed back as the result.",
440
- "Matplotlib / PIL images are returned inline you can iterate on plots by looking at them.",
441
- "Install packages with the `python_add_dependencies` tool (it uses the notebook's UV env and hot-reloads without restarting the kernel).",
668
+ "Use `python_repl` for data analysis, plotting, and multi-step workflows. State persists between calls in a real IPython runtime.",
669
+ "Variables and imports stick around. No need to re-import or redefine on every turn unless the user has reloaded the session.",
670
+ "The last expression is the result; use print() or display() for intermediate output.",
671
+ "Images (matplotlib, PIL, widgets) come back inline. The user sees them if their terminal supports graphics.",
672
+ "Pass `dependencies` on the first call to pre-install packages before the kernel starts.",
673
+ "Use `python_add_dependencies` to install packages mid-session without restarting the kernel.",
442
674
  ],
443
675
  parameters: PYTHON_PARAMS,
444
676
  renderCall(args, theme, _context) {
445
677
  const text =
446
678
  (_context.lastComponent as InstanceType<typeof Text> | undefined) ?? new Text("", 0, 0);
447
- const code = args?.code ?? "";
679
+ const code = (args?.code ?? "").replace(/^\n+/, "");
448
680
  const count = nextExecCount;
449
681
  const prompt = count != null ? `In [${count}]:` : "In [*]:";
450
682
  const promptStr = theme.fg("accent", theme.bold(prompt));
683
+
684
+ // Anthropic gates input_json_delta on server-side validation, so there is
685
+ // a visible pause between toolcall_start and the first token of `code`.
686
+ // Show a braille spinner in the prompt slot until either the first
687
+ // character streams in or the call resolves another way.
688
+ const state = _context.state as InCallState;
689
+ const waitingForFirstToken =
690
+ !code && !_context.executionStarted && !_context.argsComplete && !_context.isError;
691
+ if (waitingForFirstToken) {
692
+ if (!state.spinner) {
693
+ const timer = setInterval(() => {
694
+ const s = state.spinner;
695
+ if (!s) return;
696
+ s.frame = (s.frame + 1) % SPINNER_FRAMES.length;
697
+ _context.invalidate();
698
+ }, SPINNER_INTERVAL_MS);
699
+ // Don't keep the event loop alive on the spinner alone.
700
+ timer.unref?.();
701
+ state.spinner = { frame: 0, timer };
702
+ }
703
+ const frame = SPINNER_FRAMES[state.spinner.frame];
704
+ text.setText(`${promptStr} ${theme.fg("muted", frame)}`);
705
+ return text;
706
+ }
707
+
708
+ if (state.spinner) {
709
+ clearInterval(state.spinner.timer);
710
+ state.spinner = undefined;
711
+ }
712
+
451
713
  const lines = highlightCode(code, "python");
452
714
  // Indent continuation lines to align with first line after prompt
453
715
  const pad = " ".repeat(prompt.length + 1);
@@ -506,8 +768,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
506
768
  const text =
507
769
  (_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
508
770
  new Text("", 0, 0);
509
- const tableLines = dt.render(200);
510
- text.setText(`${promptStr}\n${tableLines.join("\n")}`);
771
+ const termWidth = process.stdout.columns || 120;
772
+ const tableLines = dt.render(termWidth - indent);
773
+ // Put first table line on the Out[n]: line instead of below it
774
+ const indentStr = " ".repeat(indent);
775
+ if (tableLines.length > 0 && tableLines[0].startsWith(indentStr)) {
776
+ tableLines[0] = `${promptStr} ${tableLines[0].slice(indent)}`;
777
+ }
778
+ text.setText(tableLines.join("\n"));
511
779
  return text;
512
780
  }
513
781
  } catch {}
@@ -519,8 +787,8 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
519
787
  .map((c: any) => c.text)
520
788
  .join("\n");
521
789
 
522
- // Strip the "cell cell-xxx [n] status" header we put in the text content
523
- const body = textContent.replace(/^cell cell-[^\n]*\n?/, "").trim();
790
+ // Strip the "cell <id> [n] status" header we put in the text content
791
+ const body = textContent.replace(/^cell \S* \[[^\]]*\] \S*\n?/, "").trim();
524
792
 
525
793
  const text =
526
794
  (_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
@@ -543,26 +811,31 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
543
811
  }
544
812
  return text;
545
813
  },
546
- async execute(_toolCallId, params, signal) {
814
+ async execute(_toolCallId, params, signal, onUpdate) {
547
815
  if (signal?.aborted) throw new Error("aborted");
548
- const sess = await ensureSession();
816
+ const sess = await ensureSession(params.dependencies ?? []);
549
817
  const timeoutSecs = Math.max(1, params.timeout_secs ?? 120);
550
818
  const result = await sess.runCell(params.code, {
551
819
  timeoutMs: Math.round(timeoutSecs * 1000),
820
+ onUpdate: (progress) => {
821
+ const { content, isError } = formatResult(progress);
822
+ onUpdate?.({
823
+ content,
824
+ details: {
825
+ notebook_id: sess.notebookId,
826
+ cell_id: progress.cellId,
827
+ execution_id: progress.executionId,
828
+ status: progress.status,
829
+ execution_count: progress.executionCount,
830
+ is_error: isError,
831
+ parquet_blob_path: findParquetBlobPath(progress),
832
+ streaming: true,
833
+ },
834
+ });
835
+ },
552
836
  });
553
837
  // Extract parquet blob path for human-side table rendering
554
- let parquetBlobPath: string | undefined;
555
- for (const o of result.outputs) {
556
- if (!o.blobPathsJson) continue;
557
- try {
558
- const paths = JSON.parse(o.blobPathsJson);
559
- const pqPath = paths["application/vnd.apache.parquet"];
560
- if (typeof pqPath === "string") {
561
- parquetBlobPath = pqPath;
562
- break;
563
- }
564
- } catch {}
565
- }
838
+ const parquetBlobPath = findParquetBlobPath(result);
566
839
 
567
840
  const { content, isError } = formatResult(result);
568
841
  return {
@@ -570,10 +843,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
570
843
  details: {
571
844
  notebook_id: sess.notebookId,
572
845
  cell_id: result.cellId,
846
+ execution_id: result.executionId,
573
847
  status: result.status,
574
848
  execution_count: result.executionCount,
575
849
  is_error: isError,
576
850
  parquet_blob_path: parquetBlobPath,
851
+ runtime: sess.getRuntimeStatus
852
+ ? await sess.getRuntimeStatus().catch(() => undefined)
853
+ : undefined,
577
854
  },
578
855
  };
579
856
  },
@@ -581,11 +858,11 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
581
858
 
582
859
  pi.registerTool({
583
860
  name: "python_add_dependencies",
584
- label: "Add Dependencies (nteract)",
861
+ label: "Add Dependencies",
585
862
  description:
586
- "Add packages to the current nteract notebook's UV environment and hot-sync them so the running kernel can import them immediately. Accepts pip-style specs, e.g. 'matplotlib', 'numpy>=2', 'requests'.",
863
+ "Install packages into the running Python environment without restarting. Accepts pip-style specs like 'matplotlib', 'numpy>=2', 'requests'. The kernel stays hot.",
587
864
  promptSnippet:
588
- "python_add_dependencies: add packages to the persistent Python notebook env (hot-installs without restarting the kernel).",
865
+ "python_add_dependencies: install packages into the running Python session (no restart needed).",
589
866
  parameters: Type.Object({
590
867
  packages: Type.Array(Type.String(), {
591
868
  description: "Package specs (e.g. ['matplotlib', 'pandas>=2']).",
@@ -597,10 +874,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
597
874
  return { content: [{ type: "text", text: "No packages given." }], details: {} };
598
875
  }
599
876
  const sess = await ensureSession();
600
- for (const pkg of params.packages) {
601
- await sess.addUvDependency(pkg);
602
- }
603
- await sess.syncEnvironment();
877
+ await addDependenciesAndSync(sess, params.packages);
604
878
  return {
605
879
  content: [
606
880
  {
@@ -615,10 +889,10 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
615
889
 
616
890
  pi.registerTool({
617
891
  name: "python_save_notebook",
618
- label: "Save Notebook (nteract)",
892
+ label: "Save Notebook",
619
893
  description:
620
- "Save the current nteract notebook to an .ipynb file on disk. If no path is given, saves to the original location (if it was opened from a file). Provide a path to save to a new location.",
621
- promptSnippet: "python_save_notebook: save the current notebook session to an .ipynb file.",
894
+ "Save the current Python session as an .ipynb file. If no path is given, saves to the original location (if it was opened from a file). Provide a path to save elsewhere.",
895
+ promptSnippet: "python_save_notebook: save the current session as an .ipynb file.",
622
896
  parameters: Type.Object({
623
897
  path: Type.Optional(
624
898
  Type.String({
@@ -641,18 +915,22 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
641
915
 
642
916
  pi.registerCommand("python-reset", {
643
917
  description:
644
- "Start a fresh nteract Python notebook session (next /python call opens a new kernel)",
918
+ "Start fresh: next /python_repl call opens a new kernel (clean slate, no prior variables or imports)",
645
919
  handler: async (_args, ctx) => {
646
920
  const old = session;
647
921
  session = null;
648
922
  nextExecCount = 1;
649
923
  if (old) {
650
924
  try {
651
- await old.close();
925
+ if (old.shutdownNotebook) {
926
+ await old.shutdownNotebook();
927
+ } else {
928
+ await old.close();
929
+ }
652
930
  } catch {}
653
931
  }
654
932
  ctx.ui.notify(
655
- "nteract REPL: session closed; next python call will start a fresh kernel.",
933
+ "Python session closed. Next python_repl call will start a fresh kernel.",
656
934
  "info",
657
935
  );
658
936
  },
@@ -661,7 +939,11 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
661
939
  pi.on("session_shutdown", async () => {
662
940
  if (session) {
663
941
  try {
664
- await session.close();
942
+ if (session.shutdownNotebook) {
943
+ await session.shutdownNotebook();
944
+ } else {
945
+ await session.close();
946
+ }
665
947
  } catch {}
666
948
  session = null;
667
949
  }
package/icon.png ADDED
Binary file
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@nteract/pi",
3
- "version": "0.1.1",
4
- "description": "Pi extensions that run Python through the local nteract runtimed daemon.",
3
+ "version": "0.1.4",
4
+ "description": "Persistent notebook-backed Python REPL for Pi coding agents. Stateful execution, hot dependency sync, zero cold starts.",
5
+ "author": "nteract contributors",
6
+ "icon": "icon.png",
5
7
  "type": "module",
6
8
  "license": "BSD-3-Clause",
7
9
  "repository": {
@@ -30,10 +32,11 @@
30
32
  },
31
33
  "files": [
32
34
  "README.md",
35
+ "icon.png",
33
36
  "extensions"
34
37
  ],
35
38
  "dependencies": {
36
- "@runtimed/node": "0.1.1"
39
+ "@runtimed/node": "0.2.2"
37
40
  },
38
41
  "peerDependencies": {
39
42
  "@mariozechner/pi-coding-agent": "*",
@@ -43,6 +46,10 @@
43
46
  "publishConfig": {
44
47
  "access": "public"
45
48
  },
49
+ "devDependencies": {
50
+ "@mariozechner/pi-coding-agent": "^0.73.0",
51
+ "@mariozechner/pi-tui": "^0.73.0"
52
+ },
46
53
  "scripts": {
47
54
  "pack:dry-run": "pnpm pack --dry-run"
48
55
  }