@timurproko/a1 0.1.8-dev.b449b5c → 0.1.8-dev.dbc8a9b

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
@@ -30,16 +30,20 @@ Pi extension packages install into A1's own profile, so bare `a1` loads them and
30
30
  `a1 pi` and `a1 sandbox` do not. Sources are Pi's: `npm:`, git, or a local path.
31
31
 
32
32
  ```sh
33
- a1 install npm:pi-mcp-adapter # install a package into ~/.a1/agent
34
- a1 remove npm:pi-mcp-adapter # remove it again (alias: a1 uninstall)
35
- a1 list # list packages installed for a1
36
- a1 update --extensions # update every installed package
37
- a1 update npm:pi-mcp-adapter # update one of them
38
- a1 update --models # refresh model catalogs
33
+ a1 pi install npm:pi-mcp-adapter # install a package into ~/.a1/agent
34
+ a1 pi remove npm:pi-mcp-adapter # remove it again (alias: a1 pi uninstall)
35
+ a1 pi list # list packages installed for a1
36
+ a1 pi update --extensions # update every installed package
37
+ a1 pi update npm:pi-mcp-adapter # update one of them
38
+ a1 update --models # refresh A1's model catalogs
39
39
  ```
40
40
 
41
41
  A running session loads a newly installed package after a restart. Pi's own
42
- profile at `~/.pi/agent` is managed by Pi itself.
42
+ profile at `~/.pi/agent` is managed by Pi itself. Extension configuration is
43
+ isolated too: for example, MCP configuration for bare `a1` belongs under
44
+ `~/.a1/agent` (or the project), so `~/.pi/agent/mcp.json` is not read. Run
45
+ `/mcp setup` inside bare `a1` to configure it; the MCP footer status appears
46
+ when that A1 configuration contains a server.
43
47
 
44
48
  ## Develop
45
49
 
@@ -68,14 +72,15 @@ time and never committed, so previews cost no commits and need no command, and a
68
72
  installed preview says exactly which source produced it.
69
73
 
70
74
  ```sh
71
- a1 update:next # install the newest preview
72
- a1 update:7eabe9e # install the preview built from that commit
73
- a1 update:0.1.8-dev.7eabe9e # or name the version outright
75
+ a1 update:next # install the newest preview
76
+ a1 update:7eabe9e # install the preview built from that commit
74
77
  ```
75
78
 
76
79
  Naming a commit is what the version suffix is for: read it off `a1 version`, a
77
- pull request, or a commit list, and install exactly that build. A commit that was
78
- never published is refused rather than guessed at.
80
+ pull request, or a commit list, and install exactly that build you never need to
81
+ know which version it went out under. A commit that was never published is refused
82
+ rather than guessed at. A full preview version works in the same place, so a string
83
+ pasted back from `a1 version` is understood too.
79
84
 
80
85
  ### Stable — npm `latest`
81
86
 
@@ -92,7 +97,7 @@ It lands the version on `develop` through a pull request that merges itself.
92
97
  **Landing it is what publishes** — the workflow builds, validates the packed release
93
98
  on Windows, Linux, and macOS, publishes to npm `latest` with provenance, and only
94
99
  then writes the `v<version>` tag and records the GitHub Release. The command waits
95
- for that to succeed, then lands the next `-dev.0` version so previews resume
100
+ for that to succeed, then opens the next `-dev` line so previews resume
96
101
  immediately.
97
102
 
98
103
  ```sh
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "unsupported",
8
- "builtAt": "2026-08-24T06:35:58.522Z",
8
+ "builtAt": "2026-08-25T07:47:40.603Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "7524bf568992517f50ed53196c861c5edeba0d7275633bb4735ab45bd5963a28",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-24T06:35:49.229Z",
8
+ "builtAt": "2026-08-25T07:47:45.696Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "29e22fe29de2828982bc67ef418c4adcaab1490281477b7bea592ec6fe621bcd",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-24T06:36:28.685Z",
8
+ "builtAt": "2026-08-25T07:48:23.701Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "1d535ec647852d21736769e0d418b80f9a99d02a9721609a13f251300986b9b9",
11
+ "sha256": "8a76f3b0814f4e98d3b71b2285be8b18f0ad95441cc35d14f023cd184bd293ac",
12
12
  "size": 172544
13
13
  },
14
14
  "provenance": {
@@ -5,12 +5,13 @@ export function cliUsage(capabilities) {
5
5
  "",
6
6
  ...(capabilities.developmentProfiles ? ["pi", "sandbox"] : []),
7
7
  "version",
8
- "update [self|<source>|--extensions|--models]",
8
+ "update [self|--models]",
9
9
  "update:next",
10
10
  "update:<commit>",
11
- "install <source>",
12
- "remove <source>",
13
- "list",
11
+ "pi install <source>",
12
+ "pi remove <source>",
13
+ "pi list",
14
+ "pi update [--extensions|<source>]",
14
15
  ]);
15
16
  }
16
17
  const PROFILE_WORDS = new Set(["pi", "sandbox"]);
@@ -32,7 +33,13 @@ export function parseCliCommand(arguments_, capabilities) {
32
33
  if (arguments_.length === 0)
33
34
  return { kind: "launch", profileId: "a1" };
34
35
  const [command, ...rest] = arguments_;
35
- if (capabilities.developmentProfiles && (command === "pi" || command === "sandbox")) {
36
+ if (command === "pi") {
37
+ if (rest.length > 0)
38
+ return parsePiPackageCommand(rest);
39
+ if (capabilities.developmentProfiles)
40
+ return { kind: "launch", profileId: "pi" };
41
+ }
42
+ if (capabilities.developmentProfiles && command === "sandbox") {
36
43
  return withoutArguments(rest, { kind: "launch", profileId: command });
37
44
  }
38
45
  if (command === "version")
@@ -41,11 +48,9 @@ export function parseCliCommand(arguments_, capabilities) {
41
48
  return parseColonUpdate(command.slice("update:".length), rest);
42
49
  if (command === "update")
43
50
  return parseUpdate(rest);
44
- if (command === "install" || command === "remove" || command === "uninstall") {
45
- return parseSourceCommand(command === "install" ? "install" : "remove", rest);
51
+ if (command === "install" || command === "remove" || command === "uninstall" || command === "list") {
52
+ return packageNamespaceRejection(command, rest.join(" ") || undefined);
46
53
  }
47
- if (command === "list")
48
- return withoutArguments(rest, { kind: "packages", request: { verb: "list", source: null } });
49
54
  if (command === "ui")
50
55
  return { kind: "error", message: `The ui subcommand was removed; run bare ${PRODUCT_TEXT.commandName} for the owned UI.` };
51
56
  if (command === "agent")
@@ -71,10 +76,9 @@ function parseColonUpdate(suffix, rest) {
71
76
  return { kind: "update", channel: "next", target: suffix };
72
77
  }
73
78
  /**
74
- * `update` carries both meanings pinned Pi gives it: itself by default, and the
75
- * profile's packages when a target says so. Pi is refused as a target because A1
76
- * certifies each release against one pinned Pi, so moving Pi underneath it would
77
- * invalidate what was certified.
79
+ * Top-level `update` owns A1 itself and A1's model catalogs. Extension package
80
+ * maintenance lives under the `pi` compatibility namespace, while updating the
81
+ * pinned Pi runtime remains impossible.
78
82
  */
79
83
  function parseUpdate(rest) {
80
84
  if (rest.length === 0)
@@ -90,18 +94,42 @@ function parseUpdate(rest) {
90
94
  message: PRODUCT_TEXT.diagnostic(`pins the Pi version it was certified against; run ${PRODUCT_TEXT.commandName} update to move ${PRODUCT_TEXT.displayName} itself.`),
91
95
  };
92
96
  }
93
- // A release channel is spelled with the colon. Taking the bare word as a package
94
- // source would turn a near miss into a confident search for a package nobody has.
95
97
  if (target === "next" || target === "stable") {
96
98
  const form = target === "next" ? `${PRODUCT_TEXT.commandName} update:next` : `${PRODUCT_TEXT.commandName} update`;
97
99
  return { kind: "error", message: PRODUCT_TEXT.diagnostic(`selects a release channel with a colon; run ${form}.`) };
98
100
  }
99
- if (target === "--extensions")
100
- return { kind: "packages", request: { verb: "update", source: null } };
101
101
  if (target === "--models")
102
102
  return { kind: "packages", request: { verb: "refresh-models", source: null } };
103
+ if (target === "--extensions" || (target !== undefined && !target.startsWith("-"))) {
104
+ return packageNamespaceRejection("update", target === "--extensions" ? "--extensions" : target);
105
+ }
106
+ return unknownOption(target ?? "", "update");
107
+ }
108
+ function parsePiPackageCommand(arguments_) {
109
+ const [verb, ...rest] = arguments_;
110
+ if (verb === "install" || verb === "remove" || verb === "uninstall") {
111
+ return parseSourceCommand(verb === "install" ? "install" : "remove", rest);
112
+ }
113
+ if (verb === "list")
114
+ return withoutArguments(rest, { kind: "packages", request: { verb: "list", source: null } });
115
+ if (verb === "update")
116
+ return parsePiPackageUpdate(rest);
117
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`received an unknown pi package command: ${verb ?? ""}`) };
118
+ }
119
+ function parsePiPackageUpdate(rest) {
120
+ if (rest.length === 0) {
121
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`pi update needs --extensions or a package source.`) };
122
+ }
123
+ if (rest.length > 1)
124
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic("pi update accepts one target.") };
125
+ const [target] = rest;
126
+ if (target === "--extensions")
127
+ return { kind: "packages", request: { verb: "update", source: null } };
128
+ if (target === "--models") {
129
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`refreshes its model catalogs at the top level; run ${PRODUCT_TEXT.commandName} update --models.`) };
130
+ }
103
131
  if (target === undefined || target.startsWith("-"))
104
- return unknownOption(target ?? "", "update");
132
+ return unknownOption(target ?? "", "pi update");
105
133
  if (PROFILE_WORDS.has(target))
106
134
  return profileRejection("update");
107
135
  return { kind: "packages", request: { verb: "update", source: target } };
@@ -129,6 +157,13 @@ function withoutArguments(rest, command) {
129
157
  return profileRejection("list");
130
158
  return { kind: "error", message: PRODUCT_TEXT.diagnostic("commands do not accept additional arguments.") };
131
159
  }
160
+ function packageNamespaceRejection(verb, target) {
161
+ const suffix = target === undefined ? "" : ` ${target}`;
162
+ return {
163
+ kind: "error",
164
+ message: PRODUCT_TEXT.diagnostic(`manages extension packages under its pi namespace; run ${PRODUCT_TEXT.commandName} pi ${verb}${suffix}.`),
165
+ };
166
+ }
132
167
  function profileRejection(verb) {
133
168
  return {
134
169
  kind: "error",
@@ -3,7 +3,7 @@ export * from "./conformance.js";
3
3
  export * from "./shell-components.js";
4
4
  export * from "./theme.js";
5
5
  export * from "./upstream/theme/theme-controller.js";
6
- export { applyConfiguredPiTheme, getAvailablePiThemes } from "./upstream/theme/theme.js";
6
+ export { applyConfiguredPiTheme, getAvailablePiThemes, onPiThemeChange } from "./upstream/theme/theme.js";
7
7
  export * from "./upstream/components/countdown-timer.js";
8
8
  export * from "./upstream/components/extension-editor.js";
9
9
  export * from "./upstream/components/session-selector.js";
@@ -3,7 +3,7 @@ export * from "./conformance.js";
3
3
  export * from "./shell-components.js";
4
4
  export * from "./theme.js";
5
5
  export * from "./upstream/theme/theme-controller.js";
6
- export { applyConfiguredPiTheme, getAvailablePiThemes } from "./upstream/theme/theme.js";
6
+ export { applyConfiguredPiTheme, getAvailablePiThemes, onPiThemeChange } from "./upstream/theme/theme.js";
7
7
  export * from "./upstream/components/countdown-timer.js";
8
8
  export * from "./upstream/components/extension-editor.js";
9
9
  export * from "./upstream/components/session-selector.js";
@@ -1,6 +1,7 @@
1
1
  import { AssistantMessageComponent, BashExecutionComponent, CompactionSummaryMessageComponent, CustomMessageComponent, DynamicBorder, getMarkdownTheme, parseSkillBlock, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
2
2
  import { SkillInvocationMessageComponent, } from "./upstream/components/skill-invocation-message.js";
3
3
  import { Container, Markdown, Spacer, Text, } from "#pi-tui";
4
+ import { PRODUCT_TEXT } from "../../product-identity.js";
4
5
  import { KeybindingsManager, } from "./upstream/adjacent/core/keybindings.js";
5
6
  import { PINNED_PI_LAYOUT, piTheme, } from "./theme.js";
6
7
  import { componentPort, createTuiFacade, ensureTheme, formatSessionTokens, isRecord, } from "./shell-shared-facade.js";
@@ -168,7 +169,7 @@ export function renderPiShellPackageUpdateNotice(packages, width) {
168
169
  container.addChild(new Spacer(1));
169
170
  container.addChild(new DynamicBorder(text => theme.fg("warning", text)));
170
171
  container.addChild(new Text(`${theme.bold(theme.fg("warning", "Package Updates Available"))}\n`
171
- + `${theme.fg("muted", "Package updates are available. Run ")}${theme.fg("accent", "pi update --extensions")}\n`
172
+ + `${theme.fg("muted", "Package updates are available. Run ")}${theme.fg("accent", `${PRODUCT_TEXT.commandName} pi update --extensions`)}\n`
172
173
  + `${theme.fg("muted", "Packages:")}\n`
173
174
  + packages.map(name => `- ${name}`).join("\n"), 1, 0));
174
175
  container.addChild(new DynamicBorder(text => theme.fg("warning", text)));
@@ -60,11 +60,18 @@ export interface OwnedPiResourceSummary {
60
60
  readonly sourcePath: string | null;
61
61
  readonly diagnostic: string | null;
62
62
  }
63
+ export interface OwnedPiExtensionSourceSummary {
64
+ readonly source: string;
65
+ readonly scope: "user" | "project" | "temporary";
66
+ readonly origin: "package" | "top-level";
67
+ readonly baseDir: string | null;
68
+ }
63
69
  export interface OwnedPiExtensionResourceSummary {
64
70
  readonly kind: "extension";
65
71
  readonly id: string;
66
72
  readonly sourcePath: string | null;
67
73
  readonly resolvedPath: string | null;
74
+ readonly sourceInfo: OwnedPiExtensionSourceSummary | null;
68
75
  readonly loaded: boolean;
69
76
  readonly hidden: boolean;
70
77
  readonly diagnostic: string | null;
@@ -11,6 +11,12 @@ import { createPiRuntimeIntegration } from "./runtime-integration.js";
11
11
  import { PiSessionCommandIntegration } from "./session-integration.js";
12
12
  import { PiSettingsIntegration } from "./settings-integration.js";
13
13
  const execFileAsync = promisify(execFile);
14
+ /**
15
+ * Engine events delivered before the queue hands the event loop a turn. Small enough
16
+ * that a streaming burst never holds input, large enough that an ordinary turn is one
17
+ * batch.
18
+ */
19
+ const EVENT_DELIVERY_BATCH = 16;
14
20
  const DEFAULT_SURFACE = {
15
21
  columns: 100,
16
22
  rows: 32,
@@ -53,6 +59,8 @@ export class PiEngineAdapter {
53
59
  #activeCommandIds = [];
54
60
  #completedCommands = new Map();
55
61
  #transcript = [];
62
+ #transcriptIndex = new Map();
63
+ #transcriptSnapshot;
56
64
  #messageBlockIds = new WeakMap();
57
65
  #messageFallbackIds = new Map();
58
66
  #toolBlockIds = new Map();
@@ -61,6 +69,8 @@ export class PiEngineAdapter {
61
69
  #eventQueue = [];
62
70
  #eventQueueProcessing;
63
71
  #droppedEventCount = 0;
72
+ #agentRunActive = false;
73
+ #statusKind = null;
64
74
  #sessionCommands;
65
75
  #gitBranch = null;
66
76
  #extensionUi;
@@ -143,7 +153,7 @@ export class PiEngineAdapter {
143
153
  if (this.#disposed || updates.length === 0)
144
154
  return;
145
155
  const packages = updates.map(name => `- ${name}`).join("\n");
146
- this.#addDiagnostic("info", "package-updates", `Package updates are available. Run pi update --extensions\nPackages:\n${packages}`, true);
156
+ this.#addDiagnostic("info", "package-updates", `Package updates are available. Run ${PRODUCT_IDENTITY.commandName} pi update --extensions\nPackages:\n${packages}`, true);
147
157
  this.#emitView();
148
158
  }
149
159
  onEvent(listener) {
@@ -278,6 +288,7 @@ export class PiEngineAdapter {
278
288
  id: `extension-${resources.length}`,
279
289
  sourcePath: extension.path,
280
290
  resolvedPath: extension.resolvedPath,
291
+ sourceInfo: extensionSourceSummary(extension.sourceInfo),
281
292
  loaded: true,
282
293
  hidden: extension.hidden === true,
283
294
  diagnostic: null,
@@ -373,9 +384,10 @@ export class PiEngineAdapter {
373
384
  }
374
385
  const extensionCommands = this.#session?.extensionRunner?.getRegisteredCommands?.();
375
386
  if (Array.isArray(extensionCommands)) {
376
- for (const command of extensionCommands.filter(isRecord)) {
377
- const name = stringProperty(command, "name");
378
- if (!name || usedNames.has(name))
387
+ const registered = extensionCommands.filter(isRecord);
388
+ for (const command of registered) {
389
+ const name = stringProperty(command, "invocationName") ?? stringProperty(command, "name");
390
+ if (!name || usedNames.has(name) || isPiPrefixedCompatibilityAlias(command, registered))
379
391
  continue;
380
392
  commands.push({ name, description: stringProperty(command, "description") ?? "Extension command", source: "extension" });
381
393
  usedNames.add(name);
@@ -724,7 +736,7 @@ export class PiEngineAdapter {
724
736
  sessionId: this.#sessionId,
725
737
  revision: this.#viewRevision,
726
738
  lifecycle: this.#lifecycle,
727
- transcript: this.#transcript.map(block => ({ ...block })),
739
+ transcript: this.#transcriptSnapshot ??= Object.freeze([...this.#transcript]),
728
740
  editor: { ...this.#editor, queuedSubmissions: [...this.#editor.queuedSubmissions] },
729
741
  status: {
730
742
  ...this.#status,
@@ -1350,6 +1362,8 @@ export class PiEngineAdapter {
1350
1362
  submitEnabled: true,
1351
1363
  };
1352
1364
  this.#status = { ...this.#status, workingMessage: null, badges: [] };
1365
+ this.#statusKind = null;
1366
+ this.#agentRunActive = false;
1353
1367
  this.#activeModel = readModel(session.model);
1354
1368
  this.#reconcileActiveModelAvailability();
1355
1369
  this.#thinkingLevel = readThinkingLevel(session.thinkingLevel);
@@ -1402,15 +1416,61 @@ export class PiEngineAdapter {
1402
1416
  this.#emitView();
1403
1417
  }
1404
1418
  }
1419
+ /**
1420
+ * Replaces the transcript and the index that finds a block by its identifier. Every
1421
+ * lookup goes through the index, so streaming a chunk costs the same in a long session
1422
+ * as in a new one.
1423
+ */
1424
+ #setTranscript(blocks) {
1425
+ this.#transcript = blocks;
1426
+ this.#transcriptIndex.clear();
1427
+ for (const [index, block] of blocks.entries())
1428
+ this.#transcriptIndex.set(block.id, index);
1429
+ this.#transcriptSnapshot = undefined;
1430
+ }
1431
+ #transcriptBlock(id) {
1432
+ const index = this.#transcriptIndex.get(id);
1433
+ return index === undefined ? undefined : this.#transcript[index];
1434
+ }
1435
+ /** Shows the state named by `kind`, which becomes the state a later end can clear. */
1436
+ #enterWorkState(kind, message) {
1437
+ const wasBusy = this.#lifecycle === "busy";
1438
+ this.#statusKind = kind;
1439
+ this.#lifecycle = "busy";
1440
+ this.#status = { ...this.#status, workingMessage: message };
1441
+ if (!wasBusy)
1442
+ this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
1443
+ this.#emitEvent({ type: "status", status: this.#status });
1444
+ }
1445
+ /**
1446
+ * Ends one named state. A state the shell is not in is left alone, so a finished
1447
+ * compaction or retry cannot clear the working state it never replaced. While the run
1448
+ * continues, ending either of those returns to working rather than to idle.
1449
+ */
1450
+ #endWorkState(kind) {
1451
+ if (this.#statusKind !== kind)
1452
+ return;
1453
+ if (this.#agentRunActive) {
1454
+ this.#enterWorkState("working", "Working...");
1455
+ return;
1456
+ }
1457
+ this.#leaveWorkStates();
1458
+ }
1459
+ /** Leaves every work state and reports the session idle. */
1460
+ #leaveWorkStates() {
1461
+ this.#statusKind = null;
1462
+ this.#lifecycle = "ready";
1463
+ this.#status = { ...this.#status, workingMessage: null };
1464
+ this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
1465
+ this.#emitEvent({ type: "status", status: this.#status });
1466
+ }
1405
1467
  #handlePiEvent(event) {
1406
1468
  if (!isRecord(event) || typeof event.type !== "string")
1407
1469
  return;
1408
1470
  switch (event.type) {
1409
1471
  case "agent_start":
1410
- this.#lifecycle = "busy";
1411
- this.#status = { ...this.#status, workingMessage: "Working..." };
1412
- this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
1413
- this.#emitEvent({ type: "status", status: this.#status });
1472
+ this.#agentRunActive = true;
1473
+ this.#enterWorkState("working", "Working...");
1414
1474
  return;
1415
1475
  case "message_start":
1416
1476
  this.#upsertMessageBlock(event.message, "live");
@@ -1452,11 +1512,18 @@ export class PiEngineAdapter {
1452
1512
  if (finalMessages.length > 0)
1453
1513
  this.#rebuildTranscript(finalMessages, "finalized");
1454
1514
  else
1455
- this.#transcript = this.#transcript.map(block => block.status === "live" ? { ...block, status: "finalized" } : block);
1456
- this.#lifecycle = "ready";
1457
- this.#status = { ...this.#status, workingMessage: null };
1458
- this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
1459
- this.#emitEvent({ type: "status", status: this.#status });
1515
+ this.#setTranscript(this.#transcript.map(block => block.status === "live" ? { ...block, status: "finalized" } : block));
1516
+ // Ending a turn leaves the working state, as the recorded pinned baseline does, but
1517
+ // it leaves only that state: a compaction or retry being shown outlives the turn
1518
+ // that ended under it. Settlement ends the run, and with it every state — the
1519
+ // engine ends a turn for each continuation it makes and settles once.
1520
+ if (event.type === "agent_settled") {
1521
+ this.#agentRunActive = false;
1522
+ this.#leaveWorkStates();
1523
+ }
1524
+ else if (this.#statusKind === null || this.#statusKind === "working") {
1525
+ this.#leaveWorkStates();
1526
+ }
1460
1527
  this.#emitView();
1461
1528
  return;
1462
1529
  }
@@ -1472,20 +1539,16 @@ export class PiEngineAdapter {
1472
1539
  return;
1473
1540
  }
1474
1541
  case "auto_retry_start":
1475
- this.#lifecycle = "busy";
1476
- this.#status = { ...this.#status, workingMessage: "Retrying…" };
1477
- this.#emitEvent({ type: "status", status: this.#status });
1542
+ this.#enterWorkState("retry", "Retrying…");
1478
1543
  return;
1479
1544
  case "auto_retry_end":
1480
- case "compaction_end":
1481
- this.#lifecycle = "ready";
1482
- this.#status = { ...this.#status, workingMessage: null };
1483
- this.#emitEvent({ type: "status", status: this.#status });
1545
+ this.#endWorkState("retry");
1484
1546
  return;
1485
1547
  case "compaction_start":
1486
- this.#lifecycle = "busy";
1487
- this.#status = { ...this.#status, workingMessage: "Compacting…" };
1488
- this.#emitEvent({ type: "status", status: this.#status });
1548
+ this.#enterWorkState("compaction", "Compacting…");
1549
+ return;
1550
+ case "compaction_end":
1551
+ this.#endWorkState("compaction");
1489
1552
  return;
1490
1553
  case "thinking_level_changed":
1491
1554
  this.#thinkingLevel = readThinkingLevel(event.level);
@@ -1575,7 +1638,7 @@ export class PiEngineAdapter {
1575
1638
  }
1576
1639
  }
1577
1640
  }
1578
- this.#transcript = blocks;
1641
+ this.#setTranscript(blocks);
1579
1642
  }
1580
1643
  #upsertMessageBlock(message, status) {
1581
1644
  const blocks = this.#messageBlocks(message, status, this.#transcript.length);
@@ -1660,7 +1723,7 @@ export class PiEngineAdapter {
1660
1723
  : this.#toolBlockIds.get(toolCallId) ?? `tool-${toolCallId}`;
1661
1724
  if (toolCallId !== undefined)
1662
1725
  this.#toolBlockIds.set(toolCallId, blockId);
1663
- const existing = this.#transcript.find(block => block.id === blockId);
1726
+ const existing = this.#transcriptBlock(blockId);
1664
1727
  const existingPayload = isRecord(existing?.payload) ? existing.payload : undefined;
1665
1728
  return [{
1666
1729
  id: blockId,
@@ -1751,11 +1814,14 @@ export class PiEngineAdapter {
1751
1814
  });
1752
1815
  }
1753
1816
  #upsertTranscriptBlock(block) {
1754
- const index = this.#transcript.findIndex(existing => existing.id === block.id);
1755
- if (index >= 0)
1817
+ const index = this.#transcriptIndex.get(block.id);
1818
+ if (index !== undefined)
1756
1819
  this.#transcript[index] = block;
1757
- else
1820
+ else {
1821
+ this.#transcriptIndex.set(block.id, this.#transcript.length);
1758
1822
  this.#transcript.push(block);
1823
+ }
1824
+ this.#transcriptSnapshot = undefined;
1759
1825
  this.#emitEvent({ type: "transcript-block", block });
1760
1826
  }
1761
1827
  #messageBlockId(message, fallbackIndex, status, occurrence) {
@@ -1773,7 +1839,7 @@ export class PiEngineAdapter {
1773
1839
  }
1774
1840
  }
1775
1841
  else {
1776
- id = [...cached].reverse().find(candidate => this.#transcript.some(block => block.id === candidate && block.status === "live"));
1842
+ id = [...cached].reverse().find(candidate => this.#transcriptBlock(candidate)?.status === "live");
1777
1843
  if (id === undefined && status === "finalized")
1778
1844
  id = cached.at(-1);
1779
1845
  if (id === undefined) {
@@ -1786,7 +1852,7 @@ export class PiEngineAdapter {
1786
1852
  return id;
1787
1853
  }
1788
1854
  #nextBlockRevision(id) {
1789
- const existing = this.#transcript.find(block => block.id === id);
1855
+ const existing = this.#transcriptBlock(id);
1790
1856
  if (existing)
1791
1857
  return existing.revision + 1;
1792
1858
  this.#nextBlockSequence += 1;
@@ -1847,6 +1913,7 @@ export class PiEngineAdapter {
1847
1913
  }
1848
1914
  async #processEventQueue() {
1849
1915
  try {
1916
+ let deliveredSinceYield = 0;
1850
1917
  while (this.#eventQueue.length > 0) {
1851
1918
  const event = this.#eventQueue.shift();
1852
1919
  if (!event)
@@ -1859,6 +1926,14 @@ export class PiEngineAdapter {
1859
1926
  this.#recordDiagnostic("warning", "event-listener", error instanceof Error ? error.message : String(error), true);
1860
1927
  }
1861
1928
  }
1929
+ deliveredSinceYield += 1;
1930
+ // A microtask chain runs to exhaustion before the loop turns, so a streaming
1931
+ // burst would hold typed input, pointer reports, and timed indicators until it
1932
+ // drained. Yielding on a macrotask hands those their turn between batches.
1933
+ if (deliveredSinceYield >= EVENT_DELIVERY_BATCH && this.#eventQueue.length > 0) {
1934
+ deliveredSinceYield = 0;
1935
+ await new Promise(resolve => { setImmediate(resolve); });
1936
+ }
1862
1937
  }
1863
1938
  }
1864
1939
  finally {
@@ -2245,11 +2320,26 @@ function extensionResourceDiagnostic(index, sourcePath, diagnostic) {
2245
2320
  id: `extension-diagnostic-${index}`,
2246
2321
  sourcePath,
2247
2322
  resolvedPath: null,
2323
+ sourceInfo: null,
2248
2324
  loaded: false,
2249
2325
  hidden: false,
2250
2326
  diagnostic,
2251
2327
  };
2252
2328
  }
2329
+ function extensionSourceSummary(value) {
2330
+ if (!isRecord(value))
2331
+ return null;
2332
+ const source = stringProperty(value, "source");
2333
+ const scope = value.scope;
2334
+ const origin = value.origin;
2335
+ const baseDir = value.baseDir;
2336
+ if (!source
2337
+ || (scope !== "user" && scope !== "project" && scope !== "temporary")
2338
+ || (origin !== "package" && origin !== "top-level")
2339
+ || (baseDir !== undefined && typeof baseDir !== "string"))
2340
+ return null;
2341
+ return { source, scope, origin, baseDir: baseDir ?? null };
2342
+ }
2253
2343
  function collectionResult(value, key) {
2254
2344
  if (!isRecord(value))
2255
2345
  return { values: [], diagnostics: [] };
@@ -2274,6 +2364,26 @@ function stringProperty(value, key) {
2274
2364
  const item = value[key];
2275
2365
  return typeof item === "string" && item.length > 0 ? item : undefined;
2276
2366
  }
2367
+ /**
2368
+ * Some ecosystem extensions retain a `pi-<name>` slash-command alias beside
2369
+ * their unprefixed command. A1 presents the product-neutral command once while
2370
+ * leaving Pi's runner free to accept the compatibility alias when typed.
2371
+ */
2372
+ function isPiPrefixedCompatibilityAlias(command, commands) {
2373
+ const name = stringProperty(command, "name");
2374
+ if (!name?.startsWith("pi-") || name.length === 3)
2375
+ return false;
2376
+ const canonicalName = name.slice(3);
2377
+ const description = stringProperty(command, "description");
2378
+ const sourcePath = extensionCommandSourcePath(command);
2379
+ return commands.some(candidate => candidate !== command
2380
+ && stringProperty(candidate, "name") === canonicalName
2381
+ && stringProperty(candidate, "description") === description
2382
+ && extensionCommandSourcePath(candidate) === sourcePath);
2383
+ }
2384
+ function extensionCommandSourcePath(command) {
2385
+ return isRecord(command) ? stringProperty(command.sourceInfo, "path") : undefined;
2386
+ }
2277
2387
  function compactResourceLabel(path) {
2278
2388
  const segments = path.replaceAll("\\", "/").split("/").filter(Boolean);
2279
2389
  return segments.at(-1) ?? path;
@@ -1,6 +1,6 @@
1
1
  import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../ui-components/index.js";
2
2
  import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../pi-engine-adapter/index.js";
3
- import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../pi-component-adapter/index.js";
3
+ import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, onPiThemeChange, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../pi-component-adapter/index.js";
4
4
  import { PiTuiRuntimeAdapter, } from "../pi-tui-runtime-adapter/index.js";
5
5
  export class OwnedUiSessionShellRoot {
6
6
  editor;
@@ -8,6 +8,9 @@ export class OwnedUiSessionShellRoot {
8
8
  resources;
9
9
  #cwd;
10
10
  #transcript = new Map();
11
+ #blocksById = new Map();
12
+ #renderedRows = new Map();
13
+ #themeUnsubscribe;
11
14
  #transcriptOrder = [];
12
15
  #view;
13
16
  #status;
@@ -54,6 +57,9 @@ export class OwnedUiSessionShellRoot {
54
57
  this.editor.addToHistory(block.text);
55
58
  }
56
59
  this.#syncTranscript(view.transcript);
60
+ // Colours come from the active theme, so rendered rows outlive their revision only
61
+ // until the theme under them changes.
62
+ this.#themeUnsubscribe = onPiThemeChange(() => this.#renderedRows.clear());
57
63
  }
58
64
  update(view) {
59
65
  this.#view = view;
@@ -128,10 +134,10 @@ export class OwnedUiSessionShellRoot {
128
134
  }
129
135
  #renderDocument(width) {
130
136
  const transcript = this.#transcriptOrder.flatMap((id, index) => {
131
- const block = this.#view.transcript.find(item => item.id === id);
137
+ const block = this.#blocksById.get(id);
132
138
  if (!this.#thinkingVisible && block?.kind === "thinking")
133
139
  return [];
134
- const rows = this.#transcript.get(id)?.render(width) ?? [];
140
+ const rows = this.#blockRows(id, block, width);
135
141
  if (index > 0 && block?.kind === "user")
136
142
  return ["", ...rows];
137
143
  return rows;
@@ -167,6 +173,25 @@ export class OwnedUiSessionShellRoot {
167
173
  ...diagnosticRows,
168
174
  ];
169
175
  }
176
+ /**
177
+ * Rows for one transcript block. A finalized block renders once for a given revision
178
+ * and width and is reused after that, so a frame costs what changed rather than what
179
+ * the session has accumulated. A live block, and anything the shell drives itself, is
180
+ * rendered every time because its content is still moving.
181
+ */
182
+ #blockRows(id, block, width) {
183
+ const component = this.#transcript.get(id);
184
+ if (component === undefined)
185
+ return [];
186
+ if (block === undefined || block.status !== "finalized")
187
+ return component.render(width);
188
+ const cached = this.#renderedRows.get(id);
189
+ if (cached && cached.width === width && cached.revision === block.revision)
190
+ return cached.rows;
191
+ const rows = component.render(width);
192
+ this.#renderedRows.set(id, { width, revision: block.revision, rows });
193
+ return rows;
194
+ }
170
195
  transcriptComponent(id) {
171
196
  return this.#transcript.get(id);
172
197
  }
@@ -334,6 +359,8 @@ export class OwnedUiSessionShellRoot {
334
359
  this.#extensionWorkingMessage = undefined;
335
360
  this.#status.setWorkingOverride(undefined);
336
361
  this.#footer.update(this.#viewWithExtensionStatuses(this.#view));
362
+ // An extension renderer may have drawn transcript blocks that are now unrendered by it.
363
+ this.#renderedRows.clear();
337
364
  this.invalidate();
338
365
  }
339
366
  addExtensionNotification(message, type) {
@@ -384,11 +411,13 @@ export class OwnedUiSessionShellRoot {
384
411
  this.#inputSurface.setFocused?.(focused);
385
412
  }
386
413
  dispose() {
414
+ this.#themeUnsubscribe();
387
415
  this.header.dispose?.();
388
416
  this.resources.dispose?.();
389
417
  for (const component of this.#transcript.values())
390
418
  component.dispose?.();
391
419
  this.#transcript.clear();
420
+ this.#renderedRows.clear();
392
421
  if (this.#inputSurface !== this.editor)
393
422
  this.#inputSurface.dispose?.();
394
423
  this.#extensionHeader?.dispose?.();
@@ -402,12 +431,16 @@ export class OwnedUiSessionShellRoot {
402
431
  this.#queued.dispose?.();
403
432
  }
404
433
  #syncTranscript(blocks) {
434
+ this.#blocksById.clear();
435
+ for (const block of blocks)
436
+ this.#blocksById.set(block.id, block);
405
437
  const nextIds = new Set(blocks.map(block => block.id));
406
438
  for (const [id, component] of this.#transcript) {
407
439
  if (id.startsWith("workflow-status-") || nextIds.has(id))
408
440
  continue;
409
441
  component.dispose?.();
410
442
  this.#transcript.delete(id);
443
+ this.#renderedRows.delete(id);
411
444
  }
412
445
  for (const block of blocks) {
413
446
  const component = this.#transcript.get(block.id);
@@ -431,8 +464,9 @@ export class OwnedUiSessionShellRoot {
431
464
  if (block !== undefined)
432
465
  order.push(block.id);
433
466
  }
467
+ const placed = new Set(order);
434
468
  for (const statusId of statusIds) {
435
- if (!order.includes(statusId))
469
+ if (!placed.has(statusId))
436
470
  order.push(statusId);
437
471
  }
438
472
  this.#transcriptOrder = order;
@@ -498,6 +532,8 @@ export class OwnedUiSessionShellRoot {
498
532
  this.resources.setExpanded(expanded);
499
533
  for (const component of this.#transcript.values())
500
534
  component.setExpanded(expanded);
535
+ // Expansion changes what a block draws without changing its revision.
536
+ this.#renderedRows.clear();
501
537
  this.invalidate();
502
538
  }
503
539
  }
@@ -525,12 +561,14 @@ function shellResourceEntries(backend) {
525
561
  sourcePath: resource.sourcePath,
526
562
  diagnostic: resource.diagnostic,
527
563
  }));
528
- for (const extension of backend.extensionResources()) {
529
- if (extension.hidden)
530
- continue;
564
+ const extensions = backend.extensionResources().filter(extension => !extension.hidden);
565
+ const loadedExtensions = extensions.filter(extension => extension.diagnostic === null);
566
+ const extensionLabels = compactExtensionLabels(loadedExtensions);
567
+ for (const extension of extensions) {
568
+ const labelIndex = loadedExtensions.indexOf(extension);
531
569
  resources.push({
532
570
  section: "Extensions",
533
- label: compactResourceLabel(extension.sourcePath ?? extension.resolvedPath ?? "extension"),
571
+ label: extensionLabels[labelIndex] ?? compactResourceLabel(extension.sourcePath ?? extension.resolvedPath ?? "extension"),
534
572
  sourcePath: extension.sourcePath ?? extension.resolvedPath,
535
573
  diagnostic: extension.diagnostic,
536
574
  });
@@ -538,12 +576,129 @@ function shellResourceEntries(backend) {
538
576
  return resources;
539
577
  }
540
578
  function compactResourceLabel(path) {
541
- const segments = path.replaceAll("\\", "/").split("/").filter(Boolean);
579
+ const segments = compactPathSegments(path);
542
580
  const leaf = segments.at(-1) ?? path;
543
581
  if ((leaf === "index.ts" || leaf === "index.js") && segments.length > 1)
544
582
  return segments.at(-2) ?? leaf;
545
583
  return leaf;
546
584
  }
585
+ /**
586
+ * Pinned from InteractiveMode's compact extension-label helpers at Pi commit
587
+ * 914cf1472e715297caa30db4b9535d534a9eb718. The source metadata crosses an
588
+ * A1-owned boundary first; the owned shell never inspects Pi's private root.
589
+ */
590
+ function compactExtensionLabels(extensions) {
591
+ const localExtensions = extensions
592
+ .filter(extension => !isPackageExtensionSource(extension.sourceInfo))
593
+ .map(extension => {
594
+ const path = extension.sourcePath ?? extension.resolvedPath ?? "extension";
595
+ const segments = compactPathSegments(path);
596
+ if (segments.length > 1 && (segments.at(-1) === "index.ts" || segments.at(-1) === "index.js"))
597
+ segments.pop();
598
+ return { extension, segments };
599
+ });
600
+ return extensions.map(extension => {
601
+ const resourcePath = extension.sourcePath ?? extension.resolvedPath ?? "extension";
602
+ if (isPackageExtensionSource(extension.sourceInfo)) {
603
+ return compactPackageExtensionLabel(resourcePath, extension.sourceInfo);
604
+ }
605
+ const localIndex = localExtensions.findIndex(item => item.extension === extension);
606
+ const segments = localExtensions[localIndex]?.segments;
607
+ if (!segments || segments.length === 0)
608
+ return compactResourceLabel(resourcePath);
609
+ for (let count = 1; count <= segments.length; count += 1) {
610
+ const candidate = segments.slice(-count).join("/");
611
+ if (localExtensions.every((item, itemIndex) => itemIndex === localIndex || item.segments.slice(-count).join("/") !== candidate)) {
612
+ return candidate;
613
+ }
614
+ }
615
+ return segments.join("/");
616
+ });
617
+ }
618
+ function compactPackageExtensionLabel(resourcePath, sourceInfo) {
619
+ const sourceLabel = compactPackageSourceLabel(sourceInfo.source);
620
+ if (!sourceLabel)
621
+ return compactResourceLabel(resourcePath);
622
+ const shortPath = shortPackagePath(resourcePath, sourceInfo).replaceAll("\\", "/");
623
+ const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath;
624
+ const slash = packagePath.lastIndexOf("/");
625
+ const fileName = slash < 0 ? packagePath : packagePath.slice(slash + 1);
626
+ const directory = slash < 0 ? "" : packagePath.slice(0, slash);
627
+ const extension = fileName.lastIndexOf(".");
628
+ const name = extension <= 0 ? fileName : fileName.slice(0, extension);
629
+ if (name === "index")
630
+ return !directory || directory === "." ? sourceLabel : `${sourceLabel}:${directory}`;
631
+ return `${sourceLabel}:${packagePath}`;
632
+ }
633
+ function compactPackageSourceLabel(source) {
634
+ if (source.startsWith("npm:"))
635
+ return source.slice("npm:".length) || source;
636
+ if (!source.startsWith("git:"))
637
+ return source;
638
+ const gitSource = source.slice("git:".length).trim();
639
+ let repositoryPath;
640
+ const scpLike = gitSource.match(/^git@[^:]+:(.+)$/);
641
+ if (scpLike?.[1]) {
642
+ repositoryPath = scpLike[1];
643
+ }
644
+ else if (/^[a-z]+:\/\//i.test(gitSource)) {
645
+ try {
646
+ repositoryPath = new URL(gitSource).pathname.replace(/^\/+/, "");
647
+ }
648
+ catch {
649
+ return source;
650
+ }
651
+ }
652
+ else {
653
+ const slash = gitSource.indexOf("/");
654
+ if (slash >= 0)
655
+ repositoryPath = gitSource.slice(slash + 1);
656
+ }
657
+ if (!repositoryPath)
658
+ return source;
659
+ const ref = repositoryPath.indexOf("@");
660
+ const withoutRef = ref < 0 ? repositoryPath : repositoryPath.slice(0, ref);
661
+ return withoutRef.replace(/\.git$/, "") || source;
662
+ }
663
+ function shortPackagePath(resourcePath, sourceInfo) {
664
+ const fullPath = normalizeResourcePath(resourcePath);
665
+ const baseDir = sourceInfo.baseDir === null ? undefined : normalizeResourcePath(sourceInfo.baseDir).replace(/\/$/, "");
666
+ if (baseDir) {
667
+ const npmRoot = baseDir.match(/^(.*\/node_modules)\/(@?[^/]+(?:\/[^/]+)?)$/);
668
+ if (npmRoot?.[1] && fullPath.startsWith(`${npmRoot[1]}/`))
669
+ return relativeResourcePath(baseDir, fullPath);
670
+ if (fullPath === baseDir)
671
+ return ".";
672
+ if (fullPath.startsWith(`${baseDir}/`))
673
+ return fullPath.slice(baseDir.length + 1);
674
+ }
675
+ const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
676
+ if (npmMatch?.[2] && sourceInfo.source.startsWith("npm:"))
677
+ return npmMatch[2];
678
+ const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
679
+ if (gitMatch?.[1] && sourceInfo.source.startsWith("git:"))
680
+ return gitMatch[1];
681
+ return resourcePath;
682
+ }
683
+ function relativeResourcePath(from, to) {
684
+ const fromSegments = from.split("/").filter(Boolean);
685
+ const toSegments = to.split("/").filter(Boolean);
686
+ let common = 0;
687
+ while (common < fromSegments.length && common < toSegments.length
688
+ && fromSegments[common]?.toLowerCase() === toSegments[common]?.toLowerCase())
689
+ common += 1;
690
+ return [...fromSegments.slice(common).map(() => ".."), ...toSegments.slice(common)].join("/") || ".";
691
+ }
692
+ function compactPathSegments(path) {
693
+ return normalizeResourcePath(path).split("/").filter(segment => segment.length > 0 && segment !== "~");
694
+ }
695
+ function normalizeResourcePath(path) {
696
+ return path.replaceAll("\\", "/");
697
+ }
698
+ function isPackageExtensionSource(sourceInfo) {
699
+ const source = sourceInfo?.source ?? "";
700
+ return source.startsWith("npm:") || source.startsWith("git:");
701
+ }
547
702
  export class OwnedUiSessionShell {
548
703
  backend;
549
704
  root;
@@ -560,6 +715,7 @@ export class OwnedUiSessionShell {
560
715
  #sequence = 0;
561
716
  #started = false;
562
717
  #disposed = false;
718
+ #pointerReporting = false;
563
719
  #compactionQueue = [];
564
720
  #lastClearTime = 0;
565
721
  #activeLoginDialog;
@@ -638,8 +794,10 @@ export class OwnedUiSessionShell {
638
794
  });
639
795
  this.root.editor.setAutocompleteCommands(this.backend.workflowAutocompleteCommands());
640
796
  this.#unsubscribe = this.backend.onEvent(event => {
641
- this.#syncView();
642
- if (this.view().lifecycle === "ready" && this.#compactionQueue.length > 0)
797
+ // One view read per event: the model is built by the backend, and building it
798
+ // twice per streamed chunk is what made a long session cost more per chunk.
799
+ const view = this.#syncView();
800
+ if (view.lifecycle === "ready" && this.#compactionQueue.length > 0)
643
801
  void this.#flushCompactionQueue();
644
802
  if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
645
803
  this.#resolveStopped?.();
@@ -1232,10 +1390,25 @@ export class OwnedUiSessionShell {
1232
1390
  this.root.appendDaxnuts();
1233
1391
  }
1234
1392
  }
1393
+ /**
1394
+ * Turns terminal pointer reporting on for a screen that reads the pointer, and off for
1395
+ * every path that ends it. While it is on the terminal hands A1 the wheel and the
1396
+ * button instead of scrolling and selecting itself, so leaving it on outlives the
1397
+ * screen that wanted it and takes the terminal's own scrolling and selection with it.
1398
+ */
1399
+ #setPointerReporting(enabled) {
1400
+ if (this.#pointerReporting === enabled)
1401
+ return;
1402
+ this.#pointerReporting = enabled;
1403
+ if (!this.runtime.active)
1404
+ return;
1405
+ this.runtime.writeControl(enabled ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1406
+ }
1235
1407
  async dispose() {
1236
1408
  if (this.#disposed)
1237
1409
  return;
1238
1410
  this.#disposed = true;
1411
+ this.#setPointerReporting(false);
1239
1412
  this.#unsubscribe();
1240
1413
  this.#dialogHandle?.hide();
1241
1414
  await this.backend.unbindExtensionUi();
@@ -1248,6 +1421,8 @@ export class OwnedUiSessionShell {
1248
1421
  this.#sessionGeneration = this.backend.sessionGeneration;
1249
1422
  this.#activeLoginDialog = undefined;
1250
1423
  this.#extensionBridge.reset();
1424
+ // A replaced session takes its screens with it, pointer reporting included.
1425
+ this.#setPointerReporting(false);
1251
1426
  this.root.setInputSurface(null);
1252
1427
  this.root.resetExtensionUi();
1253
1428
  this.root.resetWorkflowPresentation();
@@ -1257,6 +1432,7 @@ export class OwnedUiSessionShell {
1257
1432
  this.runtime.requestRender();
1258
1433
  for (const listener of this.#listeners)
1259
1434
  listener(view);
1435
+ return view;
1260
1436
  }
1261
1437
  #openOwnedRoute(route) {
1262
1438
  const surface = this.#routeHost?.open(route) ?? null;
@@ -1267,7 +1443,7 @@ export class OwnedUiSessionShell {
1267
1443
  this.#dialogHandle?.hide();
1268
1444
  // Any-event reporting: hover and drag are what the screen is driven by, and
1269
1445
  // it also stops the terminal treating a drag as a text selection.
1270
- this.runtime.writeControl(MOUSE_TRACKING_ON);
1446
+ this.#setPointerReporting(true);
1271
1447
  // The interrupt chord is global, so it is watched on raw input rather than
1272
1448
  // through the overlay: the pinned shell handles that key before an overlay
1273
1449
  // ever sees it, which is why an owned screen must not rely on being asked.
@@ -1290,7 +1466,7 @@ export class OwnedUiSessionShell {
1290
1466
  });
1291
1467
  const closeSurface = () => {
1292
1468
  removeInterruptWatch();
1293
- this.runtime.writeControl(MOUSE_TRACKING_OFF);
1469
+ this.#setPointerReporting(false);
1294
1470
  this.#dialogHandle?.hide();
1295
1471
  this.#dialogHandle = undefined;
1296
1472
  this.#dialogId = undefined;
@@ -1,7 +1,6 @@
1
1
  export * from "./bootstrap.js";
2
2
  export * from "./cohort-selection.js";
3
3
  export * from "./cohort-state.js";
4
- export * from "./development-preview-release.js";
5
4
  export * from "./process-cleanup.js";
6
5
  export * from "./release.js";
7
6
  export * from "./release-gc.js";
@@ -1,7 +1,6 @@
1
1
  export * from "./bootstrap.js";
2
2
  export * from "./cohort-selection.js";
3
3
  export * from "./cohort-state.js";
4
- export * from "./development-preview-release.js";
5
4
  export * from "./process-cleanup.js";
6
5
  export * from "./release.js";
7
6
  export * from "./release-gc.js";
@@ -259,8 +259,16 @@ async function resolveRequestedPreview(runner, requested, output) {
259
259
  }
260
260
  const versions = (Array.isArray(published) ? published : [published]).filter((value) => typeof value === "string");
261
261
  const exact = versions.find(version => version === requested);
262
- if (exact !== undefined)
262
+ if (exact !== undefined) {
263
+ // Naming a release here would install it through the preview path, which is a
264
+ // different command with a different meaning. The commit form cannot express
265
+ // one, so only the fuller spelling of a preview reaches this.
266
+ if (!exact.includes("-dev.")) {
267
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`${exact} is a release, not a preview; run ${PRODUCT_TEXT.commandName} update to move to the current release.`)}\n`);
268
+ return { version: null, exitCode: 1 };
269
+ }
263
270
  return { version: exact, exitCode: 0 };
271
+ }
264
272
  const matches = versions.filter(version => version.endsWith(`-dev.${requested}`));
265
273
  if (matches.length === 1)
266
274
  return { version: matches[0], exitCode: 0 };
@@ -306,7 +314,9 @@ export async function runSelfUpdate(options) {
306
314
  if (resolved.version === null)
307
315
  return resolved.exitCode;
308
316
  const targetVersion = resolved.version;
309
- output.stdout(`${PRODUCT_TEXT.commandName} update (${UPDATE_CHANNEL_LABELS[channel]}): ${runningVersion} ${targetVersion}.\n`);
317
+ // No full stop after a version: it already ends in a dot-separated identifier,
318
+ // and a trailing one reads as part of the version rather than as punctuation.
319
+ output.stdout(`${PRODUCT_TEXT.commandName} update (${UPDATE_CHANNEL_LABELS[channel]}): ${runningVersion} → ${targetVersion}\n`);
310
320
  const progress = createUpdateProgress(output, options.progress ?? (options.output === undefined && process.stdout.isTTY === true));
311
321
  const rootLookup = await measure("global-root", async () => await runNpm(runner, ["root", "--global"], true, output, "resolve npm's global package root"));
312
322
  if (rootLookup.result === null)
@@ -395,7 +405,7 @@ export async function runSelfUpdate(options) {
395
405
  await transactionStore.clearCompleted();
396
406
  options.onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
397
407
  progress.finish();
398
- output.stdout(`${PRODUCT_TEXT.commandName} updated successfully: ${targetVersion}.\n`);
408
+ output.stdout(`${PRODUCT_TEXT.commandName} updated successfully: ${targetVersion}\n`);
399
409
  return 0;
400
410
  }
401
411
  catch (error) {
@@ -54,7 +54,7 @@ npm run release -- patch # or minor, major, or an exact x.y.z
54
54
  ```
55
55
 
56
56
  It lands `x.y.z` on `develop` through a pull request that merges itself, waits for
57
- that publication to succeed, and then lands `x.y.(z+1)-dev.0` so previews resume
57
+ that publication to succeed, and then lands `x.y.(z+1)-dev` so previews resume
58
58
  immediately. It publishes nothing itself and creates no tag.
59
59
 
60
60
  Landing the stable version is what publishes. The same pipeline sees a commit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.1.8-dev.b449b5c",
3
+ "version": "0.1.8-dev.dbc8a9b",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.13.0",
@@ -1,49 +0,0 @@
1
- export declare const PREVIEW_RELEASE_SCHEMA: string;
2
- export interface DevelopmentPreviewCandidate {
3
- readonly version: string;
4
- readonly requiresVersionCommit: boolean;
5
- }
6
- export interface DevelopmentPreviewRegistryState {
7
- readonly published: boolean;
8
- readonly nextVersion: string | null;
9
- }
10
- export interface DevelopmentPreviewVerificationOptions {
11
- readonly attempts?: number;
12
- readonly delayMs?: number;
13
- readonly delay?: (milliseconds: number) => Promise<void>;
14
- }
15
- export interface DevelopmentPreviewPublishResult {
16
- readonly published: boolean;
17
- readonly recoveredPublishError: unknown | null;
18
- }
19
- export interface UncertifiedDevelopmentPreviewEvidenceInput {
20
- readonly packageName: string;
21
- readonly version: string;
22
- readonly commit: string;
23
- readonly tarball: string;
24
- readonly integrity: string;
25
- readonly shasum: string;
26
- readonly platform: NodeJS.Platform;
27
- readonly architecture: string;
28
- readonly recordedAt: string;
29
- }
30
- export interface UncertifiedDevelopmentPreviewEvidence extends UncertifiedDevelopmentPreviewEvidenceInput {
31
- readonly schema: typeof PREVIEW_RELEASE_SCHEMA;
32
- readonly channel: "next";
33
- readonly certificationStatus: "uncertified-development-preview";
34
- readonly terminalCapability: "owned-ui";
35
- readonly manualAcceptance: "accepted";
36
- readonly physicalHostCertification: "deferred";
37
- readonly crossPlatformCertification: "deferred";
38
- readonly stableReleaseEligible: false;
39
- }
40
- export declare function createUncertifiedDevelopmentPreviewEvidence(input: UncertifiedDevelopmentPreviewEvidenceInput): UncertifiedDevelopmentPreviewEvidence;
41
- export declare function requireManuallyAcceptedDevelopmentPreview(version: string, acceptedVersion: string): void;
42
- export declare function selectDevelopmentPreviewCandidate(currentVersion: string, publishedVersions: readonly string[]): DevelopmentPreviewCandidate;
43
- /**
44
- * Treats npm's process result as provisional: browser-auth completion can fail
45
- * after the immutable upload succeeds. Registry identity remains authoritative.
46
- */
47
- export declare function publishDevelopmentPreviewWithRecovery(publish: () => Promise<void>, verify: () => Promise<void>): Promise<DevelopmentPreviewPublishResult>;
48
- export declare function verifyDevelopmentPreviewRegistry(version: string, observe: () => Promise<DevelopmentPreviewRegistryState>, repairNextTag: () => Promise<void>, options?: DevelopmentPreviewVerificationOptions): Promise<void>;
49
- export declare function developmentPreviewTarballName(packageName: string, version: string): string;
@@ -1,114 +0,0 @@
1
- import { compare, inc, prerelease, valid } from "semver";
2
- import { PRODUCT_IDENTITY } from "../../product-identity.js";
3
- export const PREVIEW_RELEASE_SCHEMA = PRODUCT_IDENTITY.evidence.previewReleaseSchema;
4
- export function createUncertifiedDevelopmentPreviewEvidence(input) {
5
- const prereleaseParts = prerelease(input.version);
6
- if (valid(input.version) === null || prereleaseParts?.[0] !== "dev") {
7
- throw new Error(`uncertified preview requires a development prerelease: ${input.version}`);
8
- }
9
- return {
10
- schema: PREVIEW_RELEASE_SCHEMA,
11
- channel: "next",
12
- certificationStatus: "uncertified-development-preview",
13
- terminalCapability: "owned-ui",
14
- manualAcceptance: "accepted",
15
- physicalHostCertification: "deferred",
16
- crossPlatformCertification: "deferred",
17
- stableReleaseEligible: false,
18
- ...input,
19
- };
20
- }
21
- export function requireManuallyAcceptedDevelopmentPreview(version, acceptedVersion) {
22
- const acceptedPrerelease = prerelease(acceptedVersion);
23
- if (valid(acceptedVersion) === null || acceptedPrerelease?.[0] !== "dev") {
24
- throw new Error(`invalid manually accepted development preview: ${acceptedVersion}`);
25
- }
26
- if (version !== acceptedVersion) {
27
- throw new Error(`development preview ${version} has no exact manual acceptance; accepted version is ${acceptedVersion}`);
28
- }
29
- }
30
- export function selectDevelopmentPreviewCandidate(currentVersion, publishedVersions) {
31
- if (valid(currentVersion) === null)
32
- throw new Error(`invalid current package version: ${currentVersion}`);
33
- const published = publishedVersions.map(version => {
34
- if (valid(version) === null)
35
- throw new Error(`invalid published package version: ${version}`);
36
- return version;
37
- });
38
- const publishedSet = new Set(published);
39
- const highestPublished = published.reduce((highest, version) => highest === null || compare(version, highest) > 0 ? version : highest, null);
40
- const currentPrerelease = prerelease(currentVersion);
41
- const currentIsUnpublishedLeadingDev = currentPrerelease?.[0] === "dev"
42
- && !publishedSet.has(currentVersion)
43
- && (highestPublished === null || compare(currentVersion, highestPublished) > 0);
44
- if (currentIsUnpublishedLeadingDev)
45
- return { version: currentVersion, requiresVersionCommit: false };
46
- const base = highestPublished === null || compare(currentVersion, highestPublished) > 0
47
- ? currentVersion
48
- : highestPublished;
49
- const basePrerelease = prerelease(base);
50
- let candidate = basePrerelease?.[0] === "dev"
51
- ? inc(base, "prerelease", "dev")
52
- : inc(base, "prepatch", "dev");
53
- if (candidate === null)
54
- throw new Error(`could not increment development preview from ${base}`);
55
- while (publishedSet.has(candidate)) {
56
- candidate = inc(candidate, "prerelease", "dev");
57
- if (candidate === null)
58
- throw new Error(`could not increment development preview from ${base}`);
59
- }
60
- return { version: candidate, requiresVersionCommit: candidate !== currentVersion };
61
- }
62
- /**
63
- * Treats npm's process result as provisional: browser-auth completion can fail
64
- * after the immutable upload succeeds. Registry identity remains authoritative.
65
- */
66
- export async function publishDevelopmentPreviewWithRecovery(publish, verify) {
67
- let publishError = null;
68
- try {
69
- await publish();
70
- }
71
- catch (error) {
72
- publishError = error;
73
- }
74
- try {
75
- await verify();
76
- return { published: true, recoveredPublishError: publishError };
77
- }
78
- catch (verificationError) {
79
- if (publishError !== null)
80
- throw new AggregateError([publishError, verificationError], "npm publish failed and the exact version could not be verified in the registry");
81
- throw verificationError;
82
- }
83
- }
84
- export async function verifyDevelopmentPreviewRegistry(version, observe, repairNextTag, options = {}) {
85
- const attempts = options.attempts ?? 12;
86
- const delayMs = options.delayMs ?? 2_000;
87
- const delay = options.delay ?? (async (milliseconds) => await new Promise(resolvePromise => setTimeout(resolvePromise, milliseconds)));
88
- if (!Number.isInteger(attempts) || attempts < 1)
89
- throw new Error(`invalid registry verification attempts: ${attempts}`);
90
- let repaired = false;
91
- let last = { published: false, nextVersion: null };
92
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
93
- last = await observe();
94
- if (last.published && last.nextVersion === version)
95
- return;
96
- if (last.published && !repaired) {
97
- await repairNextTag();
98
- repaired = true;
99
- }
100
- if (attempt < attempts)
101
- await delay(delayMs);
102
- }
103
- if (!last.published)
104
- throw new Error(`npm registry did not expose published version ${version} after ${attempts} attempts`);
105
- throw new Error(`npm next resolved ${last.nextVersion ?? "nothing"}; expected ${version} after ${attempts} attempts`);
106
- }
107
- export function developmentPreviewTarballName(packageName, version) {
108
- if (valid(version) === null)
109
- throw new Error(`invalid development preview version: ${version}`);
110
- const unscopedName = packageName.startsWith("@") ? packageName.slice(1).replace("/", "-") : packageName;
111
- if (!/^[a-z0-9._-]+$/i.test(unscopedName))
112
- throw new Error(`invalid package name: ${packageName}`);
113
- return `${unscopedName}-${version}.tgz`;
114
- }