@sigrun/sigrun 0.6.1 → 0.7.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 (63) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +105 -18
  3. package/dist/blocks/build-stamp.json +2 -2
  4. package/dist/blocks/schema.js +5 -0
  5. package/dist/cli.js +85 -22
  6. package/dist/curation/host.js +46 -21
  7. package/dist/curation/migrate.js +96 -0
  8. package/dist/curation/overlays.js +52 -16
  9. package/dist/curation/paths.js +22 -0
  10. package/dist/curation/runs.js +34 -17
  11. package/dist/curation/skill-tool.js +34 -9
  12. package/dist/curation/store.js +382 -254
  13. package/dist/curation/targets.js +21 -0
  14. package/dist/curation/wrap.js +1 -6
  15. package/dist/extension.d.ts +12 -3
  16. package/dist/harness/boot.js +143 -6
  17. package/dist/harness/event-bus.js +19 -0
  18. package/dist/harness/loader.js +22 -0
  19. package/dist/harness/reserved-sections.js +2 -0
  20. package/dist/harness/seam-host.js +71 -16
  21. package/dist/harness/workspace.js +34 -1
  22. package/dist/history/git.js +707 -0
  23. package/dist/history/trailers.js +145 -0
  24. package/dist/history/workspace-history.js +47 -0
  25. package/dist/jobs/index.js +5 -3
  26. package/dist/jobs/reconcile.js +2 -3
  27. package/dist/jobs/state-dir.js +9 -0
  28. package/dist/jobs/transfer.js +6 -6
  29. package/dist/sandbox/grants.js +11 -0
  30. package/dist/sandbox/launch.js +19 -9
  31. package/dist/sandbox/policy.js +6 -1
  32. package/dist/serve.js +3 -0
  33. package/dist/server/auth/proxy-only.js +5 -4
  34. package/dist/server/core-views/index.js +26 -0
  35. package/dist/server/core-views/jobs.js +661 -0
  36. package/dist/server/core-views/skills-tree.js +628 -0
  37. package/dist/server/core-views/skills.js +1090 -0
  38. package/dist/server/index.js +88 -0
  39. package/dist/server/jobs-access.js +62 -0
  40. package/dist/server/notifications.js +24 -12
  41. package/dist/server/routes/extensions.js +9 -1
  42. package/dist/server/routes/jobs.js +19 -52
  43. package/dist/server/routes/meta.js +9 -0
  44. package/dist/server/routes/onboarding.js +13 -1
  45. package/dist/server/surfaces/service.js +38 -0
  46. package/dist/skills/index.js +1 -1
  47. package/dist/skills/roots.js +301 -0
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
  49. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  50. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  52. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  53. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  55. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  56. package/node_modules/@sigrun/shared/package.json +9 -1
  57. package/package.json +7 -7
  58. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  59. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  60. package/webui/dist/favicon.svg +7 -5
  61. package/webui/dist/index.html +3 -3
  62. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  63. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Task 11: every-root discovery with safe reads.
3
+ *
4
+ * `listAllRoots` is the Skills-tab (core/skills view) counterpart to
5
+ * `discoverSkills`: where `merge()` keeps only the winning skill per
6
+ * name, this lists EVERY root's skills — losers included — in precedence
7
+ * order (workspace > learned > user dir > packages in install order),
8
+ * with shadowing info (`shadowedBy` names the root kind that won), the
9
+ * hidden flag, and `{error}` rows for skills that fail the safety
10
+ * checks. The frontmatter parsing is `scanFsRoot`'s own.
11
+ *
12
+ * `readSkillFile` / `listSupportFiles` are the ONLY read paths the view
13
+ * uses: core reads these roots with HOST privileges, so every read
14
+ * refuses symlinks on EVERY path segment, verifies real-path containment
15
+ * within the discovery root, allows only SKILL.md or
16
+ * `references|templates|scripts|assets/<rel>`, and caps reads at
17
+ * 256 KiB — the same checks the curator store applies
18
+ * (`assertNoSymlinks` + support-file shape). Without them the view is a
19
+ * confused deputy: a symlinked SKILL.md would show the user a file the
20
+ * agent's sandbox could never read. A skill that fails the checks
21
+ * becomes `{error}` and is never read further.
22
+ */
23
+ import { lstat, readdir, realpath, readFile, stat } from "node:fs/promises";
24
+ import { join, sep } from "node:path";
25
+ import { validSkillName } from "../curation/names.js";
26
+ import { parseSkillFile } from "./index.js";
27
+ /** The largest file the view will read (SKILL.md or a support file). */
28
+ export const SKILL_READ_CAP_BYTES = 256 * 1024;
29
+ const SKILL_FILE = "SKILL.md";
30
+ const SUPPORT_ROOTS = ["references", "templates", "scripts", "assets"];
31
+ const SYMLINK_REFUSED = "symlinks are not allowed in skill files";
32
+ /**
33
+ * The file argument's allowed shapes: exactly `SKILL.md`, or a relative
34
+ * path under `references/`, `templates/`, `scripts/`, or `assets/` with
35
+ * no empty/`.`/`..` segments, no backslashes, no absolute prefix, no NUL.
36
+ * Returns the forward-slash segments.
37
+ */
38
+ function fileSegments(file) {
39
+ if (file === SKILL_FILE)
40
+ return [file];
41
+ if (file.includes("\\") || file.startsWith("/") || file.includes("\0")) {
42
+ throw new Error("skill file must be SKILL.md or a relative support-file path");
43
+ }
44
+ const parts = file.split("/");
45
+ if (parts.length < 2 ||
46
+ !SUPPORT_ROOTS.includes(parts[0]) ||
47
+ parts.some((part) => part === "" || part === "." || part === "..")) {
48
+ throw new Error("skill file must be SKILL.md or under references/, templates/, scripts/ or assets/");
49
+ }
50
+ return parts;
51
+ }
52
+ /**
53
+ * Refuse symlinks on EVERY segment of `<root>/<segments>` (the curator
54
+ * store's `assertNoSymlinks` check): a symlink anywhere on the path means
55
+ * the read target is not what the root contains, so the read is refused
56
+ * outright. Returns once a segment does not exist — the read itself will
57
+ * report the absence.
58
+ */
59
+ async function assertNoSymlinks(root, segments) {
60
+ let path = root;
61
+ for (const segment of segments) {
62
+ path = join(path, segment);
63
+ try {
64
+ if ((await lstat(path)).isSymbolicLink())
65
+ throw new Error(SYMLINK_REFUSED);
66
+ }
67
+ catch (error) {
68
+ if (error.code === "ENOENT")
69
+ return;
70
+ throw error;
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Real-path containment: after resolving every link, `target` must sit
76
+ * inside `root`. The belt to `assertNoSymlinks`'s braces — a hardlink-free
77
+ * lstat walk still cannot see a rename race, and containment catches any
78
+ * resolution that escaped the anchor regardless of how.
79
+ */
80
+ async function assertContained(root, target) {
81
+ const [realRoot, realTarget] = await Promise.all([
82
+ realpath(root),
83
+ realpath(target),
84
+ ]);
85
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
86
+ throw new Error("skill file escapes its discovery root");
87
+ }
88
+ }
89
+ /**
90
+ * The ONLY read path the core/skills view uses. `rootDir` is the
91
+ * discovery root (the containment anchor), `skill` the skill directory
92
+ * name inside it, `file` either `SKILL.md` or a support-file path under
93
+ * `references|templates|scripts|assets`. Symlinks on any segment and any
94
+ * escape from `rootDir` are refused; reads over 256 KiB are refused.
95
+ */
96
+ export async function readSkillFile(rootDir, skill, file) {
97
+ if (!validSkillName(skill))
98
+ throw new Error(`invalid skill name: ${skill}`);
99
+ const segments = [skill, ...fileSegments(file)];
100
+ await assertNoSymlinks(rootDir, segments);
101
+ const target = join(rootDir, ...segments);
102
+ await assertContained(rootDir, target);
103
+ const info = await stat(target);
104
+ if (info.size > SKILL_READ_CAP_BYTES) {
105
+ throw new Error(`skill file exceeds the 256 KiB read cap: ${file}`);
106
+ }
107
+ const buffer = await readFile(target);
108
+ if (buffer.byteLength > SKILL_READ_CAP_BYTES) {
109
+ throw new Error(`skill file exceeds the 256 KiB read cap: ${file}`);
110
+ }
111
+ return buffer.toString("utf8");
112
+ }
113
+ /**
114
+ * Recursively collect relative file paths under `dir`, never following
115
+ * symlinks: a symlinked entry (file or directory) is skipped, not
116
+ * traversed — the listing mirrors what `readSkillFile` would serve.
117
+ */
118
+ async function collectFiles(dir, prefix, out) {
119
+ let entries;
120
+ try {
121
+ entries = await readdir(dir, { withFileTypes: true });
122
+ }
123
+ catch (error) {
124
+ if (error.code === "ENOENT")
125
+ return;
126
+ throw error;
127
+ }
128
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
129
+ for (const entry of entries) {
130
+ if (entry.isSymbolicLink())
131
+ continue;
132
+ const relative = `${prefix}/${entry.name}`;
133
+ if (entry.isDirectory()) {
134
+ await collectFiles(join(dir, entry.name), relative, out);
135
+ }
136
+ else if (entry.isFile()) {
137
+ out.push(relative);
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * The support-file listing under one skill, with the same safety posture
143
+ * as `readSkillFile`: the skill directory must be symlink-free and
144
+ * contained in `rootDir`, and symlinked entries inside the support roots
145
+ * are excluded rather than followed. A skill without support files (or
146
+ * without a directory at all) lists nothing.
147
+ */
148
+ export async function listSupportFiles(rootDir, skill) {
149
+ if (!validSkillName(skill))
150
+ throw new Error(`invalid skill name: ${skill}`);
151
+ await assertNoSymlinks(rootDir, [skill]);
152
+ const skillDir = join(rootDir, skill);
153
+ try {
154
+ if (!(await lstat(skillDir)).isDirectory())
155
+ return [];
156
+ }
157
+ catch (error) {
158
+ if (error.code === "ENOENT")
159
+ return [];
160
+ throw error;
161
+ }
162
+ await assertContained(rootDir, skillDir);
163
+ const out = [];
164
+ for (const root of SUPPORT_ROOTS) {
165
+ await collectFiles(join(skillDir, root), root, out);
166
+ }
167
+ return out.sort();
168
+ }
169
+ /** The safety check a skill must pass before its SKILL.md is ever read. */
170
+ async function skillSafetyError(rootDir, dirName) {
171
+ try {
172
+ await assertNoSymlinks(rootDir, [dirName]);
173
+ const skillDir = join(rootDir, dirName);
174
+ if (!(await lstat(skillDir)).isDirectory())
175
+ return undefined; // not a skill
176
+ await assertContained(rootDir, skillDir);
177
+ // SKILL.md itself: present, symlink-free, and inside the root.
178
+ await assertNoSymlinks(rootDir, [dirName, SKILL_FILE]);
179
+ await assertContained(rootDir, join(skillDir, SKILL_FILE));
180
+ return undefined;
181
+ }
182
+ catch (error) {
183
+ return error instanceof Error ? error.message : String(error);
184
+ }
185
+ }
186
+ /**
187
+ * List ONE root's skill directories, applying the safety checks before
188
+ * any read. Entries that are not directories, and directories without a
189
+ * SKILL.md, are not skills (the v1 rule, unchanged). A symlinked skill
190
+ * dir or SKILL.md is an error row — named by its directory, never read.
191
+ * Readable skills parse through `parseSkillFile` (scanFsRoot's own
192
+ * frontmatter parsing); within one root, duplicate names resolve
193
+ * first-wins in sorted directory order with a log, exactly like
194
+ * scanFsRoot.
195
+ */
196
+ async function scanRootSafely(root) {
197
+ let entries;
198
+ try {
199
+ entries = await readdir(root.rootDir, { withFileTypes: true });
200
+ }
201
+ catch (error) {
202
+ if (error.code === "ENOENT")
203
+ return [];
204
+ throw error;
205
+ }
206
+ const names = [...new Set(entries.map((entry) => entry.name))].sort();
207
+ const skills = [];
208
+ const byName = new Map();
209
+ for (const dirName of names) {
210
+ if (!validSkillName(dirName))
211
+ continue;
212
+ const dir = join(root.rootDir, dirName);
213
+ const base = {
214
+ root: root.kind,
215
+ rootDir: root.rootDir,
216
+ dir,
217
+ hidden: false,
218
+ ...(root.packageName === undefined ? {} : { packageName: root.packageName }),
219
+ };
220
+ // A symlinked entry (skill dir or SKILL.md inside) is an error row —
221
+ // never read further, so the target's content leaks nowhere.
222
+ const safetyError = await skillSafetyError(root.rootDir, dirName);
223
+ if (safetyError !== undefined) {
224
+ skills.push({ ...base, name: dirName, description: "", error: safetyError });
225
+ continue;
226
+ }
227
+ let text;
228
+ try {
229
+ text = await readSkillFile(root.rootDir, dirName, SKILL_FILE);
230
+ }
231
+ catch (error) {
232
+ if (error.code === "ENOENT")
233
+ continue; // no SKILL.md — not a skill
234
+ skills.push({
235
+ ...base,
236
+ name: dirName,
237
+ description: "",
238
+ error: error instanceof Error ? error.message : String(error),
239
+ });
240
+ continue;
241
+ }
242
+ const parsed = parseSkillFile(text);
243
+ if (parsed === undefined || parsed.name === undefined || parsed.description === undefined) {
244
+ // The v1 skip rule (a malformed SKILL.md is not a skill) holds for
245
+ // the view too — the row simply does not appear.
246
+ continue;
247
+ }
248
+ const skill = {
249
+ ...base,
250
+ name: parsed.name,
251
+ description: parsed.description,
252
+ hidden: parsed.disableModelInvocation,
253
+ };
254
+ if (!byName.has(skill.name)) {
255
+ byName.set(skill.name, skill);
256
+ skills.push(skill);
257
+ }
258
+ else {
259
+ console.warn(`skills: duplicate name "${skill.name}" (${dir} ignored; ${byName.get(skill.name)?.dir} wins)`);
260
+ }
261
+ }
262
+ return skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
263
+ }
264
+ /**
265
+ * Every root's skills, losers included, in precedence order: workspace,
266
+ * learned, user dir, then packages in install order. A name listed by
267
+ * several roots wins in the FIRST (highest-precedence) root; every
268
+ * lower-precedence row carries `shadowedBy` naming the winning root
269
+ * kind. Absent roots are the designed empty case.
270
+ */
271
+ export async function listAllRoots(opts) {
272
+ const roots = [];
273
+ if (opts.workspace !== undefined) {
274
+ roots.push({ kind: "workspace", rootDir: opts.workspace });
275
+ }
276
+ if (opts.learned !== undefined) {
277
+ roots.push({ kind: "learned", rootDir: opts.learned });
278
+ }
279
+ roots.push({ kind: "user dir", rootDir: opts.userDir });
280
+ for (const pkg of opts.packageRoots) {
281
+ roots.push({ kind: "package", rootDir: pkg.dir, packageName: pkg.extension });
282
+ }
283
+ const out = [];
284
+ const winners = new Map();
285
+ for (const root of roots) {
286
+ for (const skill of await scanRootSafely(root)) {
287
+ if (skill.error === undefined) {
288
+ const winner = winners.get(skill.name);
289
+ if (winner === undefined) {
290
+ winners.set(skill.name, skill.root);
291
+ }
292
+ else {
293
+ out.push({ ...skill, shadowedBy: winner });
294
+ continue;
295
+ }
296
+ }
297
+ out.push(skill);
298
+ }
299
+ }
300
+ return out;
301
+ }
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.7.0] - 2026-11-04
10
+
11
+ - New `./model-label` subpath: `modelBreadcrumb` collapses every non-leaf path segment of
12
+ a model id to its first character and prepends the provider (`bifrost` +
13
+ `chain/acme-flash` → `b/c/acme-flash`).
14
+ - New `./auth` subpath: the one `isProxyOnly` predicate (server, webui, CLI), treating
15
+ absent/empty method lists as proxy-only (fail safe).
16
+
9
17
  ## [0.6.1] - 2026-11-03
10
18
 
11
19
  - Version pin to the 0.6.1 core family (no shared-source changes beyond the version).
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The auth posture shared by the server, the webui, and the CLI
3
+ * (polish task 12; proxy-trust spec §5.2/§5.8).
4
+ *
5
+ * `isProxyOnly` had THREE private copies (src/server/auth/proxy-only.ts,
6
+ * webui/src/auth/AuthGate.tsx, src/cli.ts) that could drift: a proxy-only
7
+ * server mints no redeemable setup code, so every surface must agree on
8
+ * what "proxy-only" means. One definition lives here now; the server's
9
+ * own module re-exports it for its existing callers.
10
+ */
11
+ /**
12
+ * Whether the configured auth methods are exactly proxy-only: a `proxy`
13
+ * method with no `token` method. A proxy-only server has no token
14
+ * callers, so an unvouched setup-code prompt can never be redeemed —
15
+ * onboarding happens through the proxy in a browser.
16
+ *
17
+ * UNKNOWN posture fails safe: an absent/empty method list (the 403
18
+ * setup_required path carries no methods; a pre-methods server speaks
19
+ * an older shape) counts as proxy-only — a stranger-facing server whose
20
+ * auth methods cannot be seen must not invite an unredeemable claim.
21
+ */
22
+ export declare function isProxyOnly(methods: readonly string[] | undefined): boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The auth posture shared by the server, the webui, and the CLI
3
+ * (polish task 12; proxy-trust spec §5.2/§5.8).
4
+ *
5
+ * `isProxyOnly` had THREE private copies (src/server/auth/proxy-only.ts,
6
+ * webui/src/auth/AuthGate.tsx, src/cli.ts) that could drift: a proxy-only
7
+ * server mints no redeemable setup code, so every surface must agree on
8
+ * what "proxy-only" means. One definition lives here now; the server's
9
+ * own module re-exports it for its existing callers.
10
+ */
11
+ /**
12
+ * Whether the configured auth methods are exactly proxy-only: a `proxy`
13
+ * method with no `token` method. A proxy-only server has no token
14
+ * callers, so an unvouched setup-code prompt can never be redeemed —
15
+ * onboarding happens through the proxy in a browser.
16
+ *
17
+ * UNKNOWN posture fails safe: an absent/empty method list (the 403
18
+ * setup_required path carries no methods; a pre-methods server speaks
19
+ * an older shape) counts as proxy-only — a stranger-facing server whose
20
+ * auth methods cannot be seen must not invite an unredeemable claim.
21
+ */
22
+ export function isProxyOnly(methods) {
23
+ if (methods === undefined || methods.length === 0)
24
+ return true;
25
+ return methods.includes("proxy") && !methods.includes("token");
26
+ }
@@ -18,7 +18,7 @@ export { lttb } from "./lttb.js";
18
18
  export { formatValue } from "./format.js";
19
19
  export { toText } from "./text.js";
20
20
  export { canonicalJson, eventKeyWeb, instanceKey } from "./instance.js";
21
- export type { Action, ActionButton, Bindable, Binding, Block, BlockBase, BulkAction, CalloutBlock, Card, CardBadge, CardMetric, CardMeta, CardStatus, ChartAxisX, ChartAxisY, ChartBlock, ChartSeries, ChartThreshold, ChoiceField, ChoiceOption, CollectionBlock, CollectionColumn, CollectionGroup, CollectionSort, CollectionTime, ColumnChild, ColumnsBlock, Confirm, CoreTarget, DataOp, DataOps, DataUpdate, DateField, DatePreset, DaterangeField, DatetimeField, DetailsBlock, DiffBlock, Field, FieldBase, FieldsBlock, FieldsRow, FileField, Format, FormBlock, GridBlock, GridChild, ImageBlock, ListField, LogBlock, LogLine, LongtextField, MarkdownBlock, MeterBlock, MeterThreshold, NumberField, PageBlock, PageEmpty, Placement, SectionBlock, SecretField, Size, StackBlock, StatBlock, TextField, ToggleField, Tone, Tree, TreeBlock, TreeNode, WidthCondition, } from "./types.js";
21
+ export type { Action, ActionButton, Bindable, Binding, Block, BlockBase, BulkAction, CalloutBlock, Card, CardBadge, CardMetric, CardMeta, CardStatus, ChartAxisX, ChartAxisY, ChartBlock, ChartSeries, ChartThreshold, ChoiceField, ChoiceOption, CollectionBlock, CollectionColumn, CollectionGroup, CollectionSort, CollectionTime, ColumnChild, ColumnsBlock, Confirm, CoreTarget, DataOp, DataOps, DataUpdate, DateField, DatePreset, DaterangeField, DatetimeField, DetailsBlock, DiffBlock, Field, FieldBase, FieldsBlock, FieldsRow, FileField, Format, FormBlock, GridBlock, GridChild, ImageBlock, ListField, LogBlock, LogLine, LongtextField, MarkdownBlock, MeterBlock, MeterThreshold, NumberField, PageBlock, PageEmpty, PageSelection, Placement, SectionBlock, SecretField, Size, StackBlock, StatBlock, TextField, ToggleField, Tone, Tree, TreeBlock, TreeNode, WidthCondition, } from "./types.js";
22
22
  export type { IconName } from "./icons.js";
23
23
  export type { TextOptions } from "./text.js";
24
24
  export type { SurfaceAccess, SurfaceActErr, SurfaceActOk, SurfaceActResult, SurfaceChangedEvent, SurfaceFetch, SurfaceLoad, SurfaceTransport, SurfaceUploadBody, } from "./transport.js";
@@ -108,6 +108,19 @@ export interface PageEmpty {
108
108
  title: string;
109
109
  hint?: string;
110
110
  }
111
+ /**
112
+ * Server-declared list+detail drill-in (spec §12.3), presentation-only:
113
+ * on compact widths `selected: false` shows the sidebar list full width
114
+ * (main hidden); `selected: true` shows main with a back arrow that
115
+ * navigates with `back` as the params. Wide widths are unchanged —
116
+ * sidebar and main sit side by side regardless of `selected`. `toText`
117
+ * ignores it (sidebar then main, as today).
118
+ */
119
+ export interface PageSelection {
120
+ readonly selected: boolean;
121
+ /** The params the compact back arrow navigates to. */
122
+ readonly back: JsonObject;
123
+ }
111
124
  export interface PageBlock extends BlockBase {
112
125
  readonly type: "page";
113
126
  readonly title: Bindable<string>;
@@ -121,6 +134,8 @@ export interface PageBlock extends BlockBase {
121
134
  /** Blocks for a side pane, shown when non-empty. */
122
135
  readonly detail?: readonly Block[];
123
136
  readonly empty?: PageEmpty;
137
+ /** Compact-width list+detail drill-in (see {@link PageSelection}). */
138
+ readonly selection?: PageSelection;
124
139
  }
125
140
  export interface StackBlock extends BlockBase {
126
141
  readonly type: "stack";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The model breadcrumb (polish pass Task 5, spec §5.1): how a client
3
+ * displays "which model answered this message".
4
+ *
5
+ * User ruling: the provider is ALWAYS a breadcrumb segment. Every path
6
+ * segment except the leaf collapses to its first character, the leaf
7
+ * stays whole, and the provider joins as its own one-character segment —
8
+ * `openai` + `gpt-4o-mini` reads `o/gpt-4o-mini`, an old bifrost chain
9
+ * message (bare `acme-flash`) `b/acme-flash`, a new one (Task 4 restored
10
+ * the display id, so `chain/acme-flash`) `b/c/acme-flash`. The untruncated
11
+ * id rides along as `full` for a hover title, so the transcript stays
12
+ * compact without losing what it abbreviated.
13
+ *
14
+ * A message with NO provider shows its plain model name: the archive has
15
+ * rows recorded before a provider was stamped, and inventing a segment
16
+ * for them would name a provider nobody recorded.
17
+ *
18
+ * Pure module (no imports at all): both clients render this from one
19
+ * source, so it must never drag in a dependency one of them can't resolve.
20
+ */
21
+ /**
22
+ * The two strings a client shows for one message's model: `short` is the
23
+ * collapsed breadcrumb (the transcript label), `full` the untruncated
24
+ * `provider/model` id (a hover title).
25
+ */
26
+ export declare function modelBreadcrumb(provider: string | undefined, model: string): {
27
+ readonly short: string;
28
+ readonly full: string;
29
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The model breadcrumb (polish pass Task 5, spec §5.1): how a client
3
+ * displays "which model answered this message".
4
+ *
5
+ * User ruling: the provider is ALWAYS a breadcrumb segment. Every path
6
+ * segment except the leaf collapses to its first character, the leaf
7
+ * stays whole, and the provider joins as its own one-character segment —
8
+ * `openai` + `gpt-4o-mini` reads `o/gpt-4o-mini`, an old bifrost chain
9
+ * message (bare `acme-flash`) `b/acme-flash`, a new one (Task 4 restored
10
+ * the display id, so `chain/acme-flash`) `b/c/acme-flash`. The untruncated
11
+ * id rides along as `full` for a hover title, so the transcript stays
12
+ * compact without losing what it abbreviated.
13
+ *
14
+ * A message with NO provider shows its plain model name: the archive has
15
+ * rows recorded before a provider was stamped, and inventing a segment
16
+ * for them would name a provider nobody recorded.
17
+ *
18
+ * Pure module (no imports at all): both clients render this from one
19
+ * source, so it must never drag in a dependency one of them can't resolve.
20
+ */
21
+ /** The id's path segments, empties dropped (`a//b/` → `["a","b"]`). */
22
+ function pathSegments(id) {
23
+ return id.split("/").filter((segment) => segment !== "");
24
+ }
25
+ /** The collapsed breadcrumb path: every non-leaf segment's first
26
+ * character + the whole leaf. Never called with an empty path. */
27
+ function collapse(path) {
28
+ const leaf = path[path.length - 1] ?? "";
29
+ const head = path.slice(0, -1).map((segment) => segment.slice(0, 1));
30
+ return [...head, leaf].join("/");
31
+ }
32
+ /**
33
+ * The two strings a client shows for one message's model: `short` is the
34
+ * collapsed breadcrumb (the transcript label), `full` the untruncated
35
+ * `provider/model` id (a hover title).
36
+ */
37
+ export function modelBreadcrumb(provider, model) {
38
+ if (provider === undefined || provider === "") {
39
+ return { short: model, full: model };
40
+ }
41
+ // The id may already carry its provider (`bifrost/chain/x`): strip the
42
+ // prefix rather than folding it into a second segment, so the provider
43
+ // is never named twice.
44
+ const prefix = `${provider}/`;
45
+ const rest = model.startsWith(prefix) ? model.slice(prefix.length) : model;
46
+ const path = pathSegments(rest);
47
+ if (path.length === 0) {
48
+ // Nothing but the provider — show it whole, never a trailing slash.
49
+ return { short: provider.slice(0, 1), full: provider };
50
+ }
51
+ return {
52
+ short: `${provider.slice(0, 1)}/${collapse(path)}`,
53
+ full: `${provider}/${path.join("/")}`,
54
+ };
55
+ }
@@ -558,6 +558,13 @@ export interface MetaResponse {
558
558
  readonly keys: readonly string[];
559
559
  }[];
560
560
  };
561
+ /**
562
+ * The core-owned view surfaces (tabs+skills Task 4) — the same list
563
+ * `/v1/extensions` serves under `coreViews`; the meta copy exists so
564
+ * the tab shell's rail never fetches `/v1/extensions` separately.
565
+ * Absent when no core views attach.
566
+ */
567
+ readonly coreViews?: readonly CoreViewInfo[];
561
568
  /**
562
569
  * Boot-merge collisions (build-and-webui Task 6): name collisions among
563
570
  * extension-contributed webui bundles, the provider-catalog merge sink,
@@ -733,6 +740,27 @@ export interface ExtensionSurfaceInfo {
733
740
  /** The `GET /v1/extensions` payload (spec §7.7). */
734
741
  export interface ExtensionsResponse {
735
742
  readonly extensions: readonly ExtensionInfo[];
743
+ /**
744
+ * The core-owned view surfaces (tabs+skills Task 4): one row per
745
+ * `SurfaceDef<"view">` attached under the reserved `core` slug via
746
+ * `SurfaceService.attachCoreViews` (the Jobs/Skills views). Core views
747
+ * are NOT `extensions[]` rows — the health dot stays unaffected — so
748
+ * channels list them from here, ahead of the extension views.
749
+ */
750
+ readonly coreViews?: readonly CoreViewInfo[];
751
+ }
752
+ /**
753
+ * One core-owned view surface on the wire (tabs+skills Task 4): the
754
+ * id/title/icon the channels' rail/tab registration reads, plus the
755
+ * declared refresh cadence. Mirrors the view half of
756
+ * `ExtensionSurfaceInfo`, minus `placement` (core views are always
757
+ * `view`) and `sharedData` (render-time concern, never list-time).
758
+ */
759
+ export interface CoreViewInfo {
760
+ readonly id: string;
761
+ readonly title: string;
762
+ readonly icon: string;
763
+ readonly refreshMs?: number;
736
764
  }
737
765
  /**
738
766
  * The `GET /v1/settings/sections` payload (UI-blocks U4 Task 4, spec
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/shared",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "sigrun shared leaf package \u2014 theme tokens, turn/conversation shaping, and the channel wire types (pure leaf, zero dependencies)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/turns.d.ts",
21
21
  "default": "./dist/turns.js"
22
22
  },
23
+ "./model-label": {
24
+ "types": "./dist/model-label.d.ts",
25
+ "default": "./dist/model-label.js"
26
+ },
23
27
  "./session-meta": {
24
28
  "types": "./dist/session-meta.d.ts",
25
29
  "default": "./dist/session-meta.js"
@@ -40,6 +44,10 @@
40
44
  "types": "./dist/access.d.ts",
41
45
  "default": "./dist/access.js"
42
46
  },
47
+ "./auth": {
48
+ "types": "./dist/auth.d.ts",
49
+ "default": "./dist/auth.js"
50
+ },
43
51
  "./settings": {
44
52
  "types": "./dist/settings.d.ts",
45
53
  "default": "./dist/settings.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/sigrun",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Self-hosted, multi-user personal-assistant server",
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,12 +45,12 @@
45
45
  "@earendil-works/pi-tui": "^1.3.0",
46
46
  "@fastify/static": "^10.1.7",
47
47
  "@sigrun/ext-codemode": "0.1.2",
48
- "@sigrun/ext-curator": "0.1.0",
49
- "@sigrun/ext-gateway": "0.1.12",
48
+ "@sigrun/ext-curator": "0.2.0",
49
+ "@sigrun/ext-gateway": "0.1.14",
50
50
  "@sigrun/ext-mcp": "0.2.2",
51
51
  "@sigrun/ext-tool-search": "0.1.2",
52
52
  "@sigrun/oauth": "0.1.1",
53
- "@sigrun/shared": "0.6.1",
53
+ "@sigrun/shared": "0.7.0",
54
54
  "croner": "^10.0.1",
55
55
  "fastify": "^5.12.7",
56
56
  "semver": "^7.8.5",
@@ -61,9 +61,9 @@
61
61
  "@sigrun/shared"
62
62
  ],
63
63
  "optionalDependencies": {
64
- "@sigrun/sandbox-linux-arm64": "0.6.1",
65
- "@sigrun/sandbox-linux-x64": "0.6.1",
66
- "@sigrun/tui": "0.6.1"
64
+ "@sigrun/sandbox-linux-arm64": "0.7.0",
65
+ "@sigrun/sandbox-linux-x64": "0.7.0",
66
+ "@sigrun/tui": "0.7.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@sigrun/memory-markdown": "file:../sigrun-memory-markdown",