@timurproko/a1 0.2.2-dev.611 → 0.2.2

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 (67) hide show
  1. package/README.md +16 -8
  2. package/bin/pinned-pi-public.js +11 -4
  3. package/dist/app/session-shell/index.d.ts +1 -1
  4. package/dist/app/session-shell/paste-executor.js +6 -1
  5. package/dist/app/session-shell/prompt-chips.d.ts +8 -0
  6. package/dist/app/session-shell/prompt-chips.js +58 -13
  7. package/dist/app/session-shell/session-shell-root.d.ts +21 -3
  8. package/dist/app/session-shell/session-shell-root.js +110 -17
  9. package/dist/app/session-shell/session-shell.d.ts +1 -1
  10. package/dist/app/session-shell/session-shell.js +76 -31
  11. package/dist/app/session-shell/session-viewport-controller.d.ts +13 -0
  12. package/dist/app/session-shell/session-viewport-controller.js +28 -1
  13. package/dist/cli/version-stats.js +4 -7
  14. package/dist/composition/owned-ui.d.ts +7 -0
  15. package/dist/composition/owned-ui.js +71 -3
  16. package/dist/contracts/owned-ui/image-attachments.d.ts +7 -4
  17. package/dist/contracts/owned-ui/image-attachments.js +20 -7
  18. package/dist/contracts/owned-ui/index.d.ts +1 -1
  19. package/dist/contracts/owned-ui/index.js +1 -1
  20. package/dist/contracts/owned-ui/prompt-chips.d.ts +2 -2
  21. package/dist/contracts/owned-ui/prompt-chips.js +9 -6
  22. package/dist/features/owned-ui/project-trust-prompt.js +39 -3
  23. package/dist/features/owned-ui/release-note-state.d.ts +13 -0
  24. package/dist/features/owned-ui/release-note-state.js +109 -0
  25. package/dist/features/owned-ui/release-notes.d.ts +10 -0
  26. package/dist/features/owned-ui/release-notes.js +73 -0
  27. package/dist/features/owned-ui/resources/release-notes.json +9 -0
  28. package/dist/foundation/release/latest-release.d.ts +55 -0
  29. package/dist/foundation/release/latest-release.js +166 -0
  30. package/dist/foundation/release/update.js +9 -0
  31. package/dist/integrations/pi/components/index.d.ts +1 -1
  32. package/dist/integrations/pi/components/index.js +1 -1
  33. package/dist/integrations/pi/components/prompt-chip-presentation.d.ts +3 -0
  34. package/dist/integrations/pi/components/prompt-chip-presentation.js +14 -0
  35. package/dist/integrations/pi/components/shell-footer-status.js +30 -9
  36. package/dist/integrations/pi/components/shell-presenters-info.d.ts +36 -0
  37. package/dist/integrations/pi/components/shell-presenters-info.js +77 -1
  38. package/dist/integrations/pi/components/shell-presenters-transcript.d.ts +0 -6
  39. package/dist/integrations/pi/components/shell-presenters-transcript.js +0 -19
  40. package/dist/integrations/pi/components/shell-shared-facade.d.ts +2 -1
  41. package/dist/integrations/pi/components/submitted-prompt-adapter.d.ts +1 -1
  42. package/dist/integrations/pi/components/submitted-prompt-adapter.js +13 -4
  43. package/dist/integrations/pi/engine/adapter.d.ts +12 -0
  44. package/dist/integrations/pi/engine/adapter.js +13 -0
  45. package/dist/integrations/pi/engine/session-runtime.d.ts +1 -0
  46. package/dist/integrations/pi/engine/session-runtime.js +2 -1
  47. package/dist/native/darwin-arm64/manifest.json +1 -1
  48. package/dist/native/linux-x64/manifest.json +1 -1
  49. package/dist/native/win32-x64/manifest.json +2 -2
  50. package/dist/native/win32-x64/process-guardian.exe +0 -0
  51. package/dist/product-identity.d.ts +1 -1
  52. package/dist/product-identity.js +1 -1
  53. package/dist/product-identity.json +1 -0
  54. package/dist/ui/settings/declarations.d.ts +25 -1
  55. package/dist/ui/settings/declarations.js +19 -1
  56. package/dist/ui/settings/migrations.js +14 -0
  57. package/docs/architecture/internal-naming.md +2 -2
  58. package/docs/architecture/resource-and-data-policy.md +11 -0
  59. package/docs/architecture/toolchain.md +1 -1
  60. package/docs/architecture/ui-reference-provenance.md +1 -0
  61. package/docs/ci-release-runbook.md +154 -70
  62. package/docs/local-worktree-cleanup.md +21 -4
  63. package/docs/manual-owned-ui-checkpoint.md +1 -0
  64. package/docs/openspec-archive-automation.md +1 -1
  65. package/docs/releases/0.2.2.md +47 -0
  66. package/docs/validation.md +13 -3
  67. package/package.json +3 -3
@@ -20,14 +20,11 @@ export async function runVersionStats(options) {
20
20
  output.stderr(`${PRODUCT_TEXT.diagnostic(`could not read its installed version: ${message(error)}`)}\n`);
21
21
  return 1;
22
22
  }
23
- // Compatibility: match Pi's release behavior: stable builds print only their installed version.
24
- // Development builds retain channel visibility for preview comparison and updates.
25
- if (prerelease(installed) === null) {
26
- output.stdout(`${installed}\n`);
27
- return 0;
28
- }
23
+ // Rationale: unlike Pi, stable builds also show the release channel head so users can see they are
24
+ // behind without starting a session. Only development builds show the preview channel.
29
25
  const remote = await queryDistTags(runner, options.fetcher ?? defaultRegistryFetcher);
30
- output.stdout(`Current: ${installed}\nDevelop: ${remote.develop ?? "unavailable"}\nRelease: ${remote.release ?? "unavailable"}\n`);
26
+ const develop = prerelease(installed) === null ? "" : `Develop: ${remote.develop ?? "unavailable"}\n`;
27
+ output.stdout(`Current: ${installed}\n${develop}Release: ${remote.release ?? "unavailable"}\n`);
31
28
  if (remote.error)
32
29
  output.stderr(`${PRODUCT_TEXT.diagnostic(`could not resolve npm dist-tags: ${remote.error}`)}\n`);
33
30
  return 0;
@@ -1,9 +1,11 @@
1
+ import type { AvailableRelease, StartupReleaseCheckOptions } from "../foundation/release/latest-release.js";
1
2
  import type { SessionSelection } from "../foundation/lifecycle/session-selection.js";
2
3
  import type { PiEngineAdapter } from "../integrations/pi/engine/adapter.js";
3
4
  import type { PiProjectTrustPreflightPrompt } from "../integrations/pi/engine/project-trust-preflight.js";
4
5
  import type { PiSessionForkPrompt } from "../integrations/pi/engine/session-selection.js";
5
6
  import { OwnedSettingsManager } from "../ui/settings/manager.js";
6
7
  import type { OwnedUiApplicationPort, PresentationTerminalPort } from "../contracts/presentation/index.js";
8
+ import type { ReleaseNoteCatalog } from "../features/owned-ui/release-notes.js";
7
9
  export interface OwnedUiCompositionOptions {
8
10
  readonly cwd?: string;
9
11
  readonly terminal?: PresentationTerminalPort;
@@ -27,6 +29,11 @@ export interface OwnedUiCompositionOptions {
27
29
  readonly suggestionDiagnosticsPath?: string;
28
30
  /** Optional local metadata-only clipboard diagnostics; never enabled in comparison profiles. */
29
31
  readonly clipboardDiagnosticsPath?: string;
32
+ /** Deterministic release-note seams for composition tests. */
33
+ readonly packageVersion?: string;
34
+ readonly releaseNotes?: ReleaseNoteCatalog;
35
+ /** Startup release-availability seam; defaults to the throttled registry check. */
36
+ readonly checkForNewerRelease?: (options: StartupReleaseCheckOptions) => Promise<AvailableRelease | null>;
30
37
  }
31
38
  export interface OwnedUiComposition {
32
39
  readonly application: OwnedUiApplicationPort;
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { SuggestionDiagnosticCapture } from "../features/prompt-suggestions/diagnostics.js";
2
3
  import { PRODUCT_IDENTITY } from "../product-identity.js";
3
4
  import { PromptHistoryService } from "../features/prompt-history/service.js";
@@ -14,7 +15,6 @@ import { OwnedSettingsManager } from "../ui/settings/manager.js";
14
15
  import { createPiTerminalBridge } from "../integrations/pi/tui-runtime/presentation-adapter.js";
15
16
  import { createOwnedRouteHost } from "./settings-route-host.js";
16
17
  import { renderPiShellChangelogLines } from "../integrations/pi/components/shell-presenters-info.js";
17
- import { readPinnedCommandChangelog } from "../integrations/pi/engine/changelog.js";
18
18
  export async function composeOwnedUiApplication(options = {}) {
19
19
  return (await composeOwnedUi(options)).application;
20
20
  }
@@ -27,6 +27,7 @@ export async function composeOwnedUi(options = {}) {
27
27
  cwd,
28
28
  availableThemes: () => getAvailablePiThemes().map(theme => theme.name),
29
29
  settingsProductMode: ownedSurfaces ? "bare" : "comparison",
30
+ announceStartupChangelog: !ownedSurfaces,
30
31
  ...(ownedSurfaces ? {
31
32
  repositoryContextReader: async (sessionId, sessionFile, signal) => await readSessionRepositoryContext({ sessionId, sessionFile }, { signal }),
32
33
  } : {}),
@@ -35,6 +36,7 @@ export async function composeOwnedUi(options = {}) {
35
36
  ...(options.sessionForkPrompt === undefined ? {} : { sessionForkPrompt: options.sessionForkPrompt }),
36
37
  ...(options.projectTrustPrompt === undefined ? {} : { projectTrustPrompt: options.projectTrustPrompt }),
37
38
  });
39
+ const productPaths = resolveProductPaths();
38
40
  const settings = options.profileId === undefined
39
41
  ? null
40
42
  : new OwnedSettingsManager({
@@ -42,6 +44,26 @@ export async function composeOwnedUi(options = {}) {
42
44
  profileId: options.profileId,
43
45
  agentProvider: () => adapter.settingsPort(),
44
46
  });
47
+ let releaseNotes = null;
48
+ let releaseNotesFailure;
49
+ if (ownedSurfaces) {
50
+ try {
51
+ releaseNotes = options.releaseNotes
52
+ ?? await import("../features/owned-ui/release-notes.js").then(module => module.readPackagedReleaseNotes());
53
+ }
54
+ catch (error) {
55
+ releaseNotesFailure = error;
56
+ }
57
+ }
58
+ const packageVersion = ownedSurfaces
59
+ ? options.packageVersion ?? await readPackageVersion()
60
+ : null;
61
+ const currentReleaseNote = packageVersion === null ? null : releaseNotes?.current(packageVersion) ?? null;
62
+ const releaseNoteClaim = currentReleaseNote === null || options.profileId === undefined
63
+ ? null
64
+ : await import("../features/owned-ui/release-note-state.js").then(module => module.claimReleaseNote({
65
+ configDir: productPaths.configDir, profileId: options.profileId, version: currentReleaseNote.version,
66
+ }));
45
67
  // Compatibility: bare A1 intentionally ships one visual target while its UI is being completed:
46
68
  // dark, regardless of terminal detection or a previously stored Pi theme. The
47
69
  // comparison profile keeps Pi's configured theme behavior and settings surface.
@@ -50,7 +72,9 @@ export async function composeOwnedUi(options = {}) {
50
72
  // cannot open before the shell exists, so the closure is settled by the time it runs.
51
73
  const references = {
52
74
  changelog: async (input) => {
53
- const markdown = input?.document ?? await readPinnedCommandChangelog();
75
+ if (input?.document === undefined && releaseNotesFailure !== undefined)
76
+ throw releaseNotesFailure;
77
+ const markdown = input?.document ?? releaseNotes?.completeMarkdown ?? "No A1 release notes found.";
54
78
  return { rows: width => renderPiShellChangelogLines(markdown, width) };
55
79
  },
56
80
  hotkeys: async () => {
@@ -73,6 +97,10 @@ export async function composeOwnedUi(options = {}) {
73
97
  enabled: () => settings.value("promptSuggestions"),
74
98
  onChange: (listener) => settings.onChange(() => listener(settings.value("promptSuggestions"))),
75
99
  };
100
+ const promptImages = settings === null || !ownedSurfaces ? null : {
101
+ limit: () => settings.value("promptImageLimit"),
102
+ onChange: (listener) => settings.onChange(listener),
103
+ };
76
104
  const skills = settings === null || !ownedSurfaces ? null : {
77
105
  presentation: () => settings.value("skillsPresentation"),
78
106
  // Rationale: one listener covers both the A1 presentation choice and the engine skill-command toggle.
@@ -95,12 +123,23 @@ export async function composeOwnedUi(options = {}) {
95
123
  const clipboardDiagnostics = settings !== null && ownedSurfaces && clipboardDestination?.trim()
96
124
  ? new ClipboardDiagnosticCapture(clipboardDestination) : null;
97
125
  let shell;
126
+ let releaseNoteAcknowledgement = null;
98
127
  try {
99
128
  shell = new OwnedUiSessionShell({
100
129
  engine: {
101
130
  backend: adapter,
102
131
  cwd: adapter.cwd,
103
132
  ...(routeHost === null ? {} : { routeHost }),
133
+ ...(currentReleaseNote === null || releaseNoteClaim === null ? {} : {
134
+ startupRoute: {
135
+ route: "changelog",
136
+ input: { document: currentReleaseNote.markdown },
137
+ onClosed: () => {
138
+ releaseNoteAcknowledgement ??= releaseNoteClaim.acknowledge();
139
+ return releaseNoteAcknowledgement;
140
+ },
141
+ },
142
+ }),
104
143
  ...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
105
144
  },
106
145
  presentation: {
@@ -116,6 +155,7 @@ export async function composeOwnedUi(options = {}) {
116
155
  paste: event => clipboardDiagnostics.paste(event),
117
156
  } }),
118
157
  ...(promptSuggestions === null ? {} : { suggestions: promptSuggestions }),
158
+ ...(promptImages === null ? {} : { promptImages }),
119
159
  ...(skills === null ? {} : { skills }),
120
160
  ...(promptHistory === null ? {} : { history: {
121
161
  ...promptHistory,
@@ -124,20 +164,41 @@ export async function composeOwnedUi(options = {}) {
124
164
  });
125
165
  }
126
166
  catch (error) {
167
+ await releaseNoteClaim?.release();
127
168
  clipboardDiagnostics?.dispose();
128
169
  suggestionDiagnostics?.dispose();
129
170
  throw error;
130
171
  }
172
+ // Rationale: pinned Pi's version check is unreachable because the owned shell never runs its
173
+ // InteractiveMode, so A1 checks its own channel. The result is never awaited on the startup path;
174
+ // settings are read here because the runner resolves them only just before start.
175
+ const announceNewerRelease = async () => {
176
+ const check = options.checkForNewerRelease
177
+ ?? (await import("../foundation/release/latest-release.js")).checkForNewerRelease;
178
+ const release = await check({
179
+ runningVersion: packageVersion ?? await readPackageVersion(),
180
+ configDir: productPaths.configDir,
181
+ interactive: process.stdout.isTTY === true,
182
+ ...(settings === null || !ownedSurfaces ? {} : { settingEnabled: settings.value("updateCheck") }),
183
+ });
184
+ if (release !== null)
185
+ adapter.announceReleaseUpdate(release);
186
+ };
131
187
  const application = {
132
188
  get disposed() { return adapter.disposed; },
133
- start: () => shell.start(),
189
+ start: () => {
190
+ shell.start();
191
+ void announceNewerRelease().catch(() => undefined);
192
+ },
134
193
  flush: () => adapter.flushEvents(),
135
194
  waitUntilStopped: () => shell.waitUntilStopped(),
136
195
  dispose: async () => {
137
196
  try {
138
197
  await shell.dispose();
198
+ await releaseNoteAcknowledgement?.catch(() => undefined);
139
199
  }
140
200
  finally {
201
+ await releaseNoteClaim?.release();
141
202
  suggestionDiagnostics?.dispose();
142
203
  clipboardDiagnostics?.dispose();
143
204
  }
@@ -145,6 +206,13 @@ export async function composeOwnedUi(options = {}) {
145
206
  };
146
207
  return { application, settings };
147
208
  }
209
+ async function readPackageVersion() {
210
+ const manifest = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
211
+ const version = manifest?.version;
212
+ if (typeof version !== "string")
213
+ throw new Error("A1 package version is unavailable");
214
+ return version;
215
+ }
148
216
  function quitOutroSettingsSnapshot(settings) {
149
217
  return {
150
218
  enabled: settings.value("quitAnimation"),
@@ -1,5 +1,7 @@
1
1
  import type { OwnedUiImageAttachment } from "./model.js";
2
- export declare const MAX_PROMPT_IMAGES = 8;
2
+ export declare const DEFAULT_PROMPT_IMAGE_LIMIT = 8;
3
+ /** Absolute command boundary; the bare-A1 editor may choose a lower live limit. */
4
+ export declare const MAX_PROMPT_IMAGES = 16;
3
5
  /** Owned clipboard text is bounded before transfer, normalization, or editor insertion. */
4
6
  export declare const MAX_CLIPBOARD_TEXT_BYTES: number;
5
7
  /** Limit applies to canonical ASCII base64, not decoded image bytes. */
@@ -11,7 +13,7 @@ declare const MESSAGES: {
11
13
  readonly "paste-busy": "Clipboard preparation is full (8 pending pastes).";
12
14
  readonly "paste-write-failed": "Paste skipped because the preceding copy failed.";
13
15
  readonly "image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.";
14
- readonly "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.";
16
+ readonly "image-count": "A prompt has too many images.";
15
17
  readonly "image-data": "Image data is invalid. Paste a valid image again.";
16
18
  readonly "image-mime": "Image MIME type is invalid. Paste a supported image again.";
17
19
  readonly "image-source-size": "Source image exceeds 20 MiB. Reduce the source size and paste it again.";
@@ -27,10 +29,11 @@ declare const MESSAGES: {
27
29
  /** Trusted, payload-free diagnostics for user-correctable attachment failures. */
28
30
  export declare class ImageAttachmentError extends TypeError {
29
31
  readonly code: keyof typeof MESSAGES;
30
- constructor(code: keyof typeof MESSAGES);
32
+ readonly imageLimit: number | null;
33
+ constructor(code: keyof typeof MESSAGES, imageLimit?: number);
31
34
  }
32
35
  /** Checks encoded length before decoding or copying a clipboard payload. */
33
36
  export declare function assertImageEncodedSize(data: string): void;
34
37
  /** Final command admission also covers restored and deferred non-clipboard inputs. */
35
- export declare function assertPromptImages(images: readonly OwnedUiImageAttachment[]): void;
38
+ export declare function assertPromptImages(images: readonly OwnedUiImageAttachment[], imageLimit?: number): void;
36
39
  export {};
@@ -1,4 +1,6 @@
1
- export const MAX_PROMPT_IMAGES = 8;
1
+ export const DEFAULT_PROMPT_IMAGE_LIMIT = 8;
2
+ /** Absolute command boundary; the bare-A1 editor may choose a lower live limit. */
3
+ export const MAX_PROMPT_IMAGES = 16;
2
4
  /** Owned clipboard text is bounded before transfer, normalization, or editor insertion. */
3
5
  export const MAX_CLIPBOARD_TEXT_BYTES = 16 * 1024 * 1024;
4
6
  /** Limit applies to canonical ASCII base64, not decoded image bytes. */
@@ -10,7 +12,7 @@ const MESSAGES = {
10
12
  "paste-busy": "Clipboard preparation is full (8 pending pastes).",
11
13
  "paste-write-failed": "Paste skipped because the preceding copy failed.",
12
14
  "image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.",
13
- "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.",
15
+ "image-count": "A prompt has too many images.",
14
16
  "image-data": "Image data is invalid. Paste a valid image again.",
15
17
  "image-mime": "Image MIME type is invalid. Paste a supported image again.",
16
18
  "image-source-size": "Source image exceeds 20 MiB. Reduce the source size and paste it again.",
@@ -26,9 +28,14 @@ const MESSAGES = {
26
28
  /** Trusted, payload-free diagnostics for user-correctable attachment failures. */
27
29
  export class ImageAttachmentError extends TypeError {
28
30
  code;
29
- constructor(code) {
30
- super(MESSAGES[code]);
31
+ imageLimit;
32
+ constructor(code, imageLimit = DEFAULT_PROMPT_IMAGE_LIMIT) {
33
+ assertPromptImageLimit(imageLimit);
34
+ super(code === "image-count"
35
+ ? `A prompt is limited to ${imageLimit} ${imageLimit === 1 ? "image" : "images"}. To change the limit /settings.`
36
+ : MESSAGES[code]);
31
37
  this.code = code;
38
+ this.imageLimit = code === "image-count" ? imageLimit : null;
32
39
  this.name = "ImageAttachmentError";
33
40
  }
34
41
  }
@@ -42,9 +49,10 @@ export function assertImageEncodedSize(data) {
42
49
  const VALIDATED_IMAGES = new WeakSet();
43
50
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
44
51
  /** Final command admission also covers restored and deferred non-clipboard inputs. */
45
- export function assertPromptImages(images) {
46
- if (!Array.isArray(images) || images.length > MAX_PROMPT_IMAGES)
47
- throw new ImageAttachmentError("image-count");
52
+ export function assertPromptImages(images, imageLimit = MAX_PROMPT_IMAGES) {
53
+ assertPromptImageLimit(imageLimit);
54
+ if (!Array.isArray(images) || images.length > imageLimit)
55
+ throw new ImageAttachmentError("image-count", imageLimit);
48
56
  for (const image of images) {
49
57
  if (!image || image.type !== "image")
50
58
  throw new ImageAttachmentError("image-data");
@@ -65,3 +73,8 @@ export function assertPromptImages(images) {
65
73
  }
66
74
  }
67
75
  }
76
+ function assertPromptImageLimit(imageLimit) {
77
+ if (!Number.isSafeInteger(imageLimit) || imageLimit < 1 || imageLimit > MAX_PROMPT_IMAGES) {
78
+ throw new RangeError(`Prompt image limit must be an integer from 1 through ${MAX_PROMPT_IMAGES}`);
79
+ }
80
+ }
@@ -3,7 +3,7 @@ export type { OwnedUiExtensionAutocompleteItem, OwnedUiExtensionAutocompleteProv
3
3
  export { OWNED_UI_CONTRACT_VERSION } from "./model.js";
4
4
  export type { OwnedUiBlockStatus, OwnedUiCommand, OwnedUiCommandOutcome, OwnedUiCorrelationId, OwnedUiCustomization, OwnedUiDiagnostics, OwnedUiDialog, OwnedUiEditorState, OwnedUiEntityId, OwnedUiEvent, OwnedUiFooterView, OwnedUiImageAttachment, OwnedUiLifecycle, OwnedUiModelInfo, OwnedUiOverlay, OwnedUiPromptSuggestionGeneratorPort, OwnedUiPromptSuggestionIdentity, OwnedUiPromptSuggestionReasoning, OwnedUiPromptSuggestionRequest, OwnedUiPromptSuggestionResult, OwnedUiPromptSuggestionState, OwnedUiQuitOutroSettings, OwnedUiQuitOutroSettingsPort, OwnedUiSessionId, OwnedUiSessionViewModel, OwnedUiSeverity, OwnedUiSlotId, OwnedUiSnapshot, OwnedUiStatusView, OwnedUiTerminalSurface, OwnedUiThinkingLevel, OwnedUiToolRendering, OwnedUiToolResultPart, OwnedUiToolState, OwnedUiTranscriptBlock, OwnedUiTranscriptBlockKind, OwnedUiTranscriptImageReference, OwnedUiUsageView, OwnedUiViewportSettings, OwnedUiViewportSettingsPort, } from "./model.js";
5
5
  export { acceptsTranscriptUpdate, transcriptToolState } from "./transcript-lifecycle.js";
6
- export { ImageAttachmentError, MAX_CLIPBOARD_TEXT_BYTES, MAX_IMAGE_DATA_BYTES, MAX_PROMPT_IMAGES, assertImageEncodedSize, assertPromptImages, } from "./image-attachments.js";
6
+ export { DEFAULT_PROMPT_IMAGE_LIMIT, ImageAttachmentError, MAX_CLIPBOARD_TEXT_BYTES, MAX_IMAGE_DATA_BYTES, MAX_PROMPT_IMAGES, assertImageEncodedSize, assertPromptImages, } from "./image-attachments.js";
7
7
  export { CONTEXTUAL_PROMPT_SUGGESTION_INSTRUCTION, normalizePromptSuggestionCandidate } from "./prompt-suggestions.js";
8
8
  export { canonicalPromptChipMatches, protectPromptChipWrapping, replaceCanonicalPromptChips } from "./prompt-chips.js";
9
9
  export type { PromptChipTextMatch, PromptChipWrapProtection } from "./prompt-chips.js";
@@ -1,7 +1,7 @@
1
1
  export { OWNED_UI_EXTENSION_CONTRACT_VERSION, OWNED_UI_EXTENSION_RENDER_CALLBACKS, OWNED_UI_EXTENSION_UI_CALLBACKS, OWNED_UI_EXTENSION_UI_PROPERTIES, assertOwnedUiExtensionUiPort, } from "./extension-ui.js";
2
2
  export { OWNED_UI_CONTRACT_VERSION } from "./model.js";
3
3
  export { acceptsTranscriptUpdate, transcriptToolState } from "./transcript-lifecycle.js";
4
- export { ImageAttachmentError, MAX_CLIPBOARD_TEXT_BYTES, MAX_IMAGE_DATA_BYTES, MAX_PROMPT_IMAGES, assertImageEncodedSize, assertPromptImages, } from "./image-attachments.js";
4
+ export { DEFAULT_PROMPT_IMAGE_LIMIT, ImageAttachmentError, MAX_CLIPBOARD_TEXT_BYTES, MAX_IMAGE_DATA_BYTES, MAX_PROMPT_IMAGES, assertImageEncodedSize, assertPromptImages, } from "./image-attachments.js";
5
5
  export { CONTEXTUAL_PROMPT_SUGGESTION_INSTRUCTION, normalizePromptSuggestionCandidate } from "./prompt-suggestions.js";
6
6
  export { canonicalPromptChipMatches, protectPromptChipWrapping, replaceCanonicalPromptChips } from "./prompt-chips.js";
7
7
  export { SUGGESTION_DECISION_REASONS, SUGGESTION_DIAGNOSTIC_EVENTS } from "./suggestion-diagnostics.js";
@@ -11,5 +11,5 @@ export interface PromptChipWrapProtection {
11
11
  export declare function canonicalPromptChipMatches(text: string): readonly PromptChipTextMatch[];
12
12
  /** Replaces only canonical prompt-chip labels while preserving every intervening source byte. */
13
13
  export declare function replaceCanonicalPromptChips(text: string, replacement: (match: PromptChipTextMatch) => string): string;
14
- /** Protects fitting chip labels for Markdown wrapping and restores their exact visible source text. */
15
- export declare function protectPromptChipWrapping(text: string): PromptChipWrapProtection;
14
+ /** Protects chip labels for wrapping, optionally presents a derived label, and removes all sentinels. */
15
+ export declare function protectPromptChipWrapping(text: string, present?: (chip: string) => string): PromptChipWrapProtection;
@@ -12,20 +12,23 @@ export function replaceCanonicalPromptChips(text, replacement) {
12
12
  text: value, start: offset, end: offset + value.length,
13
13
  }));
14
14
  }
15
- /** Protects fitting chip labels for Markdown wrapping and restores their exact visible source text. */
16
- export function protectPromptChipWrapping(text) {
15
+ /** Protects chip labels for wrapping, optionally presents a derived label, and removes all sentinels. */
16
+ export function protectPromptChipWrapping(text, present = identity) {
17
17
  const matches = canonicalPromptChipMatches(text);
18
18
  if (matches.length === 0)
19
19
  return { text, restore: identity };
20
20
  const spaceMarker = unusedPrivateMarker(text);
21
21
  const boundaryMarker = unusedZeroWidthMarker(text);
22
+ const boundary = `${boundaryMarker} `;
22
23
  let protectedText = "", cursor = 0;
23
24
  for (const match of matches) {
24
25
  protectedText += text.slice(cursor, match.start);
25
- // Rationale: adjacent chips need a temporary break opportunity or Markdown treats the complete run as one word.
26
- if (match.start === cursor && cursor > 0)
27
- protectedText += `${boundaryMarker} `;
28
- protectedText += match.text.replaceAll(" ", spaceMarker);
26
+ // Rationale: temporary break opportunities keep each chip independent from adjacent chips or prose.
27
+ if (match.start > 0 && match.start !== cursor && !/\s/u.test(text[match.start - 1]))
28
+ protectedText += boundary;
29
+ protectedText += present(match.text).replaceAll(" ", spaceMarker);
30
+ if (match.end < text.length && !/\s/u.test(text[match.end]))
31
+ protectedText += boundary;
29
32
  cursor = match.end;
30
33
  }
31
34
  protectedText += text.slice(cursor);
@@ -2,6 +2,13 @@ import { EMERGENCY_TERMINAL_RESET } from "../../foundation/terminal-cleanup/term
2
2
  const ENTER_ALTERNATE_SCREEN = "\u001b[?1049h";
3
3
  const HIDE_CURSOR = "\u001b[?25l";
4
4
  const CLEAR_HOME = "\u001b[2J\u001b[H";
5
+ const HOME = "\u001b[H";
6
+ const ERASE_LINE_END = "\u001b[K";
7
+ const ERASE_BELOW = "\u001b[J";
8
+ const BEGIN_SYNC = "\u001b[?2026h";
9
+ const END_SYNC = "\u001b[?2026l";
10
+ const REQUEST_CURSOR_POSITION = "\u001b[6n";
11
+ const CURSOR_POSITION_REPORT = /^\u001b\[(\d+);(\d+)R/u;
5
12
  const ACCENT = "\u001b[38;2;138;190;183m";
6
13
  const MUTED = "\u001b[38;2;128;128;128m";
7
14
  const DIM = "\u001b[38;2;102;102;102m";
@@ -32,6 +39,8 @@ export function createConsoleProjectTrustPrompt(options = {}) {
32
39
  const wasRaw = input.isRaw === true;
33
40
  let selected = 0;
34
41
  let restored = false;
42
+ // Protocol: the parent screen's cursor, reported before the first frame moves it.
43
+ let origin;
35
44
  const restore = () => {
36
45
  if (restored)
37
46
  return;
@@ -39,6 +48,21 @@ export function createConsoleProjectTrustPrompt(options = {}) {
39
48
  input.setRawMode?.(wasRaw);
40
49
  output.write(`${CLEAR_HOME}${EMERGENCY_TERMINAL_RESET}`);
41
50
  };
51
+ // Protocol: a resolved bare selection hands the alternate screen to the shell, which
52
+ // enters it again. Leaving it here would show the parent screen until the shell's first
53
+ // frame. The shell's own enter saves the cursor, so park it where the parent had it and
54
+ // its leave restores the parent position. Without a position report, restore instead.
55
+ const handOff = () => {
56
+ if (renderBareDialog === undefined || origin === undefined) {
57
+ restore();
58
+ return;
59
+ }
60
+ if (restored)
61
+ return;
62
+ restored = true;
63
+ input.setRawMode?.(wasRaw);
64
+ output.write(`${BEGIN_SYNC}${CLEAR_HOME}\u001b[${origin.row};${origin.column}H${END_SYNC}`);
65
+ };
42
66
  const render = () => {
43
67
  const width = Math.max(renderBareDialog ? 1 : 20, output.columns ?? 80);
44
68
  const rows = Math.max(1, output.rows ?? 24);
@@ -52,12 +76,17 @@ export function createConsoleProjectTrustPrompt(options = {}) {
52
76
  "",
53
77
  `${DIM}↑/↓${MUTED} to navigate ${DIM}Enter${MUTED} to select ${DIM}Esc${MUTED} to cancel${RESET_FG}`,
54
78
  ]).map(line => clipAnsiSafe(line, width));
55
- const padding = renderBareDialog === undefined ? "" : "\n".repeat(Math.max(0, rows - lines.length));
56
- output.write(`${CLEAR_HOME}${padding}${lines.join("\n")}`);
79
+ const padded = renderBareDialog === undefined
80
+ ? lines : [...Array(Math.max(0, rows - lines.length)).fill(""), ...lines];
81
+ // Rationale: overwrite in place within one synchronized frame; clearing first flickers.
82
+ output.write(`${BEGIN_SYNC}${HOME}${padded.map(line => `${line}${ERASE_LINE_END}`).join("\n")}${ERASE_BELOW}${END_SYNC}`);
57
83
  };
58
84
  try {
59
85
  output.write(`${ENTER_ALTERNATE_SCREEN}${HIDE_CURSOR}`);
60
86
  input.setRawMode?.(true);
87
+ // Invariant: entering the alternate screen keeps the cursor, so this reports the parent's.
88
+ if (renderBareDialog !== undefined)
89
+ output.write(REQUEST_CURSOR_POSITION);
61
90
  render();
62
91
  return await new Promise((resolve, reject) => {
63
92
  let settled = false;
@@ -66,7 +95,7 @@ export function createConsoleProjectTrustPrompt(options = {}) {
66
95
  return;
67
96
  settled = true;
68
97
  cleanup();
69
- restore();
98
+ handOff();
70
99
  resolve(value);
71
100
  };
72
101
  const fail = (error) => {
@@ -81,6 +110,13 @@ export function createConsoleProjectTrustPrompt(options = {}) {
81
110
  const onData = (chunk) => {
82
111
  const data = chunk.toString();
83
112
  for (let index = 0; index < data.length;) {
113
+ const report = origin === undefined && renderBareDialog !== undefined
114
+ ? CURSOR_POSITION_REPORT.exec(data.slice(index)) : null;
115
+ if (report !== null) {
116
+ origin = { row: Number(report[1]), column: Number(report[2]) };
117
+ index += report[0].length;
118
+ continue;
119
+ }
84
120
  if (data.startsWith("\u001b[A", index)) {
85
121
  selected = (selected - 1 + choices.length) % choices.length;
86
122
  index += 3;
@@ -0,0 +1,13 @@
1
+ export interface ReleaseNoteClaim {
2
+ readonly version: string;
3
+ acknowledge(): Promise<void>;
4
+ release(): Promise<void>;
5
+ }
6
+ export interface ReleaseNoteStateOptions {
7
+ readonly configDir: string;
8
+ readonly profileId: string;
9
+ readonly version: string;
10
+ readonly now?: () => number;
11
+ }
12
+ /** Claims one stable note across concurrent sessions and acknowledges it only after presentation closes. */
13
+ export declare function claimReleaseNote(options: ReleaseNoteStateOptions): Promise<ReleaseNoteClaim | null>;
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import semver from "semver";
5
+ const STATE_VERSION = 1;
6
+ const MAX_STATE_BYTES = 4096;
7
+ const CLAIM_MAX_AGE_MS = 60 * 60_000;
8
+ const PROFILE = /^[a-z][a-z0-9-]{0,63}$/u;
9
+ const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
10
+ /** Claims one stable note across concurrent sessions and acknowledges it only after presentation closes. */
11
+ export async function claimReleaseNote(options) {
12
+ if (!PROFILE.test(options.profileId))
13
+ throw new Error("release-note profile is invalid");
14
+ if (!STABLE.test(options.version) || semver.valid(options.version) !== options.version)
15
+ return null;
16
+ const directory = resolve(options.configDir, "release-notes");
17
+ const statePath = join(directory, `${options.profileId}.json`);
18
+ const claimPath = join(directory, `${options.profileId}.claim.json`);
19
+ const acknowledged = await readAcknowledged(statePath);
20
+ if (acknowledged !== null && semver.gte(acknowledged, options.version))
21
+ return null;
22
+ await mkdir(directory, { recursive: true, mode: 0o700 });
23
+ const now = options.now ?? Date.now;
24
+ const token = randomUUID();
25
+ const claim = { version: STATE_VERSION, token, release: options.version, claimedAt: now() };
26
+ if (!await createClaim(claimPath, claim)) {
27
+ if (!await staleClaim(claimPath, now()))
28
+ return null;
29
+ await rm(claimPath, { force: true });
30
+ if (!await createClaim(claimPath, claim))
31
+ return null;
32
+ }
33
+ let settled = false;
34
+ const ownsClaim = async () => {
35
+ try {
36
+ const current = await boundedJson(claimPath);
37
+ return current.token === token;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ };
43
+ const release = async () => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ if (await ownsClaim())
48
+ await rm(claimPath, { force: true });
49
+ };
50
+ return Object.freeze({
51
+ version: options.version,
52
+ acknowledge: async () => {
53
+ if (settled || !await ownsClaim())
54
+ return;
55
+ const prior = await readAcknowledged(statePath);
56
+ const version = prior !== null && semver.gt(prior, options.version) ? prior : options.version;
57
+ const temporary = `${statePath}.${process.pid}.${token}.tmp`;
58
+ await writeFile(temporary, `${JSON.stringify({ version: STATE_VERSION, acknowledged: version }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
59
+ await rename(temporary, statePath);
60
+ settled = true;
61
+ await rm(claimPath, { force: true });
62
+ },
63
+ release,
64
+ });
65
+ }
66
+ async function readAcknowledged(path) {
67
+ try {
68
+ const value = await boundedJson(path);
69
+ if (value.version !== STATE_VERSION || typeof value.acknowledged !== "string"
70
+ || !STABLE.test(value.acknowledged) || semver.valid(value.acknowledged) !== value.acknowledged)
71
+ return null;
72
+ return value.acknowledged;
73
+ }
74
+ catch (error) {
75
+ return error?.code === "ENOENT" ? null : null;
76
+ }
77
+ }
78
+ async function boundedJson(path) {
79
+ const info = await stat(path);
80
+ if (!info.isFile() || info.size > MAX_STATE_BYTES)
81
+ throw new Error("release-note state is invalid");
82
+ return JSON.parse(await readFile(path, "utf8"));
83
+ }
84
+ async function createClaim(path, value) {
85
+ try {
86
+ const handle = await open(path, "wx", 0o600);
87
+ try {
88
+ await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
89
+ }
90
+ finally {
91
+ await handle.close();
92
+ }
93
+ return true;
94
+ }
95
+ catch (error) {
96
+ if (error?.code === "EEXIST")
97
+ return false;
98
+ throw error;
99
+ }
100
+ }
101
+ async function staleClaim(path, now) {
102
+ try {
103
+ const value = await boundedJson(path);
104
+ return typeof value.claimedAt !== "number" || !Number.isFinite(value.claimedAt) || now - value.claimedAt > CLAIM_MAX_AGE_MS;
105
+ }
106
+ catch {
107
+ return true;
108
+ }
109
+ }
@@ -0,0 +1,10 @@
1
+ export interface PackagedReleaseNote {
2
+ readonly version: string;
3
+ readonly markdown: string;
4
+ }
5
+ export interface ReleaseNoteCatalog {
6
+ readonly releases: readonly PackagedReleaseNote[];
7
+ readonly completeMarkdown: string;
8
+ current(version: string): PackagedReleaseNote | null;
9
+ }
10
+ export declare function readPackagedReleaseNotes(path?: URL): Promise<ReleaseNoteCatalog>;