@timurproko/a1 0.2.7-dev.1221 → 0.2.7-dev.1225

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +8 -6
  2. package/bin/ui.js +1 -0
  3. package/dist/app/session-shell/dock-chrome.js +1 -1
  4. package/dist/app/session-shell/exit-notice.d.ts +2 -2
  5. package/dist/app/session-shell/exit-notice.js +4 -4
  6. package/dist/app/session-shell/session-lifecycle.d.ts +2 -1
  7. package/dist/app/session-shell/session-lifecycle.js +4 -2
  8. package/dist/app/session-shell/session-shell-root.d.ts +2 -0
  9. package/dist/app/session-shell/session-shell-root.js +3 -2
  10. package/dist/app/session-shell/session-shell.js +9 -1
  11. package/dist/app/session-shell/session-viewport-controller.d.ts +2 -1
  12. package/dist/app/session-shell/session-viewport-controller.js +26 -37
  13. package/dist/app/session-shell/shell-editor-factory.js +4 -1
  14. package/dist/cli/dispatch.js +15 -2
  15. package/dist/composition/owned-ui.d.ts +2 -0
  16. package/dist/composition/owned-ui.js +43 -11
  17. package/dist/composition/reference-selection-options.d.ts +3 -0
  18. package/dist/composition/reference-selection-options.js +16 -0
  19. package/dist/composition/settings-route-host.d.ts +1 -2
  20. package/dist/composition/settings-route-host.js +19 -13
  21. package/dist/composition/settings-search-input.js +4 -1
  22. package/dist/composition/utility-tab.js +4 -2
  23. package/dist/features/launch/intent.js +2 -2
  24. package/dist/features/owned-ui/plus-menu-dialog.js +28 -20
  25. package/dist/features/owned-ui/reference-document-selection.d.ts +30 -0
  26. package/dist/features/owned-ui/reference-document-selection.js +179 -0
  27. package/dist/features/owned-ui/reference-screen-app.d.ts +4 -1
  28. package/dist/features/owned-ui/reference-screen-app.js +39 -8
  29. package/dist/features/owned-ui/settings-app.d.ts +1 -0
  30. package/dist/features/owned-ui/settings-app.js +23 -19
  31. package/dist/foundation/release/automatic-update.js +90 -22
  32. package/dist/foundation/release/bootstrap.js +2 -2
  33. package/dist/foundation/release/latest-release.d.ts +5 -1
  34. package/dist/foundation/release/latest-release.js +74 -31
  35. package/dist/integrations/pi/components/owned-editor-ux.js +48 -5
  36. package/dist/integrations/pi/engine/workflow-runner.js +1 -1
  37. package/dist/native/darwin-arm64/manifest.json +1 -1
  38. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  39. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  40. package/dist/native/darwin-x64/manifest.json +1 -1
  41. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  42. package/dist/native/darwin-x64/terminal-host.node +0 -0
  43. package/dist/native/linux-x64/manifest.json +1 -1
  44. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  45. package/dist/native/linux-x64/terminal-host.node +0 -0
  46. package/dist/native/win32-x64/manifest.json +2 -2
  47. package/dist/native/win32-x64/process-guardian.node +0 -0
  48. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  49. package/dist/native/win32-x64/terminal-host.node +0 -0
  50. package/dist/ui/components/dialog-panel.d.ts +17 -8
  51. package/dist/ui/components/dialog-panel.js +97 -26
  52. package/dist/ui/components/index.d.ts +3 -3
  53. package/dist/ui/components/index.js +3 -3
  54. package/dist/ui/components/scrollbar.d.ts +1 -1
  55. package/dist/ui/components/scrollbar.js +1 -1
  56. package/dist/ui/components/selection-copy.d.ts +2 -4
  57. package/dist/ui/components/selection-copy.js +4 -2
  58. package/dist/ui/components/text-selection.d.ts +1 -2
  59. package/dist/ui/components/text-selection.js +5 -2
  60. package/dist/ui/components/transcript-viewport.js +3 -3
  61. package/dist/ui/settings/declarations.d.ts +1 -1
  62. package/dist/ui/settings/declarations.js +1 -1
  63. package/docs/architecture/resident-tabs.md +5 -4
  64. package/docs/features/launch-profiles.md +7 -4
  65. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { DIALOG_BACK_SHORTCUT_HINT, DIALOG_CLOSE_SHORTCUT_HINT } from "../../contracts/presentation/index.js";
2
- import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, numericValues, RAIL_COLUMNS, renderDialogPanel, renderSteppedDialogPanel, renderEmptyState, renderGroupHeader, renderListRow, renderNote, renderShortcutHintsWithClose, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, truncateToWidth, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, formatLocalTime, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
2
+ import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, configureDialogChoiceCapacity, configureDialogTextRows, configureDialogWidth, numericValues, RAIL_COLUMNS, renderCenteredDialogOverlay, renderDialogPanel, renderSteppedDialogPanel, renderEmptyState, renderGroupHeader, renderListRow, renderNote, renderShortcutHintsWithClose, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, truncateToWidth, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, formatLocalTime, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
3
3
  import { PLUS_MENU_SETTING_ID, PlusMenuDialog } from "./plus-menu-dialog.js";
4
4
  import { SETTINGS_APP_ID } from "./settings-route.js";
5
5
  export { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
@@ -125,7 +125,11 @@ export class SettingsApp {
125
125
  */
126
126
  constructor(session, options) {
127
127
  this.#session = session;
128
- this.#options = { pinned: options.pinned === true, configurePlusMenu: options.configurePlusMenu ?? null };
128
+ this.#options = {
129
+ pinned: options.pinned === true,
130
+ configurePlusMenu: options.configurePlusMenu ?? null,
131
+ dialogPanel: options.dialogPanel ?? null,
132
+ };
129
133
  this.#search = {
130
134
  input: null,
131
135
  scrollBefore: null,
@@ -148,12 +152,11 @@ export class SettingsApp {
148
152
  }
149
153
  render(rect, host) {
150
154
  const theme = host.theme ?? PLAIN_THEME;
151
- const rows = this.#rows();
152
- const selected = indexOfKey(rows, this.#selectedKey);
155
+ const rows = this.#rows(), selected = indexOfKey(rows, this.#selectedKey);
153
156
  const footer = this.#footerLines(rect.width, theme);
154
- // Invariant: the shared search input and structured panel each begin with their own standard rule.
155
- // That rule replaces the ordinary content/footer divider instead of stacking beneath it.
156
- const dividerRows = this.#search.input === null && this.#structured === null ? SETTINGS_FOOTER_DIVIDER_ROWS : 0;
157
+ // Invariant: search owns its ruled footer. Configure dialogs float over the complete ordinary
158
+ // Settings frame, so the standing divider and guidance remain visible in the dimmed backdrop.
159
+ const dividerRows = this.#search.input === null ? SETTINGS_FOOTER_DIVIDER_ROWS : 0;
157
160
  const topRows = Math.min(SETTINGS_TOP_RULE_ROWS, rect.height);
158
161
  const contentHeight = Math.max(0, rect.height - topRows - dividerRows - footer.length);
159
162
  let titleRows = this.#scroll <= 0 ? Math.min(SETTINGS_TITLE_ROWS, contentHeight) : 0;
@@ -246,7 +249,8 @@ export class SettingsApp {
246
249
  });
247
250
  const rule = theme.fg("border", "─".repeat(Math.max(0, rect.width)));
248
251
  const frame = this.#withMenu([rule, ...withRail, ...(dividerRows === 0 ? [] : [rule]), ...footer], selected, layout, valueColumn, theme, rect, reservesRail ? RAIL_COLUMNS : 0);
249
- return frame.slice(0, rect.height).concat(Array(Math.max(0, rect.height - frame.length)).fill(""));
252
+ const complete = frame.slice(0, rect.height).concat(Array(Math.max(0, rect.height - frame.length)).fill("")), open = this.#structured, dialogTheme = this.#options.dialogPanel === null ? theme : { ...theme, panel: this.#options.dialogPanel };
253
+ return open === null ? complete : renderCenteredDialogOverlay(complete, this.#dialogLines(open, configureDialogWidth(rect.width), rect.height, dialogTheme), rect, dialogTheme);
250
254
  }
251
255
  onInput(data, host) {
252
256
  if (data === "\u0003" && this.#search.input === null)
@@ -983,9 +987,9 @@ export class SettingsApp {
983
987
  this.#menuFrame = frame;
984
988
  return renderValueMenu(lines, state, frame, theme);
985
989
  }
986
- #dialogLines(open, width, theme) {
990
+ #dialogLines(open, width, height, theme) {
987
991
  if (open.modelDialog)
988
- return this.#modelDialogLines(open, width, theme);
992
+ return this.#modelDialogLines(open, width, height, theme);
989
993
  if (open.plusMenu !== null)
990
994
  return open.plusMenu.lines(open.record, width, theme);
991
995
  const rows = open.flags.map(key => {
@@ -997,9 +1001,10 @@ export class SettingsApp {
997
1001
  ...(declared?.description === undefined ? {} : { description: declared.description }),
998
1002
  };
999
1003
  });
1000
- return renderDialogPanel({ title: labelOf(open.entry), rows, index: open.index, hint: SETTINGS_SHORTCUTS.hintEntries(DIALOG_SCOPE) }, width, theme);
1004
+ const visible = dialogWindow(rows, open.index, configureDialogChoiceCapacity(height, false, configureDialogTextRows(rows[open.index]?.description ?? "", width)));
1005
+ return renderDialogPanel({ title: labelOf(open.entry), rows: visible.rows, index: visible.index, hint: SETTINGS_SHORTCUTS.hintEntries(DIALOG_SCOPE) }, width, theme);
1001
1006
  }
1002
- #modelDialogLines(open, width, theme) {
1007
+ #modelDialogLines(open, width, height, theme) {
1003
1008
  if (open.step === "model") {
1004
1009
  const flags = this.#filteredModelFlags(open);
1005
1010
  const stored = structuredValue(open.entry, open.record);
@@ -1009,12 +1014,13 @@ export class SettingsApp {
1009
1014
  ...modelLabelParts(flag.label ?? humanizeLabel(flag.key)),
1010
1015
  value: stored[flag.key] === undefined ? "" : String(stored[flag.key]),
1011
1016
  }));
1012
- const visible = dialogWindow(allRows, open.index, 10);
1017
+ const description = "Select a model to configure";
1018
+ const visible = dialogWindow(allRows, open.index, configureDialogChoiceCapacity(height, true, configureDialogTextRows(description, width)));
1013
1019
  return renderSteppedDialogPanel({
1014
1020
  title: "Thinking Level",
1015
1021
  step: 1,
1016
1022
  steps: 2,
1017
- description: "Select a model to configure",
1023
+ description,
1018
1024
  ...(open.filter === null ? {} : { input: open.filter }),
1019
1025
  rows: visible.rows,
1020
1026
  index: visible.index,
@@ -1027,21 +1033,19 @@ export class SettingsApp {
1027
1033
  label: choice.value === current ? `✓ ${choice.label}` : choice.label,
1028
1034
  value: choice.description,
1029
1035
  }));
1030
- const visible = dialogWindow(allRows, open.index, 10);
1036
+ const description = `Select default thinking level for ${selected?.label ?? open.modelKey ?? "model"}`;
1037
+ const visible = dialogWindow(allRows, open.index, configureDialogChoiceCapacity(height, false, configureDialogTextRows(description, width)));
1031
1038
  return renderSteppedDialogPanel({
1032
1039
  title: "Thinking Level",
1033
1040
  step: 2,
1034
1041
  steps: 2,
1035
- description: `Select default thinking level for ${selected?.label ?? open.modelKey ?? "model"}`,
1042
+ description,
1036
1043
  rows: visible.rows,
1037
1044
  index: visible.index,
1038
1045
  hint: MODEL_SHORTCUTS.hintEntries(MODEL_LEVEL_SCOPE),
1039
1046
  }, width, theme);
1040
1047
  }
1041
1048
  #footerLines(width, theme) {
1042
- const open = this.#structured;
1043
- if (open !== null)
1044
- return this.#dialogLines(open, width, theme);
1045
1049
  // Rationale: standing guidance is more useful than transient feedback; failures still
1046
1050
  // roll back without displacing the Settings interaction contract.
1047
1051
  const entries = SETTINGS_SHORTCUTS.hintEntries(SCOPE).filter(entry => !this.#options.pinned
@@ -9,7 +9,8 @@ import { CohortStateStore } from "./cohort-state.js";
9
9
  import { processIsAlive } from "./process-cleanup.js";
10
10
  import { isNewerRelease, releaseChannelOf } from "./latest-release.js";
11
11
  import { readCertifiedReleaseManifest, resolveReleaseEntryPoint } from "./release-store.js";
12
- import { readUpdateExecutionOwner } from "./update-execution-lease.js";
12
+ import { acquireUpdateExecutionLease, readUpdateExecutionOwner } from "./update-execution-lease.js";
13
+ import { UpdateTransactionStore } from "./update-transaction.js";
13
14
  import { runSelfUpdate, UPDATE_ALREADY_RUNNING_EXIT_CODE } from "./update.js";
14
15
  export const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
15
16
  export const AUTOMATIC_UPDATE_RETRY_MS = 24 * 60 * 60_000;
@@ -72,10 +73,37 @@ export async function requestAutomaticUpdate(options) {
72
73
  catch {
73
74
  return null;
74
75
  }
75
- const readActiveVersion = options.activeVersion ?? (async () => await activeNewerVersion(paths.dataDir, options.runningVersion));
76
+ const readActiveVersion = options.activeVersion ?? (async () => await activeNewerVersion(paths.dataDir, options.runningVersion, options.release));
76
77
  const active = await readActiveVersion();
77
78
  if (active !== null)
78
79
  return active;
80
+ let owner;
81
+ try {
82
+ owner = await readUpdateExecutionOwner(paths.dataDir);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ let transaction;
88
+ try {
89
+ transaction = await new UpdateTransactionStore(paths.dataDir).read();
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ // Invariant: recovery of a foreign in-progress transaction belongs to its own owner.
95
+ if (transaction?.status === "active" && (transaction.targetVersion !== options.release.version
96
+ || transaction.channel !== (options.release.channel === "stable" ? "stable" : "next")))
97
+ return null;
98
+ if (existing?.status === "running" && now() - existing.updatedAt >= WORKER_START_GRACE_MS
99
+ && (owner === null || !processIsAlive(owner.pid))) {
100
+ // Invariant: a coordinator record is not ownership. Settle only when no transaction
101
+ // remains to recover; never overwrite or roll back a foreign in-progress target.
102
+ if (transaction === null) {
103
+ existing = { ...existing, status: "failed", updatedAt: now(), failure: "worker-exited" };
104
+ await store.write(existing).catch(() => undefined);
105
+ }
106
+ }
79
107
  if (existing?.status === "failed" && existing.channel === options.release.channel && now() - existing.attemptedAt >= 0
80
108
  && now() - existing.attemptedAt < AUTOMATIC_UPDATE_RETRY_MS
81
109
  && !isNewerRelease(options.release.version, existing.targetVersion))
@@ -100,15 +128,16 @@ export async function requestAutomaticUpdate(options) {
100
128
  "--target-version", options.release.version,
101
129
  "--data-dir", paths.dataDir,
102
130
  ];
103
- let owner;
104
- try {
105
- owner = await readUpdateExecutionOwner(paths.dataDir);
106
- }
107
- catch {
108
- return null;
109
- }
110
131
  if (owner === null || !processIsAlive(owner.pid)) {
111
- await (options.spawnWorker ?? spawnAutomaticUpdateWorker)(worker.entry, arguments_, environment).catch(() => undefined);
132
+ try {
133
+ await (options.spawnWorker ?? spawnAutomaticUpdateWorker)(worker.entry, arguments_, environment);
134
+ }
135
+ catch {
136
+ // Invariant: no worker began this attempt. Do not invent a successful install or an owner.
137
+ await store.write(state({ packageRoot: worker.packageRoot, channel: options.release.channel,
138
+ targetVersion: options.release.version, dataDir: paths.dataDir }, "failed", now(), null, "worker-start-failed")).catch(() => undefined);
139
+ return null;
140
+ }
112
141
  }
113
142
  const startedAt = now();
114
143
  const sleep = options.sleep ?? (async (durationMs) => await new Promise(resolvePromise => {
@@ -134,15 +163,22 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
134
163
  const paths = resolveProductPaths(environment);
135
164
  if (!samePath(parsed.dataDir, paths.dataDir))
136
165
  return 1;
137
- const launch = readLaunchContext(environment, "release");
138
- const cohort = await new CohortStateStore(paths.dataDir).read();
139
- const record = launch.releaseId === undefined ? undefined : cohort.releases[launch.releaseId];
140
- if (!record || record.approval !== "approved" || !samePath(record.releaseRoot, launch.releaseRoot ?? "")
141
- || record.contentDigest !== launch.releaseDigest)
142
- return 1;
143
- const selected = await readCertifiedReleaseManifest(record, resolve(paths.dataDir, "releases"));
144
- if (!samePath(selected.packageRoot, parsed.packageRoot))
166
+ let verified = false;
167
+ try {
168
+ const launch = readLaunchContext(environment, "release");
169
+ const cohort = await new CohortStateStore(paths.dataDir).read();
170
+ const record = launch.releaseId === undefined ? undefined : cohort.releases[launch.releaseId];
171
+ if (record?.approval === "approved" && samePath(record.releaseRoot, launch.releaseRoot ?? "")
172
+ && record.contentDigest === launch.releaseDigest) {
173
+ const selected = await readCertifiedReleaseManifest(record, resolve(paths.dataDir, "releases"));
174
+ verified = samePath(selected.packageRoot, parsed.packageRoot);
175
+ }
176
+ }
177
+ catch { /* Security: invalid provenance fails closed without running the updater. */ }
178
+ if (!verified) {
179
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
145
180
  return 1;
181
+ }
146
182
  const store = new AutomaticUpdateStateStore(paths.dataDir);
147
183
  const attemptedAt = Date.now();
148
184
  const output = { stdout() { }, stderr() { } };
@@ -166,7 +202,10 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
166
202
  return 0;
167
203
  if (code !== 0) {
168
204
  await runningWrite;
169
- await store.write(state(parsed, "failed", attemptedAt, null, `exit-${code}`));
205
+ if (acquired)
206
+ await store.write(state(parsed, "failed", attemptedAt, null, `exit-${code}`));
207
+ else
208
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
170
209
  return code;
171
210
  }
172
211
  const active = await activeRelease(paths.dataDir);
@@ -176,6 +215,8 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
176
215
  await runningWrite;
177
216
  await store.write(state(parsed, "failed", attemptedAt, null, "target-not-active"));
178
217
  }
218
+ else
219
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
179
220
  return 1;
180
221
  }
181
222
  await runningWrite;
@@ -184,10 +225,34 @@ export async function runAutomaticUpdateWorker(argv, environment = process.env)
184
225
  }
185
226
  catch (error) {
186
227
  await runningWrite.catch(() => undefined);
187
- await store.write(state(parsed, "failed", attemptedAt, null, errorMessage(error).slice(0, 512))).catch(() => undefined);
228
+ if (acquired)
229
+ await store.write(state(parsed, "failed", attemptedAt, null, errorMessage(error).slice(0, 512))).catch(() => undefined);
230
+ else
231
+ await recordAutomaticPreflightFailure(parsed, paths.dataDir);
188
232
  return 1;
189
233
  }
190
234
  }
235
+ async function recordAutomaticPreflightFailure(parsed, dataDir) {
236
+ // Security: even a rejected worker must claim the ordinary update lease before recording
237
+ // failure, so it cannot overwrite another owner's result or an unfinished transaction.
238
+ const lease = await acquireUpdateExecutionLease({ dataDir, packageRoot: parsed.packageRoot,
239
+ channel: parsed.channel === "stable" ? "stable" : "next", targetVersion: parsed.targetVersion, mode: "automatic" }).catch(() => null);
240
+ if (lease?.status !== "acquired")
241
+ return;
242
+ try {
243
+ if (await new UpdateTransactionStore(dataDir).read() !== null)
244
+ return;
245
+ const store = new AutomaticUpdateStateStore(dataDir);
246
+ const existing = await store.read();
247
+ if (existing !== null && (existing.targetVersion !== parsed.targetVersion || existing.status === "installed"))
248
+ return;
249
+ await store.write(state(parsed, "failed", Date.now(), null, "worker-preflight-failed"));
250
+ }
251
+ catch { /* Rationale: preflight failure is best effort; never mask the rejected worker. */ }
252
+ finally {
253
+ await lease.release().catch(() => undefined);
254
+ }
255
+ }
191
256
  async function spawnAutomaticUpdateWorker(entry, arguments_, environment) {
192
257
  const child = spawn(process.execPath, [entry, ...arguments_], {
193
258
  detached: true,
@@ -201,9 +266,12 @@ async function spawnAutomaticUpdateWorker(entry, arguments_, environment) {
201
266
  });
202
267
  child.unref();
203
268
  }
204
- async function activeNewerVersion(dataDir, runningVersion) {
269
+ async function activeNewerVersion(dataDir, runningVersion, target) {
205
270
  const active = await activeRelease(dataDir);
206
- return active !== null && isNewerRelease(active.packageVersion, runningVersion) ? active.packageVersion : null;
271
+ return active !== null && releaseChannelOf(active.packageVersion) === target.channel
272
+ && isNewerRelease(active.packageVersion, runningVersion)
273
+ && (active.packageVersion === target.version || isNewerRelease(active.packageVersion, target.version))
274
+ ? active.packageVersion : null;
207
275
  }
208
276
  async function activeRelease(dataDir) {
209
277
  const cohort = await new CohortStateStore(dataDir).read();
@@ -31,8 +31,8 @@ async function runBootstrapAttempt(options, retriesRemaining) {
31
31
  await markStartupPhase(environment, "bootstrap-start");
32
32
  assertLaunchProfileId(launchProfileId);
33
33
  const sessionArgs = sessionSelectionArguments(options.launchIntent?.sessionSelection);
34
- if (sessionArgs.length > 0 && (launchProfileId !== "a1" || launchMode === "solo")) {
35
- throw new Error("session selection requires the normal tabbed A1 launch");
34
+ if (sessionArgs.length > 0 && launchProfileId !== "a1") {
35
+ throw new Error("session selection requires the A1 profile");
36
36
  }
37
37
  const output = options.output ?? process.stderr;
38
38
  const paths = resolveProductPaths(environment);
@@ -21,6 +21,7 @@ export interface AvailableRelease {
21
21
  }
22
22
  export declare const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
23
23
  export declare const UPDATE_CHECK_INTERVAL_MS: number;
24
+ export declare const NOTIFY_CHECK_INTERVAL_MS: number;
24
25
  /** One published version with the pull request its manifest records, when it records one. */
25
26
  export interface PublishedVersion {
26
27
  readonly version: string;
@@ -54,7 +55,7 @@ export declare function parsePublishedVersions(document: unknown): PublishedVers
54
55
  /** Forget the startup release check so the next interactive launch asks the registry again. */
55
56
  export declare function invalidateReleaseCheckCache(configDir: string): Promise<void>;
56
57
  export declare function unavailableDistTags(error: string): DistTagVersions;
57
- export declare function createRegistryFetcher(timeoutMs: number): RegistryFetcher;
58
+ export declare function createRegistryFetcher(timeoutMs: number, signal?: AbortSignal): RegistryFetcher;
58
59
  export interface StartupReleaseCheckOptions {
59
60
  readonly runningVersion: string;
60
61
  readonly configDir: string;
@@ -64,6 +65,9 @@ export interface StartupReleaseCheckOptions {
64
65
  /** The bare-A1 `updateCheck` setting; comparison profiles omit it and use auto. */
65
66
  readonly settingMode?: StartupUpdateMode;
66
67
  readonly fetcher?: RegistryFetcher;
68
+ readonly signal?: AbortSignal;
69
+ /** Remaining cache lifetime for a live Auto observer, when discovery succeeds. */
70
+ readonly onNextCheckDelay?: (milliseconds: number) => void;
67
71
  readonly now?: () => number;
68
72
  }
69
73
  /** Why the startup check is skipped before any cache read or network access, or null when it may run. */
@@ -2,12 +2,14 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { gt, prerelease, valid as validSemver } from "semver";
5
+ import lockfile from "proper-lockfile";
5
6
  import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
6
7
  export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
7
- export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
8
+ export const UPDATE_CHECK_INTERVAL_MS = 15 * 60_000;
9
+ export const NOTIFY_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
8
10
  const STARTUP_FETCH_TIMEOUT_MS = 3_000;
9
11
  const CACHE_FILENAME = "update-check.json";
10
- const CACHE_VERSION = 1;
12
+ const CACHE_VERSION = 2;
11
13
  const MAX_CACHE_BYTES = 4096;
12
14
  const MAX_PACKAGE_DOCUMENT_BYTES = 32 * 1024 * 1024;
13
15
  const DEVELOPMENT_BUILD = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-dev\.(0|[1-9]\d*)$/u;
@@ -105,13 +107,14 @@ export function parsePublishedVersions(document) {
105
107
  }
106
108
  /** Forget the startup release check so the next interactive launch asks the registry again. */
107
109
  export async function invalidateReleaseCheckCache(configDir) {
108
- await rm(join(configDir, CACHE_FILENAME), { force: true });
110
+ await withCacheLock(configDir, async () => { await rm(join(configDir, CACHE_FILENAME), { force: true }); });
109
111
  }
110
112
  export function unavailableDistTags(error) {
111
113
  return { release: null, develop: null, error };
112
114
  }
113
- export function createRegistryFetcher(timeoutMs) {
114
- return async (url) => await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
115
+ export function createRegistryFetcher(timeoutMs, signal) {
116
+ return async (url) => await fetch(url, { headers: { accept: "application/json" },
117
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
115
118
  }
116
119
  /** Why the startup check is skipped before any cache read or network access, or null when it may run. */
117
120
  export function startupReleaseCheckSkipReason(options) {
@@ -136,22 +139,33 @@ export function startupReleaseCheckSkipReason(options) {
136
139
  * answer replaces the cache. Every failure resolves to null: the notice is best-effort.
137
140
  */
138
141
  export async function checkForNewerRelease(options) {
139
- if (startupReleaseCheckSkipReason(options) !== null)
142
+ if (options.signal?.aborted || startupReleaseCheckSkipReason(options) !== null)
140
143
  return null;
141
144
  const channel = releaseChannelOf(options.runningVersion);
142
145
  const environment = options.environment ?? process.env;
143
146
  const now = options.now ?? Date.now;
144
147
  const cachePath = join(options.configDir, CACHE_FILENAME);
145
148
  try {
146
- let latest = await readFreshCache(cachePath, channel, now());
147
- if (latest === null) {
148
- const tags = await queryRegistryDistTags(options.fetcher ?? createRegistryFetcher(STARTUP_FETCH_TIMEOUT_MS), environment.npm_config_registry?.trim() || undefined);
149
- latest = channel === "stable" ? tags.release : tags.develop;
150
- if (tags.error !== null || latest === null)
149
+ const interval = options.settingMode === "notify" ? NOTIFY_CHECK_INTERVAL_MS : UPDATE_CHECK_INTERVAL_MS;
150
+ const latest = await withCacheLock(options.configDir, async () => {
151
+ if (options.signal?.aborted)
151
152
  return null;
152
- await writeCache(options.configDir, cachePath, { version: CACHE_VERSION, channel, latest, checkedAt: now() }).catch(() => undefined);
153
- }
154
- return isNewerRelease(latest, options.runningVersion) ? availableRelease(channel, latest) : null;
153
+ const document = await readCache(cachePath);
154
+ const cached = freshVersion(document[channel], now(), interval);
155
+ if (cached !== null)
156
+ return cached;
157
+ const tags = await queryRegistryDistTags(options.fetcher ?? createRegistryFetcher(STARTUP_FETCH_TIMEOUT_MS, options.signal), environment.npm_config_registry?.trim() || undefined);
158
+ const discovered = channel === "stable" ? tags.release : tags.develop;
159
+ if (tags.error !== null || discovered === null || options.signal?.aborted)
160
+ return null;
161
+ const entry = { latest: discovered, checkedAt: now() };
162
+ await writeCache(options.configDir, cachePath, { ...document, [channel]: entry }).catch(() => undefined);
163
+ return entry;
164
+ });
165
+ if (options.signal?.aborted || latest === null)
166
+ return null;
167
+ options.onNextCheckDelay?.(Math.min(interval, Math.max(1, interval - (now() - latest.checkedAt))));
168
+ return isNewerRelease(latest.latest, options.runningVersion) ? availableRelease(channel, latest.latest) : null;
155
169
  }
156
170
  catch {
157
171
  return null;
@@ -165,29 +179,58 @@ export function availableRelease(channel, version) {
165
179
  changelogUrl: channel === "stable" ? `${RELEASE_PAGE}${version}` : null,
166
180
  };
167
181
  }
168
- async function readFreshCache(path, channel, now) {
169
- let parsed;
182
+ function validEntry(value, channel) {
183
+ if (typeof value !== "object" || value === null)
184
+ return undefined;
185
+ const entry = value;
186
+ return typeof entry.latest === "string" && releaseChannelOf(entry.latest) === channel
187
+ && typeof entry.checkedAt === "number" && Number.isFinite(entry.checkedAt) ? { latest: entry.latest, checkedAt: entry.checkedAt } : undefined;
188
+ }
189
+ async function readCache(path) {
170
190
  try {
171
191
  const source = await readFile(path, "utf8");
172
- if (source.length > MAX_CACHE_BYTES)
173
- return null;
174
- parsed = JSON.parse(source);
192
+ if (Buffer.byteLength(source, "utf8") > MAX_CACHE_BYTES)
193
+ return { version: CACHE_VERSION };
194
+ const value = JSON.parse(source);
195
+ if (typeof value !== "object" || value === null || Array.isArray(value))
196
+ return { version: CACHE_VERSION };
197
+ const cache = value;
198
+ if (cache.version === 1) {
199
+ // Compatibility: an old cache has exactly one channel; do not infer a value for the other.
200
+ if (cache.channel !== "stable" && cache.channel !== "development")
201
+ return { version: CACHE_VERSION };
202
+ const entry = validEntry(cache, cache.channel);
203
+ return entry ? { version: CACHE_VERSION, [cache.channel]: entry } : { version: CACHE_VERSION };
204
+ }
205
+ if (cache.version !== CACHE_VERSION)
206
+ return { version: CACHE_VERSION };
207
+ const stable = validEntry(cache.stable, "stable");
208
+ const development = validEntry(cache.development, "development");
209
+ return { version: CACHE_VERSION, ...(stable ? { stable } : {}), ...(development ? { development } : {}) };
175
210
  }
176
211
  catch {
177
- return null;
212
+ return { version: CACHE_VERSION };
178
213
  }
179
- if (typeof parsed !== "object" || parsed === null)
180
- return null;
181
- const cache = parsed;
182
- if (cache.version !== CACHE_VERSION || cache.channel !== channel)
183
- return null;
184
- if (typeof cache.latest !== "string" || validSemver(cache.latest) === null)
185
- return null;
186
- if (typeof cache.checkedAt !== "number" || !Number.isFinite(cache.checkedAt))
214
+ }
215
+ function freshVersion(entry, now, interval) {
216
+ if (!entry)
187
217
  return null;
188
- // Invariant: a timestamp from the future (clock moved back) is stale rather than fresh forever.
189
- const age = now - cache.checkedAt;
190
- return age >= 0 && age < UPDATE_CHECK_INTERVAL_MS ? cache.latest : null;
218
+ // Invariant: a timestamp from the future is stale rather than fresh forever.
219
+ const age = now - entry.checkedAt;
220
+ return age >= 0 && age < interval ? entry : null;
221
+ }
222
+ async function withCacheLock(directory, operation) {
223
+ await mkdir(directory, { recursive: true });
224
+ const release = await lockfile.lock(join(directory, CACHE_FILENAME), {
225
+ realpath: false, stale: 30_000, update: 10_000,
226
+ retries: { retries: 20, factor: 1.2, minTimeout: 20, maxTimeout: 100, randomize: true },
227
+ });
228
+ try {
229
+ return await operation();
230
+ }
231
+ finally {
232
+ await release();
233
+ }
191
234
  }
192
235
  async function writeCache(directory, path, document) {
193
236
  await mkdir(directory, { recursive: true });
@@ -67,7 +67,7 @@ class PromptSelectionInterceptor {
67
67
  #lastClick;
68
68
  #redoStack = [];
69
69
  #selectionRevision = 0;
70
- #pasteGeneration = 0;
70
+ #pasteState = { generation: 0, preview: undefined };
71
71
  #terminalPaste;
72
72
  #wordDirection;
73
73
  #geometry;
@@ -81,6 +81,7 @@ class PromptSelectionInterceptor {
81
81
  installAtomicSegmentation(editor, options.atomicRanges, () => this.#wordDirection);
82
82
  }
83
83
  handleInput(data, next) {
84
+ this.#pasteState.preview = undefined;
84
85
  if (this.options.pasteEnabled === false ? discardTerminalPaste(this, data, value => this.handleInput(value, next)) : this.#handleTerminalPaste(data, next))
85
86
  return;
86
87
  if (this.keybindings.matches(data, "owned.editor.selectAll")) {
@@ -195,7 +196,26 @@ class PromptSelectionInterceptor {
195
196
  this.#redoStack = [];
196
197
  }
197
198
  render(width, next) {
199
+ const preview = this.#pasteState.preview;
198
200
  const state = editorState(this.editor);
201
+ if (preview !== undefined && this.editor.getText() === preview.reservedText
202
+ && this.#selectionRevision === preview.revision
203
+ && samePosition(this.#cursor(), preview.reservedCursor)
204
+ && this.#selection === undefined) {
205
+ // Invariant: painting does not commit the reserved edit.
206
+ const original = { lines: [...state.lines], cursor: this.#cursor(), selection: this.#selection };
207
+ this.#pasteState.preview = undefined;
208
+ try {
209
+ this.#setRenderState(preview.lines, preview.cursor);
210
+ this.#selection = preview.selection;
211
+ return this.render(width, next);
212
+ }
213
+ finally {
214
+ this.#setRenderState(original.lines, original.cursor);
215
+ this.#selection = original.selection;
216
+ this.#pasteState.preview = preview;
217
+ }
218
+ }
199
219
  const hidden = state.lines.map(line => this.options.hiddenRanges?.(line) ?? []);
200
220
  if (!hidden.some(ranges => ranges.length > 0))
201
221
  return this.#renderVisible(width, next);
@@ -277,6 +297,7 @@ class PromptSelectionInterceptor {
277
297
  return rows.map((row, index) => this.options.decorateRow(row, width, index));
278
298
  }
279
299
  reset() {
300
+ this.#pasteState.preview = undefined;
280
301
  this.#selection = undefined;
281
302
  this.#pointerSelecting = false;
282
303
  this.#lastClick = undefined;
@@ -285,7 +306,8 @@ class PromptSelectionInterceptor {
285
306
  this.#selectionRevision += 1;
286
307
  }
287
308
  cancelPendingPastes() {
288
- this.#pasteGeneration += 1;
309
+ this.#pasteState.generation += 1;
310
+ this.#pasteState.preview = undefined;
289
311
  this.#terminalPaste = undefined;
290
312
  DISABLED_TERMINAL_PASTES.delete(this);
291
313
  }
@@ -309,6 +331,14 @@ class PromptSelectionInterceptor {
309
331
  handlePointer(event) {
310
332
  if (event.button !== 0)
311
333
  return false;
334
+ if (this.#pasteState.preview !== undefined) {
335
+ this.#pasteState.preview = undefined;
336
+ // Invariant: preview coordinates cannot address a hidden marker.
337
+ this.#geometry = undefined;
338
+ this.#pointerSelecting = event.kind === "press";
339
+ this.#requestRender();
340
+ return true;
341
+ }
312
342
  if (event.kind === "press")
313
343
  return this.#pointerPress(event.column, event.row);
314
344
  if (event.kind === "motion") {
@@ -539,17 +569,30 @@ class PromptSelectionInterceptor {
539
569
  const begin = source === undefined ? this.options.beginClipboardPaste
540
570
  : this.options.beginTextPaste === undefined ? undefined : () => this.options.beginTextPaste(source);
541
571
  if (begin !== undefined) {
542
- const generation = this.#pasteGeneration;
572
+ const generation = this.#pasteState.generation;
543
573
  const restore = this.#orderedSelection() === undefined ? "" : this.#selectedText();
544
574
  const paste = begin();
545
575
  this.#redoStack = [];
546
- if (this.#orderedSelection() !== undefined)
576
+ const selected = this.#orderedSelection();
577
+ if (selected !== undefined) {
578
+ const lines = [...editorState(this.editor).lines];
579
+ const cursor = this.#cursor();
580
+ const selection = this.#selection;
581
+ const current = this.editor.getText();
582
+ const from = positionOffset(lines, selected.start);
583
+ const to = positionOffset(lines, selected.end);
584
+ const reservedText = current.slice(0, from) + normalizeInsertedText(paste.marker) + current.slice(to);
547
585
  this.#replaceSelection(paste.marker);
586
+ this.#pasteState.preview = { marker: paste.marker, reservedText, lines, cursor, selection,
587
+ reservedCursor: this.#cursor(), revision: this.#selectionRevision };
588
+ }
548
589
  else
549
590
  this.editor.insertTextAtCursor(paste.marker);
550
591
  this.#requestRender();
551
592
  const replace = (text) => {
552
- if (generation === this.#pasteGeneration)
593
+ if (this.#pasteState.preview?.marker === paste.marker)
594
+ this.#pasteState.preview = undefined;
595
+ if (generation === this.#pasteState.generation)
553
596
  this.#replacePasteMarker(paste.marker, paste.isCurrent?.() === false ? restore : text || restore);
554
597
  paste.complete?.();
555
598
  };
@@ -443,7 +443,7 @@ export class PiWorkflowRunner {
443
443
  case "copy": {
444
444
  const text = requireCapability(session.getLastAssistantText, "getLastAssistantText").call(session);
445
445
  if (typeof text !== "string" || text.length === 0)
446
- return workflowResult(request.command, "failed", "No agent messages to copy yet.");
446
+ return workflowResult(request.command, "completed", "", undefined, "silent");
447
447
  const acknowledged = await this.copyWorkflowText(text);
448
448
  return workflowResult(request.command, "completed", acknowledged ? "Copied last agent message to clipboard" : "Submitted last agent message to clipboard");
449
449
  }
@@ -7,7 +7,7 @@
7
7
  "platform": "darwin",
8
8
  "architecture": "arm64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-28T07:16:03.705Z",
10
+ "builtAt": "2026-10-29T14:00:11.225Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "a87eaaffcd986b624c8b044b756446cc1bdaf4334a1775b97820542fe34f9fb7",
@@ -9,11 +9,11 @@
9
9
  "architecture": "arm64",
10
10
  "target": "aarch64-apple-darwin",
11
11
  "capability": "supported",
12
- "builtAt": "2026-10-28T07:18:47.307Z",
12
+ "builtAt": "2026-10-29T14:03:13.305Z",
13
13
  "artifact": {
14
14
  "filename": "terminal-host.node",
15
- "sha256": "2c9107be459d3cce4f154eb34fd839b34d784a82b280d9f56896908d1e7cb1bf",
16
- "size": 6001744
15
+ "sha256": "5f1a93ca9a050c2769a4e322f4d765b00f2ccc9451bcde7ac978b573ee544bef",
16
+ "size": 6019328
17
17
  },
18
18
  "entry": {
19
19
  "filename": "dist/foundation/resident-tabs/host-entry.js",
@@ -7,7 +7,7 @@
7
7
  "platform": "darwin",
8
8
  "architecture": "x64",
9
9
  "capability": "supported",
10
- "builtAt": "2026-10-28T07:16:10.391Z",
10
+ "builtAt": "2026-10-29T14:00:18.680Z",
11
11
  "artifact": {
12
12
  "filename": "process-guardian.node",
13
13
  "sha256": "783a65f0de287a1c02cd5429dbe97bc8633f4cc8a7b92e3e961180b9bdcff814",