@visual-z/xeno 0.1.3 → 0.2.0

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 (148) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/progress.ts +9 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/store/workspace.ts +6 -0
  92. package/src/tui/chat-viewport.ts +47 -0
  93. package/src/tui/editor.ts +62 -15
  94. package/src/tui/host.ts +50 -9
  95. package/src/tui/input.ts +8 -2
  96. package/src/tui/select.ts +24 -6
  97. package/src/tui/settings.ts +4 -0
  98. package/src/tui/system-theme.ts +700 -0
  99. package/src/tui/transcript.ts +63 -11
  100. package/src/tui/tree.ts +161 -95
  101. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  102. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  103. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  104. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  105. package/src/vendor/pi-rich/html.ts +51 -0
  106. package/src/vendor/pi-rich/image-convert.ts +49 -0
  107. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  108. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  109. package/src/vendor/pi-rich/image-resize.ts +123 -0
  110. package/src/vendor/pi-rich/mime.ts +116 -0
  111. package/src/vendor/pi-rich/photon.ts +139 -0
  112. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  113. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  114. package/src/vendor/pi-tui/VENDOR.md +7 -5
  115. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  116. package/src/vendor/pi-tui/colors.ts +367 -0
  117. package/src/vendor/pi-tui/components/box.ts +8 -9
  118. package/src/vendor/pi-tui/components/editor.ts +4 -2
  119. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  120. package/src/vendor/pi-tui/index.ts +31 -1
  121. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  122. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  123. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  124. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  127. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  128. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  129. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  132. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  133. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  134. package/src/vendor/pi-tui/native/napi.h +91 -0
  135. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  136. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  139. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  140. package/src/vendor/pi-tui/native-platform.ts +2 -0
  141. package/src/vendor/pi-tui/oklab.ts +233 -0
  142. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  143. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  144. package/src/vendor/pi-tui/terminal.ts +16 -9
  145. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  146. package/src/vendor/pi-tui/tui.ts +112 -75
  147. package/src/vendor/pi-tui/utils.ts +89 -49
  148. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -7,39 +7,8 @@
7
7
  * pi's scheme; the action list is ours.
8
8
  */
9
9
 
10
- export const ACTION_IDS = [
11
- "editor.cursorLeft",
12
- "editor.cursorRight",
13
- "editor.cursorWordLeft",
14
- "editor.cursorWordRight",
15
- "editor.cursorLineStart",
16
- "editor.cursorLineEnd",
17
- "editor.cursorUp",
18
- "editor.cursorDown",
19
- "editor.deleteCharBackward",
20
- "editor.deleteCharForward",
21
- "editor.deleteWordBackward",
22
- "editor.deleteToLineStart",
23
- "editor.deleteToLineEnd",
24
- "editor.submit",
25
- "editor.newLine",
26
- "editor.undo",
27
- "editor.yank",
28
- "editor.deleteWordForward",
29
- "editor.complete",
30
- "app.toggleTools",
31
- "app.toggleThinking",
32
- "app.interrupt",
33
- "app.exit",
34
- "app.message.followUp",
35
- "app.message.dequeue",
36
- "app.model",
37
- "app.cycleModel",
38
- "app.cycleModelReverse",
39
- "app.cycleThinking",
40
- "app.saveDefault",
41
- "app.externalEditor",
42
- ] as const;
10
+ import { ACTION_DEFAULTS, ACTION_ALIASES } from "./action-defaults.ts";
11
+ export const ACTION_IDS = Object.keys(ACTION_DEFAULTS) as (keyof typeof ACTION_DEFAULTS)[];
43
12
 
44
13
  export type ActionId = (typeof ACTION_IDS)[number];
45
14
 
@@ -118,87 +87,56 @@ export function resolveAction(map: Keymap, spec: string): ActionId | undefined {
118
87
  return map.byKey[formatKey(parsed)];
119
88
  }
120
89
 
121
- export function defaultKeymap(): Keymap {
122
- return parseKeymap(
123
- {
124
- "editor.cursorLeft": ["left", "ctrl+b"],
125
- "editor.cursorRight": ["right", "ctrl+f"],
126
- "editor.cursorWordLeft": ["alt+left", "ctrl+left", "alt+b"],
127
- "editor.cursorWordRight": ["alt+right", "ctrl+right", "alt+f"],
128
- "editor.cursorLineStart": ["home", "ctrl+a"],
129
- "editor.cursorLineEnd": ["end", "ctrl+e"],
130
- "editor.cursorUp": ["up"],
131
- "editor.cursorDown": ["down"],
132
- "editor.deleteCharBackward": ["backspace"],
133
- "editor.deleteCharForward": ["delete", "ctrl+d"],
134
- "editor.deleteWordBackward": ["ctrl+w", "alt+backspace"],
135
- "editor.deleteWordForward": ["alt+d"],
136
- "editor.deleteToLineStart": ["ctrl+u"],
137
- "editor.deleteToLineEnd": ["ctrl+k"],
138
- "editor.submit": ["enter"],
139
- "editor.newLine": ["shift+enter", "ctrl+j"],
140
- "editor.undo": ["ctrl+-", "ctrl+z", "ctrl+_"],
141
- "editor.yank": ["ctrl+y"],
142
- "editor.complete": ["tab"],
143
- "app.toggleTools": ["ctrl+o"],
144
- "app.toggleThinking": ["ctrl+t"],
145
- "app.interrupt": ["escape"],
146
- "app.exit": ["ctrl+c"],
147
- "app.message.followUp": ["alt+enter"],
148
- "app.message.dequeue": ["alt+up"],
149
- "app.model": ["ctrl+l"],
150
- "app.cycleModel": ["ctrl+p"],
151
- "app.cycleModelReverse": ["ctrl+shift+p"],
152
- "app.cycleThinking": ["shift+tab"],
153
- "app.saveDefault": ["ctrl+s"],
154
- "app.externalEditor": ["ctrl+g"],
155
- },
156
- emptyKeymap(),
157
- );
90
+ export function defaultKeymap(platform: "posix" | "win32" | "wsl" = "posix"): Keymap {
91
+ // Pi's Windows shortcuts avoid terminal-reserved chords; WSL still supports job control.
92
+ return parseKeymap({ ...ACTION_DEFAULTS, ...(platform === "posix" ? {} : {
93
+ "app.suspend": platform === "win32" ? [] : ["ctrl+z"],
94
+ "editor.undo": platform === "win32" ? ["ctrl+z"] : ["alt+z"],
95
+ "app.cycleModelReverse": ["alt+p"], "app.message.followUp": ["ctrl+q"],
96
+ "app.message.dequeue": ["alt+q"], "app.clipboard.pasteImage": ["alt+v"],
97
+ "tui.altScreen.previousPrompt": ["ctrl+up"], "tui.altScreen.nextPrompt": ["ctrl+down"],
98
+ "tui.altScreen.search": ["ctrl+f"],
99
+ }) });
158
100
  }
159
101
 
102
+ /** Modal scopes deliberately share chords. Only collisions within one owner replace a binding. */
103
+ export function actionScope(action: string): string {
104
+ if (action === "app.exit") return "exit";
105
+ if (action === "tui.input.copy") return "copy";
106
+ if (action.startsWith("tui.select.")) return "select";
107
+ if (action.startsWith("tui.altScreen.")) return "viewport";
108
+ if (action.startsWith("app.tree.")) return "tree";
109
+ if (action.startsWith("app.models.")) return "models";
110
+ if (action.startsWith("app.session.") && !["app.session.new", "app.session.tree", "app.session.fork", "app.session.resume"].includes(action)) return "session";
111
+ return "global";
112
+ }
160
113
  export function parseKeymap(json: unknown, base?: Keymap): Keymap {
161
- const diagnostics: string[] = [];
162
- const byKey: { [k: string]: ActionId } = { ...(base?.byKey ?? {}) };
163
-
164
- if (json === undefined || json === null) {
165
- return finish(byKey, base?.diagnostics ?? []);
166
- }
167
- if (typeof json !== "object" || Array.isArray(json)) {
168
- return finish(byKey, [...(base?.diagnostics ?? []), "keybindings is not an object"]);
169
- }
170
-
171
- const rec = json as { readonly [k: string]: unknown };
172
- for (const [action, raw] of Object.entries(rec)) {
173
- if (!ACTION_SET.has(action)) {
174
- diagnostics.push(`unknown action: ${action}`);
175
- continue;
176
- }
114
+ const diagnostics = [...(base?.diagnostics ?? [])];
115
+ const byAction: Record<string,string[]> = Object.fromEntries(Object.entries(base?.byAction ?? {}).map(([k,v]) => [k,[...v]]));
116
+ if (json !== undefined && json !== null && (typeof json !== "object" || Array.isArray(json))) diagnostics.push("keybindings is not an object");
117
+ else for (const [rawAction,raw] of Object.entries(json ?? {})) {
118
+ const action = ACTION_ALIASES[rawAction] ?? rawAction;
119
+ if (!ACTION_SET.has(action)) { diagnostics.push("unknown action: " + rawAction); continue; }
177
120
  const specs = keySpecs(raw);
178
- if (specs === null) {
179
- diagnostics.push(`${action}: bindings must be a string or array of strings`);
180
- continue;
181
- }
182
- // User (or this file) replaces the previous chords for this action.
183
- for (const [k, a] of Object.entries(byKey)) {
184
- if (a === action) delete byKey[k];
185
- }
121
+ if (!specs) { diagnostics.push(action + ": bindings must be a string or array of strings"); continue; }
122
+ const keys: string[] = [];
123
+ byAction[action] = keys;
186
124
  for (const spec of specs) {
187
125
  const parsed = parseKey(spec);
188
- if ("error" in parsed) {
189
- diagnostics.push(`${parsed.error} (action ${action})`);
190
- continue;
191
- }
192
- const id = formatKey(parsed);
193
- const prev = byKey[id];
194
- if (prev !== undefined && prev !== action) {
195
- diagnostics.push(`key ${id} is bound to both ${prev} and ${action}; ${action} wins`);
126
+ if ("error" in parsed) { diagnostics.push(parsed.error + " (action " + action + ")"); continue; }
127
+ const key = formatKey(parsed);
128
+ for (const [other, bindings] of Object.entries(byAction)) {
129
+ if (other !== action && actionScope(other) === actionScope(action) && bindings.includes(key)) {
130
+ diagnostics.push("key " + key + " is bound to both " + other + " and " + action + "; " + action + " wins");
131
+ byAction[other] = bindings.filter(k => k !== key);
132
+ }
196
133
  }
197
- byKey[id] = action as ActionId;
134
+ if (!keys.includes(key)) keys.push(key);
198
135
  }
199
136
  }
200
-
201
- return finish(byKey, [...(base?.diagnostics ?? []), ...diagnostics]);
137
+ const byKey: Record<string,ActionId> = {};
138
+ for (const [action,keys] of Object.entries(byAction)) if (["global", "exit"].includes(actionScope(action))) for (const key of keys) byKey[key] = action as ActionId;
139
+ return { byKey, byAction, diagnostics };
202
140
  }
203
141
 
204
142
  export function listBindings(map: Keymap): { action: string; keys: readonly string[] }[] {
@@ -208,20 +146,6 @@ export function listBindings(map: Keymap): { action: string; keys: readonly stri
208
146
  }));
209
147
  }
210
148
 
211
- function emptyKeymap(): Keymap {
212
- return { byKey: {}, byAction: {}, diagnostics: [] };
213
- }
214
-
215
- function finish(byKey: { readonly [k: string]: ActionId }, diagnostics: readonly string[]): Keymap {
216
- const byAction: { [k: string]: string[] } = {};
217
- for (const [key, action] of Object.entries(byKey)) {
218
- const list = byAction[action] ?? [];
219
- list.push(key);
220
- byAction[action] = list;
221
- }
222
- return { byKey, byAction, diagnostics };
223
- }
224
-
225
149
  function keySpecs(raw: unknown): string[] | null {
226
150
  if (typeof raw === "string") return [raw];
227
151
  if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
@@ -0,0 +1,22 @@
1
+ import type { LogEvent } from "./events.ts";
2
+
3
+ export interface ReplayModel {
4
+ provider: string; id: string; api: string; driver?: string; wireId?: string; baseUrl?: string;
5
+ compat?: unknown; nativeCompat?: unknown; thinkingLevelMap?: unknown;
6
+ }
7
+ function canonical(value: unknown): unknown {
8
+ if (Array.isArray(value)) return value.map(canonical);
9
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonical(item)]));
10
+ return value;
11
+ }
12
+
13
+ /** D8: replay identity excludes prices, UI labels, credentials and sampling choices. */
14
+ export function replayDescriptor(model: ReplayModel): string {
15
+ return JSON.stringify(canonical([model.provider, model.id, model.api, model.driver ?? "legacy", model.wireId ?? model.id,
16
+ model.baseUrl ?? "", model.compat ?? null, model.nativeCompat ?? null, model.thinkingLevelMap ?? null]));
17
+ }
18
+
19
+ /** Old native profiles have no provable wire identity. One checkpoint makes subsequent replay explicit. */
20
+ export function needsReplayCheckpoint(events: readonly LogEvent[], key: string, requireKnown: boolean): boolean {
21
+ return events.some(event => event.t === "assistant" && (event.replay_key === undefined ? requireKnown : event.replay_key !== key));
22
+ }
@@ -0,0 +1,53 @@
1
+ /** Ordered model identities are session state until explicitly saved. D13: never resolve bare IDs here. */
2
+ import { asThinkingLevel, type ThinkingLevel } from "./thinking.ts";
3
+ import { modelKey } from "./session-position.ts";
4
+
5
+ export interface ScopedModel<T> { model: T; thinking?: ThinkingLevel }
6
+
7
+ /** Exact full references win before suffix parsing, including IDs whose colon is literal (Pi 02eed88). */
8
+ export function resolveScope<T extends { id: string; provider: string }>(
9
+ patterns: readonly string[], match: (pattern: string) => readonly T[], strict = false,
10
+ ): { entries: ScopedModel<T>[]; diagnostics: string[] } {
11
+ const entries: ScopedModel<T>[] = [], diagnostics: string[] = [], seen = new Set<string>();
12
+ for (const pattern of patterns) {
13
+ let matches = match(pattern), thinking: ThinkingLevel | undefined;
14
+ if (!matches.length) {
15
+ const colon = pattern.lastIndexOf(":");
16
+ if (colon > 0) {
17
+ const suffix = pattern.slice(colon + 1), level = asThinkingLevel(suffix);
18
+ if (level || !strict) {
19
+ matches = match(pattern.slice(0, colon));
20
+ if (matches.length) {
21
+ thinking = level;
22
+ if (!level) diagnostics.push(`Invalid thinking level ${JSON.stringify(suffix)} in ${JSON.stringify(pattern)}; using the default`);
23
+ }
24
+ }
25
+ }
26
+ }
27
+ if (!matches.length) diagnostics.push(`No models match ${JSON.stringify(pattern)}`);
28
+ for (const model of matches) {
29
+ const key = modelKey(model); if (seen.has(key)) continue; seen.add(key);
30
+ entries.push({ model, ...(thinking === undefined ? {} : { thinking }) });
31
+ }
32
+ }
33
+ return { entries, diagnostics };
34
+ }
35
+
36
+ export function scopeReference<T extends { id: string; provider: string }>(entry: ScopedModel<T>): string {
37
+ return modelKey(entry.model) + (entry.thinking ? `:${entry.thinking}` : "");
38
+ }
39
+
40
+ export type ScopeChange = { readonly kind: "toggle"; readonly ids: readonly string[] } |
41
+ { readonly kind: "enable" | "disable"; readonly ids: readonly string[] } |
42
+ { readonly kind: "move"; readonly id: string; readonly delta: -1 | 1 };
43
+ export function changeModelScope(current: readonly string[], change: ScopeChange): string[] {
44
+ const next = [...new Set(current)];
45
+ if (change.kind === "move") {
46
+ const index = next.indexOf(change.id), target = index + change.delta;
47
+ if (index >= 0 && target >= 0 && target < next.length) [next[index], next[target]] = [next[target]!, next[index]!];
48
+ return next;
49
+ }
50
+ const ids = new Set(change.ids);
51
+ const disable = change.kind === "disable" || change.kind === "toggle" && change.ids.every(id => next.includes(id));
52
+ return disable ? next.filter(id => !ids.has(id)) : [...next, ...[...ids].filter(id => !next.includes(id))];
53
+ }
@@ -0,0 +1,47 @@
1
+ /** Pi package identities and filters, independent of installation and I/O (D13). */
2
+ import { parseResourcePattern, type ResourceKind } from "./resource-selection.ts";
3
+
4
+ export type PackageDeclaration = string | ({ source: string; autoload?: boolean } & Partial<Record<ResourceKind, readonly string[]>>);
5
+ export type PackageSource =
6
+ | { kind: "local"; id: string; path: string; source: string; pinned: true }
7
+ | { kind: "npm"; id: string; name: string; spec: string; version?: string; source: string; pinned: boolean }
8
+ | { kind: "git"; id: string; repo: string; ref?: string; source: string; pinned: boolean };
9
+ export function packageDeclaration(value: unknown): value is PackageDeclaration {
10
+ if (typeof value === "string") return value.trim().length > 0;
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
12
+ const entry = value as Record<string, unknown>;
13
+ return typeof entry["source"] === "string" && entry["source"].trim().length > 0 &&
14
+ (entry["autoload"] === undefined || typeof entry["autoload"] === "boolean") &&
15
+ ["extensions", "skills", "prompts", "themes"].every(key => entry[key] === undefined || Array.isArray(entry[key]) && entry[key].every(p => typeof p === "string"));
16
+ }
17
+ export function packageConfig(value: PackageDeclaration): Exclude<PackageDeclaration, string> { return typeof value === "string" ? { source: value } : value; }
18
+ export function parsePackageSource(raw: string, localPath: (path: string) => string): PackageSource {
19
+ const source = raw.trim(); if (!source || source.startsWith("-") || /[\x00-\x1f]/.test(source)) throw Error("Invalid package source");
20
+ if (source.startsWith("npm:")) {
21
+ const spec = source.slice(4), at = spec.lastIndexOf("@"), name = at > 0 ? spec.slice(0, at) : spec, version = at > 0 ? spec.slice(at + 1) : undefined;
22
+ if (!/^(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*\/)?[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) || version === "" || version?.startsWith("-")) throw Error("Invalid npm package source");
23
+ return { kind: "npm", id: "npm:" + name, source, name, spec, ...(version === undefined ? {} : { version }), pinned: !!version && /^v?\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version) };
24
+ }
25
+ if (/^(git:|git\+|https?:\/\/|ssh:\/\/|git@)/.test(source)) {
26
+ let repo = source.replace(/^git:(?!\/\/)/, "").replace(/^git\+/, ""), ref: string | undefined;
27
+ const hash = repo.lastIndexOf("#"), at = repo.lastIndexOf("@");
28
+ const authorityEnd = /^\w+:\/\//.test(repo) ? repo.indexOf("/",repo.indexOf("://")+3) : repo.startsWith("git@") ? repo.indexOf(":") : repo.indexOf("/");
29
+ const split = hash >= 0 ? hash : at > authorityEnd && authorityEnd >= 0 ? at : -1;
30
+ if (split >= 0) { ref = repo.slice(split + 1); repo = repo.slice(0, split); if (!ref || ref.startsWith("-")) throw Error("Invalid git ref"); }
31
+ if (!/^(\w+:\/\/|git@|\/|\.\.?\/)/.test(repo)) repo = "https://" + repo;
32
+ const remote = /^\w+:\/\//.test(repo) ? new URL(repo) : undefined;
33
+ const canonical = (remote ? remote.host.toLowerCase()+remote.pathname : repo.replace(/^git@([^:]+):/, "$1/")).replace(/\.git\/?$/, "").replace(/\/$/, "");
34
+ if (!canonical.includes("/")) throw Error("Invalid git repository");
35
+ return { kind: "git", source, repo, id: "git:" + canonical, ...(ref === undefined ? {} : { ref }), pinned: ref !== undefined };
36
+ }
37
+ const path = localPath(source); return { kind: "local", id: "local:" + path, source, path, pinned: true };
38
+ }
39
+
40
+ /** Missing filters inherit; [] excludes all; ordinary positive patterns form a whitelist. */
41
+ export function packageResourceEnabled(patterns: readonly string[] | undefined, matches: (pattern: string, exact: boolean) => boolean, inherited = true): boolean {
42
+ if (patterns === undefined) return inherited;
43
+ if (patterns.length === 0) return false;
44
+ let enabled = patterns.some(pattern => !/^[!+-]/.test(pattern)) ? false : inherited;
45
+ for (const raw of patterns) { const pattern = parseResourcePattern(raw); if (matches(pattern.pattern,pattern.explicit)) enabled = pattern.enabled; }
46
+ return enabled;
47
+ }
@@ -88,6 +88,15 @@ export function deriveProgress(
88
88
  if (observation !== undefined) observations.add(observation);
89
89
  }
90
90
  } else if (effect?.kind === "command" && effect.exit === 0) {
91
+ // D9 revision (2026-09-29, user-approved): output never seen before is a
92
+ // new observation, exactly as for read/grep. A model exploring with
93
+ // `sed -n` or `grep` in the shell is gathering evidence too; the digest
94
+ // excludes wall time, so an unchanged `git status` repeats and stalls.
95
+ if (observation !== undefined && !observations.has(observation)) {
96
+ observations.add(observation);
97
+ evidence.push({ kind: "observation", sha256: observation, source });
98
+ progressed = true;
99
+ }
91
100
  const key = checkKey(effect.cmd, effect.gate, effect.workspace_sha256);
92
101
  if (!checks.has(key)) {
93
102
  checks.add(key);
@@ -28,10 +28,12 @@
28
28
 
29
29
  import type { LogEvent, AssistantBlock } from "./events.ts";
30
30
  import { assertNever } from "./exhaustive.ts";
31
+ import type { ImageContent } from "./images.ts";
31
32
  import { type Handoff, emitHandoff } from "./handoff.ts";
32
33
 
33
34
  export type ContentBlock =
34
- | { type: "text"; text: string }
35
+ | ImageContent
36
+ | { type: "text"; text: string; wire?: unknown }
35
37
  /**
36
38
  * The provider's own thinking block, replayed verbatim. It is carried as
37
39
  * opaque rather than reconstructed because the API rejects modified blocks,
@@ -108,9 +110,11 @@ export interface ProjectViolation {
108
110
  * two things". It is a following message rather than a mutation of the user
109
111
  * turn so appending the event only appends to the projection (P7).
110
112
  */
111
- type ReplayEvent = Extract<LogEvent, { t: "user" | "guidance" | "interrupt" | "assistant" | "tool_result" | "rewind" | "snapshot" }>;
113
+ type ReplayEvent = Extract<LogEvent, { t: "user" | "user_bash" | "guidance" | "interrupt" | "assistant" | "tool_result" | "rewind" | "snapshot" | "extension" }>;
112
114
 
113
115
  function replayable(e: LogEvent): e is ReplayEvent {
116
+ if (e.t === "extension") return e.inContext;
117
+ if (e.t === "user_bash") return !e.excludeFromContext;
114
118
  if (e.t === "rewind") return e.mode !== "both";
115
119
  if (e.t === "snapshot") return e.undeclared.length > 0;
116
120
  return e.t === "user" || e.t === "guidance" || e.t === "interrupt" || e.t === "assistant" || e.t === "tool_result";
@@ -269,6 +273,17 @@ export function projectContext(input: ProjectInput): ProjectResult {
269
273
  let awaiting = new Set<string>();
270
274
 
271
275
  for (const e of events) {
276
+ if (e.t === "extension") {
277
+ flushPending();
278
+ if (awaiting.size) violations.push({ code: "UNPAIRED_TOOL_USE", detail: `custom message ${e.id} follows unanswered tool_use ${[...awaiting].join(", ")}` });
279
+ messages.push({ role: "user", content: [{ type: "text", text: e.content }] });
280
+ continue;
281
+ }
282
+ if (e.t === "user_bash") {
283
+ flushPending();
284
+ messages.push({ role: "user", content: [{ type: "text", text: `User ran: ${e.command}\n${e.content}` }] });
285
+ continue;
286
+ }
272
287
  if (e.t === "snapshot") {
273
288
  flushPending();
274
289
  messages.push({ role: "user", content: [{ type: "text",
@@ -292,7 +307,8 @@ export function projectContext(input: ProjectInput): ProjectResult {
292
307
  });
293
308
  awaiting = new Set();
294
309
  }
295
- messages.push({ role: "user", content: [{ type: "text", text: e.text }] });
310
+ messages.push({ role: "user", content: [...(e.text || e.t === "guidance" || !e.images?.length ? [{ type: "text" as const, text: e.text }] : []),
311
+ ...(e.t === "user" ? (e.images ?? []).map(image => ({ type: "image" as const, image })) : [])] });
296
312
  continue;
297
313
  }
298
314
  if (e.t === "interrupt") {
@@ -334,6 +350,8 @@ export function projectContext(input: ProjectInput): ProjectResult {
334
350
  content: e.content,
335
351
  ...(e.ok ? {} : { is_error: true }),
336
352
  });
353
+ // Images follow the paired text result, so APIs without image tool outputs receive a user image.
354
+ pending.push(...(e.images ?? []).map(image => ({ type: "image" as const, image })));
337
355
  }
338
356
 
339
357
  flushPending();
@@ -31,15 +31,43 @@ export function expandTemplate(
31
31
  names.push(name);
32
32
  }
33
33
  }
34
- if (arg.length > 0) {
35
- return { text: content.replace(PLACEHOLDER, arg), diagnostics: [] };
36
- }
37
34
  return {
38
- text: content,
39
- diagnostics: names.map((name) => `unfilled placeholder: ${name}`),
35
+ text: substituteArgs(content, parseCommandArgs(arg), arg),
36
+ diagnostics: arg.length ? [] : names.map((name) => `unfilled placeholder: ${name}`),
40
37
  };
41
38
  }
42
39
 
40
+ /** Ported from pi core/prompt-templates.ts (MIT, 02eed88); quote handling is Pi's, not shell execution. */
41
+ export function parseCommandArgs(text: string): string[] {
42
+ const args: string[] = [];
43
+ let current = "", quote: string | null = null;
44
+ for (const char of text) {
45
+ if (quote) { if (char === quote) quote = null; else current += char; }
46
+ else if (char === '"' || char === "'") quote = char;
47
+ else if (/\s/.test(char)) { if (current) { args.push(current); current = ""; } }
48
+ else current += char;
49
+ }
50
+ if (current) args.push(current);
51
+ return args;
52
+ }
53
+
54
+ /** Pi positional/default/slice syntax and legacy named syntax share one pass: inserted text stays literal. */
55
+ function substituteArgs(content: string, args: readonly string[], legacy: string): string {
56
+ const all = args.join(" ");
57
+ return content.replace(/\{\{([A-Za-z_][A-Za-z0-9_-]*)\}\}|\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g,
58
+ (match: string, named: string | undefined, target: string | undefined, fallback: string | undefined,
59
+ start: string | undefined, length: string | undefined, simple: string | undefined) => {
60
+ if (named) return legacy.length ? legacy : match;
61
+ if (target) return (target === "@" || target === "ARGUMENTS" ? all : args[Number(target) - 1]) || fallback || "";
62
+ if (start !== undefined) {
63
+ const from = Math.max(0, Number(start) - 1);
64
+ return args.slice(from, length === undefined ? undefined : from + Number(length)).join(" ");
65
+ }
66
+ if (simple === "@" || simple === "ARGUMENTS") return all;
67
+ return args[Number(simple) - 1] ?? "";
68
+ });
69
+ }
70
+
43
71
  export function parsePromptTemplate(input: {
44
72
  readonly filePath: string;
45
73
  readonly text: string;
package/src/core/queue.ts CHANGED
@@ -12,12 +12,15 @@
12
12
  */
13
13
 
14
14
  import { assertNever } from "./exhaustive.ts";
15
+ import type { ImageRef } from "./images.ts";
15
16
 
16
17
  export type QueueKind = "steering" | "followup";
18
+ export type DeliveryMode = "all" | "one-at-a-time";
17
19
 
18
20
  export interface QueuedMessage {
19
21
  readonly kind: QueueKind;
20
22
  readonly text: string;
23
+ readonly images?: readonly ImageRef[];
21
24
  }
22
25
 
23
26
  /**
@@ -58,3 +61,18 @@ function firstOfKind(
58
61
  }
59
62
  return null;
60
63
  }
64
+
65
+ /** Bash remains an atomic follow-up; batching never turns commands into model prompts. */
66
+ export function deliveryBatch(queue: readonly QueuedMessage[], phase: Phase,
67
+ modes: { steering?: DeliveryMode; followup?: DeliveryMode }): readonly QueuedMessage[] {
68
+ const first = nextDelivery(queue, phase);
69
+ if (first === null) return [];
70
+ if (modes[first.kind] !== "all" || first.text.startsWith("!")) return [first];
71
+ const batch: QueuedMessage[] = [];
72
+ for (const item of queue) {
73
+ if (item.kind !== first.kind) continue;
74
+ if (item.text.startsWith("!")) break;
75
+ batch.push(item);
76
+ }
77
+ return batch;
78
+ }
@@ -0,0 +1,36 @@
1
+ /** Pi +/-/! resource choices; discovery and path resolution belong to store. */
2
+ export type ResourceKind = "skills" | "prompts" | "themes" | "extensions";
3
+ export type ResourceScope = "user" | "project" | "cli";
4
+ export type ResourceOverride = "inherit" | "load" | "skip";
5
+ export interface ResourceEntry {
6
+ kind: ResourceKind;
7
+ path: string;
8
+ name: string;
9
+ scope: ResourceScope;
10
+ source: string;
11
+ enabled: boolean;
12
+ inherited: boolean;
13
+ override: ResourceOverride;
14
+ trusted: boolean;
15
+ package?: { source: string; identity: string; root: string };
16
+ }
17
+ export interface ResourcePattern { pattern: string; enabled: boolean; explicit: boolean }
18
+ export function parseResourcePattern(raw: string): ResourcePattern {
19
+ const first = raw[0];
20
+ return { pattern: ["+", "-", "!"].includes(first ?? "") ? raw.slice(1) : raw,
21
+ enabled: first !== "-" && first !== "!", explicit: first === "+" || first === "-" };
22
+ }
23
+ export function resourceOverride(patterns: readonly string[], matches: (pattern: string) => boolean): ResourceOverride {
24
+ let result: ResourceOverride = "inherit";
25
+ for (const raw of patterns) { const choice = parseResourcePattern(raw); if (matches(choice.pattern)) result = choice.enabled ? "load" : "skip"; }
26
+ return result;
27
+ }
28
+ export function updateResourcePatterns(patterns: readonly string[], path: string, state: ResourceOverride,
29
+ same: (pattern: string) => boolean): string[] {
30
+ const next = patterns.filter(raw => !same(parseResourcePattern(raw).pattern));
31
+ if (state !== "inherit") next.push((state === "load" ? "+" : "-") + path);
32
+ return next;
33
+ }
34
+ export function nextResourceOverride(state: ResourceOverride): ResourceOverride {
35
+ return state === "inherit" ? "load" : state === "load" ? "skip" : "inherit";
36
+ }
@@ -7,35 +7,26 @@
7
7
  * filesystem.
8
8
  */
9
9
 
10
- import type { Cursor } from "../store/log.ts";
10
+ import type { SavedModel } from "./session-entry.ts";
11
11
 
12
- export type ResumePlan =
12
+ export type ResumePlan<T extends SavedModel> =
13
13
  | { kind: "fresh" }
14
- | { kind: "resume"; cursor: Cursor }
14
+ | { kind: "resume"; cursor: T }
15
15
  | { kind: "refuse"; reason: string };
16
16
 
17
17
  /**
18
18
  * A session's thinking blocks belong to the model that produced them.
19
19
  *
20
- * Every provider this harness talks to requires them replayed unchanged, and
21
- * the two do not use the same representation at all: DeepSeek carries
22
- * `reasoning_content`, Anthropic carries a signed block. Handing one to the
23
- * other is a bad request, and it would surface on the first turn of the
24
- * resumed session rather than here.
25
- *
26
- * Refusing rather than converting is the point. pi converts, wrapping a
27
- * foreign thinking trace in `<thinking>` tags inside assistant content -- a
28
- * lossy, opinionated rewrite of what the model is told happened. This
29
- * project's position is that the context is a recomputable projection of the
30
- * log, so a resume that silently rewrites history breaks the one property
31
- * everything else rests on. The log is intact; switching back to the model
32
- * that wrote it picks up exactly where it left off.
20
+ * Same-model resume preserves the original projection. An explicit model
21
+ * change uses Session.switchModel after opening with the recorded identity
22
+ * (N.8); it never reinterprets signed or opaque thinking during replay.
33
23
  */
34
- export function planResume(
35
- cursor: Cursor | null,
24
+ export function planResume<T extends SavedModel>(
25
+ cursor: T | null,
36
26
  model: string,
37
27
  hasNodes: boolean,
38
- ): ResumePlan {
28
+ provider?: string,
29
+ ): ResumePlan<T> {
39
30
  if (cursor === null) {
40
31
  // No cursor and no nodes is simply a new session. No cursor but nodes on
41
32
  // disk is a log this build cannot place itself in -- written before the
@@ -53,12 +44,14 @@ export function planResume(
53
44
  }
54
45
  : { kind: "fresh" };
55
46
  }
56
- if (cursor.model !== model) {
47
+ if (cursor.model !== model || (cursor.provider !== undefined && cursor.provider !== provider)) {
48
+ const saved = cursor.provider === undefined ? cursor.model : `${cursor.provider}/${cursor.model}`;
49
+ const target = provider === undefined ? model : `${provider}/${model}`;
57
50
  return {
58
51
  kind: "refuse",
59
52
  reason:
60
- `this session ran under ${cursor.model}, and ${model} cannot replay its ` +
61
- `thinking blocks. Resume it with --model ${cursor.model}, or start a ` +
53
+ `this session ran under ${saved}, and ${target} cannot replay its ` +
54
+ `thinking blocks. Resume it with --model ${saved}, or start a ` +
62
55
  `separate session with --fresh`,
63
56
  };
64
57
  }
@@ -0,0 +1,4 @@
1
+ /** Archive paths are a closed storage vocabulary, never arbitrary filesystem destinations (D3). */
2
+ export function archivePath(path: unknown): path is string {
3
+ return typeof path === "string" && /^(?:node-\d{4,}\.jsonl|handoff-\d{4,}\.yaml|manifest\.json|metadata\.jsonl|(?:artifacts|blobs)\/[a-f0-9]{2}\/[a-f0-9]{62})$/.test(path);
4
+ }