@timqi/pier 0.1.1 → 0.1.3

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 (72) hide show
  1. package/dist/agent/config.js +57 -59
  2. package/dist/agent/listing.js +38 -15
  3. package/dist/agent/packages.js +353 -0
  4. package/dist/agent/pi.js +28 -13
  5. package/dist/core/types.js +7 -0
  6. package/dist/extensions/index.js +1 -20
  7. package/dist/main.js +19 -14
  8. package/dist/settings.js +5 -0
  9. package/dist/tools.js +46 -85
  10. package/dist/web/config.js +25 -11
  11. package/dist/web/explorer.js +5 -3
  12. package/dist/web/instance.js +6 -18
  13. package/dist/web/packages.js +84 -0
  14. package/dist/web/public/assets/{activity-B89_hH7q.js → activity-BSMeRcN2.js} +2 -2
  15. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  16. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  17. package/dist/web/public/assets/boards-BCWQMZry.js +1 -0
  18. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  19. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  20. package/dist/web/public/assets/explorer-Cr4XTi4j.js +4 -0
  21. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  22. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  23. package/dist/web/public/assets/index-C7tA0Ufu.js +85 -0
  24. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  25. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  26. package/dist/web/public/assets/index-DVIt5Gio.css +2 -0
  27. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  28. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  29. package/dist/web/public/assets/runs-DdERzeac.js +1 -0
  30. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  31. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  32. package/dist/web/public/assets/settings-CQDAHoMM.js +5 -0
  33. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  34. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  35. package/dist/web/public/assets/task-runs-C-dGUDsH.js +3 -0
  36. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  37. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  38. package/dist/web/public/assets/tasks-CY30H1u1.js +4 -0
  39. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  40. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  41. package/dist/web/public/index.html +2 -2
  42. package/dist/web/public/index.html.br +0 -0
  43. package/dist/web/public/index.html.gz +0 -0
  44. package/dist/web/server.js +3 -1
  45. package/docs/deploy.md +11 -0
  46. package/package.json +1 -1
  47. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  48. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  49. package/dist/web/public/assets/boards-BeKW0ZXK.js +0 -1
  50. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  51. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  52. package/dist/web/public/assets/explorer-DIuMlaV3.js +0 -4
  53. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  54. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  55. package/dist/web/public/assets/index-DzXDXra_.js +0 -85
  56. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  57. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  58. package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
  59. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  60. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  61. package/dist/web/public/assets/runs-Cwy0mN8i.js +0 -1
  62. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  63. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  64. package/dist/web/public/assets/settings-DzZLmujq.js +0 -5
  65. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  66. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  67. package/dist/web/public/assets/task-runs-BCakxFk8.js +0 -3
  68. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  69. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  70. package/dist/web/public/assets/tasks-BlzEbk11.js +0 -4
  71. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  72. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
package/dist/agent/pi.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
3
3
  // Pi SDK. No Pi type may appear in an exported signature.
4
4
  import { realpathSync } from "node:fs";
5
- import { basename, dirname, join } from "node:path";
5
+ import { basename, dirname, join, sep } from "node:path";
6
6
  import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
7
7
  import { inlineExtensions } from "../extensions/index.js";
8
8
  import { SESSION_TITLE_MAX } from "../core/types.js";
@@ -112,20 +112,30 @@ const bashTimeoutDefault = (pi) => {
112
112
  }
113
113
  });
114
114
  };
115
+ /** What the last session open stood down, by bundled extension name: the
116
+ * registry (agent/packages.ts) shows it as the row's state. Known only at
117
+ * open, so a built-in no session has loaded since reads nothing. */
118
+ const shadowed = new Map();
119
+ export const shadowedBuiltin = (name) => shadowed.get(name) ?? null;
115
120
  /** Pi loads a bundled extension and its on-disk twin both, leaving two tools
116
121
  * of one name; the copy the user put there wins, and the journal says so (§5). */
117
122
  export const standDownShadowed = (base) => {
118
123
  const inline = (ext) => ext.path.startsWith("<inline:");
119
- const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
120
- if (!onDisk.size)
121
- return base;
124
+ const onDisk = new Map(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()].map((tool) => [tool, ext.path])));
122
125
  return {
123
126
  ...base,
124
127
  extensions: base.extensions.filter((ext) => {
125
- const clash = inline(ext) && [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
126
- if (!clash || !clash.length)
128
+ if (!inline(ext))
127
129
  return true;
128
- log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from disk`);
130
+ const name = ext.path.slice("<inline:".length, -1);
131
+ const clash = [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
132
+ if (!clash.length) {
133
+ shadowed.delete(name);
134
+ return true;
135
+ }
136
+ const from = onDisk.get(clash[0]);
137
+ shadowed.set(name, `stood down — ${clash.join(", ")} from ${from}`);
138
+ log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from ${from}`);
129
139
  return false;
130
140
  }),
131
141
  };
@@ -396,7 +406,7 @@ export class PiAgentFactory {
396
406
  credentials;
397
407
  providerConfig;
398
408
  pinned;
399
- enabledExtensions;
409
+ pier;
400
410
  titleModel;
401
411
  listings;
402
412
  constructor(extraTools = [],
@@ -408,7 +418,9 @@ export class PiAgentFactory {
408
418
  skillPaths = [],
409
419
  /** Optional only for bare test factories; an OAuth refresh persists here,
410
420
  * never to a plaintext auth.json. */
411
- credentials, providerConfig = new PiConfigStore(), pinned = () => [], enabledExtensions = () => [], titleModel = () => undefined,
421
+ credentials, providerConfig = new PiConfigStore(), pinned = () => [],
422
+ /** The built-in `pier` package's switches: bundled extensions on, Pier's own skills off. */
423
+ pier = () => ({ extensions: [], skillsOff: [] }), titleModel = () => undefined,
412
424
  /** Injected so a test needs no session directory or database. */
413
425
  listings = new IndexedListing()) {
414
426
  this.extraTools = extraTools;
@@ -417,7 +429,7 @@ export class PiAgentFactory {
417
429
  this.credentials = credentials;
418
430
  this.providerConfig = providerConfig;
419
431
  this.pinned = pinned;
420
- this.enabledExtensions = enabledExtensions;
432
+ this.pier = pier;
421
433
  this.titleModel = titleModel;
422
434
  this.listings = listings;
423
435
  }
@@ -605,19 +617,22 @@ export class PiAgentFactory {
605
617
  await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
606
618
  }
607
619
  async resourceLoader(cwd) {
620
+ const { extensions, skillsOff } = this.pier();
608
621
  const loader = new DefaultResourceLoader({
609
622
  cwd,
610
623
  agentDir: defaultAgentDir(),
611
624
  // The user's SYSTEM.md is appended after Pier's baseline, so it still wins.
612
625
  systemPromptOverride: pierSystemPrompt,
613
626
  additionalSkillPaths: this.skillPaths,
627
+ // Only Pier's own skills answer to the off-list; a user's skill of the
628
+ // same name is Pi's to switch (settings.json).
614
629
  skillsOverride: (base) => ({
615
630
  ...base,
616
- skills: standDownUndocumented(this.extraTools, base.skills),
631
+ skills: standDownUndocumented(this.extraTools, base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep)))),
617
632
  }),
618
633
  extensionFactories: [
619
634
  { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
620
- ...inlineExtensions(this.enabledExtensions()),
635
+ ...inlineExtensions(extensions),
621
636
  ],
622
637
  extensionsOverride: standDownShadowed,
623
638
  agentsFilesOverride: (current) => {
@@ -633,7 +648,7 @@ export class PiAgentFactory {
633
648
  return loader;
634
649
  }
635
650
  open(cwd, sessionManager, opts = { cwd }) {
636
- return this.providerConfig.withSnapshot(() => this.openSnapshot(cwd, sessionManager, opts));
651
+ return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
637
652
  }
638
653
  async openSnapshot(cwd, sessionManager, opts) {
639
654
  let live;
@@ -13,6 +13,13 @@ export const SESSION_TITLE_MAX = 80;
13
13
  * drift, and boundary validators use isThinkingLevel instead of their own copy. */
14
14
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
15
15
  export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
16
+ export class PackageError extends Error {
17
+ reason;
18
+ constructor(reason, message) {
19
+ super(message);
20
+ this.reason = reason;
21
+ }
22
+ }
16
23
  // Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
17
24
  // non-Pi backend is bound to them by this seam.
18
25
  const PROVIDER_APIS = [
@@ -3,27 +3,8 @@
3
3
  // either clobbers the user's edits or skips them forever.
4
4
  import web from "./web/index.js";
5
5
  export const BUNDLED = [
6
- {
7
- name: "web",
8
- summary: "The public web through the provider's own hosted web tools — no extra " +
9
- "key, no second service, no new dependency.",
10
- tools: [
11
- { name: "web_search", needs: "an authenticated Anthropic or OpenAI model" },
12
- { name: "web_fetch", needs: "an authenticated Anthropic model — OpenAI hosts no fetch tool" },
13
- ],
14
- factory: web,
15
- },
6
+ { name: "web", factory: web },
16
7
  ];
17
- /** The catalog a surface may show: no Pi types, nothing it cannot render.
18
- * Half of one list; src/tools.ts has the other. */
19
- export const bundledInfo = (enabled) => BUNDLED.map(({ name, summary, tools }) => ({
20
- source: "bundled",
21
- kind: "extension",
22
- name,
23
- summary,
24
- adds: tools,
25
- enabled: enabled.includes(name),
26
- }));
27
8
  /** The enabled ones as Pi inline extensions; unknown names are not ours. */
28
9
  export const inlineExtensions = (enabled) => BUNDLED.filter((ext) => enabled.includes(ext.name))
29
10
  .map(({ name, factory }) => ({ name, factory }));
package/dist/main.js CHANGED
@@ -9,6 +9,7 @@ import { ConfigSync } from "./config-sync.js";
9
9
  import { configSyncTask } from "./config-sync-task.js";
10
10
  import { CredentialStore } from "./agent/credentials.js";
11
11
  import { IndexedListing } from "./agent/listing.js";
12
+ import { PiPackageStore } from "./agent/packages.js";
12
13
  import { PiAgentFactory } from "./agent/pi.js";
13
14
  import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
14
15
  import { ChannelStore } from "./channels/config.js";
@@ -24,7 +25,7 @@ import { EventHub } from "./core/hub.js";
24
25
  import { splitSpeaker } from "./core/identity.js";
25
26
  import { pierDb } from "./db.js";
26
27
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
27
- import { bundledInfo } from "./extensions/index.js";
28
+ import { BUNDLED } from "./extensions/index.js";
28
29
  import { surfacePrompt } from "./core/reply.js";
29
30
  import { Router } from "./core/router.js";
30
31
  import { acquireInstanceLock } from "./lock.js";
@@ -39,7 +40,7 @@ import { toolsTask } from "./tools-task.js";
39
40
  import { Secrets } from "./secrets.js";
40
41
  import { startUpdate, unitPath, updaterProblem } from "./service.js";
41
42
  import { SettingsStore } from "./settings.js";
42
- import { startAutoUpdate, UpdateCheck } from "./update.js";
43
+ import { currentVersion, startAutoUpdate, UpdateCheck } from "./update.js";
43
44
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
44
45
  import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
45
46
  import { PushStore, registerPushRoutes } from "./web/push.js";
@@ -84,11 +85,14 @@ let channelStore;
84
85
  const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
85
86
  let readyForConfigReload = false;
86
87
  const piConfig = new PiConfigStore();
88
+ // Before anything reads settings.json: a first boot gets Pier's seed file.
89
+ await piConfig.seedSettings();
87
90
  const configSync = new ConfigSync({
88
91
  db, settings, config: piConfig, normalizeAgent: normalizeAgentSnapshot,
89
92
  reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
90
93
  });
91
- const factory = new PiAgentFactory([
94
+ const skillsDir = fileURLToPath(new URL("../skills", import.meta.url));
95
+ const agentTools = [
92
96
  taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
93
97
  slackToolSpec((params, callerSessionId) => handleSlackTool({
94
98
  store: channelStore,
@@ -108,12 +112,13 @@ const factory = new PiAgentFactory([
108
112
  },
109
113
  log: (m) => logger("slack.tool").warn(m),
110
114
  }, params, callerSessionId), () => slackToolAvailable(channelStore)),
111
- ],
115
+ ];
116
+ const factory = new PiAgentFactory(agentTools,
112
117
  // Getters, read per session open: a Console change reaches the next session
113
118
  // without a restart.
114
119
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
115
120
  // Documents Pier's own tools, so it loads only inside a Pier session.
116
- [fileURLToPath(new URL("../skills", import.meta.url))], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get().extensions, () => settings.get().titleModel,
121
+ [skillsDir], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get(), () => settings.get().titleModel,
117
122
  // Transcripts carry the speaker header core wrote for the model.
118
123
  new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
119
124
  const hub = new EventHub();
@@ -137,6 +142,9 @@ const toolsUpdate = toolsTask(tasks);
137
142
  const reconciled = await toolsUpdate.reconcile();
138
143
  if ("problem" in reconciled)
139
144
  log.error(`tools cannot be managed: ${reconciled.problem}`);
145
+ const packages = new PiPackageStore(piConfig, { version: currentVersion(), settings, tools: agentTools }, [skillsDir]);
146
+ // At boot, not lazily: the answer waits for the next Console open (update.ts).
147
+ packages.watchUpdates();
140
148
  channelStore = new ChannelStore(db, secrets);
141
149
  const control = createControl({ router, factory, conversations, store: channelStore });
142
150
  const channels = new ChannelRuntime(channelStore, router, control);
@@ -280,23 +288,20 @@ app.route("/", createServer({
280
288
  hub,
281
289
  sessions: sessionState,
282
290
  config: piConfig,
291
+ packages,
283
292
  providers: factory,
284
293
  settings,
285
- // Assembled here so web/ gets names and summaries, not a module that
286
- // imports the Pi SDK; extensions and binaries are one kind of switch.
294
+ // Assembled here: the catalog spawns ubix, which web/ may not.
287
295
  catalog: async () => {
288
- const { extensions, tools, customTools } = settings.get();
289
- return {
290
- entries: [...bundledInfo(extensions), ...await managedTools.status(tools, customTools)],
291
- toolsTaskId: toolsUpdate.id(),
292
- };
296
+ const { tools, customTools } = settings.get();
297
+ return { entries: await managedTools.status(tools, customTools), toolsTaskId: toolsUpdate.id() };
293
298
  },
294
299
  // A switch is validated against what this Pier *can* switch, never against
295
300
  // a catalog whose custom half the request may be rewriting.
296
- names: { extensions: bundledInfo([]).map((entry) => entry.name), tools: MANAGED.map((tool) => tool.name) },
301
+ names: MANAGED.map((tool) => tool.name),
297
302
  onToolsChanged: toolsUpdate.changed,
298
303
  validateCustomTools: (raw) => {
299
- const validated = normalizeCustomTools(raw, bundledInfo([]).map((entry) => entry.name));
304
+ const validated = normalizeCustomTools(raw, BUNDLED.map((ext) => ext.name));
300
305
  return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
301
306
  },
302
307
  secrets,
package/dist/settings.js CHANGED
@@ -99,6 +99,7 @@ export class SettingsStore {
99
99
  ...(titleModel ? { titleModel } : {}),
100
100
  autoUpdate: this.#value("autoUpdate") === "1",
101
101
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
102
+ skillsOff: this.#json("skillsOff", normalizeNames, "a list of names") ?? [],
102
103
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
103
104
  // `"drop"`: a row the bundled catalog has since taken is redundant, not malformed.
104
105
  customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, toml}") ?? [],
@@ -144,6 +145,10 @@ export class SettingsStore {
144
145
  this.#set("extensions", JSON.stringify(names));
145
146
  return this.get();
146
147
  }
148
+ setSkillsOff(names) {
149
+ this.#set("skillsOff", JSON.stringify(names));
150
+ return this.get();
151
+ }
147
152
  setTools(names) {
148
153
  this.#set("tools", JSON.stringify(names));
149
154
  return this.get();
package/dist/tools.js CHANGED
@@ -13,37 +13,34 @@ const log = logger("tools");
13
13
  const UBIX_LATEST = "https://api.github.com/repos/timqi/ubix/releases/latest";
14
14
  export const MANAGED = [
15
15
  {
16
- kind: "extension",
17
16
  name: "rtk",
18
- toml: `spec = "github:rtk-ai/rtk"`,
19
- summary: "Compresses long bash output before it reaches the model. Ships as a " +
20
- "command, and installs its own Pi extension into Pier's agent dir — " +
21
- "refreshed on every update.",
22
- // Write-if-changed inside rtk: re-running after an upgrade is the extension-update path.
23
- provision: ["init", "-g", "--agent", "pi"],
24
- deprovision: ["init", "--uninstall", "--agent", "pi", "--global"],
17
+ // The hooks register and unregister its Pi extension; `--auto-patch` keeps
18
+ // `rtk init` non-interactive and overwrites a stale copy, which is right
19
+ // because nothing else writes Pier's agent dir.
20
+ toml: `spec = "github:rtk-ai/rtk"\n` +
21
+ `post_install = ["rtk", "init", "-g", "--agent", "pi", "--auto-patch"]\n` +
22
+ `pre_remove = ["rtk", "init", "--uninstall", "--agent", "pi", "--global", "--auto-patch"]`,
23
+ summary: "Compresses long bash output before it reaches the model. Its install hook " +
24
+ "writes its own Pi extension (extensions/rtk.ts, listed under the local " +
25
+ "package) — refreshed on every update.",
25
26
  },
26
27
  {
27
- kind: "tool",
28
28
  name: "rg",
29
29
  // `exe`: ubi looks for files named after the project, and ripgrep ships `rg`.
30
30
  toml: `spec = "github:BurntSushi/ripgrep"\nexe = "rg"`,
31
31
  summary: "ripgrep: searches a tree by content, fast enough to be the default.",
32
32
  },
33
33
  {
34
- kind: "tool",
35
34
  name: "fd",
36
35
  toml: `spec = "github:sharkdp/fd"`,
37
36
  summary: "Finds files by name, respecting .gitignore — what `find` should feel like.",
38
37
  },
39
38
  {
40
- kind: "tool",
41
39
  name: "wt",
42
40
  toml: `spec = "github:max-sixty/worktrunk"\nexe = "wt"`,
43
41
  summary: "worktrunk: git worktrees as one command — branch, switch, merge, clean up.",
44
42
  },
45
43
  {
46
- kind: "tool",
47
44
  name: "jq",
48
45
  // No `exe`: jq publishes bare per-platform binaries, which ubi installs
49
46
  // under the tool's own name.
@@ -339,17 +336,14 @@ export function ubixConfigToml(tools, installDir) {
339
336
  lines.push("", `[tools.${tool.name}]`, tool.toml.trim());
340
337
  return `${lines.join("\n")}\n`;
341
338
  }
342
- /** clap's unknown-argument message, and ubix's own refusal on a command that
343
- * takes no JSON. Nothing else counts as too old. */
344
- const refusesJson = (stderr) => /unexpected argument\s+'?--json|unrecognized (?:option|argument)\s+'?--json|`--json` is not supported/i
345
- .test(stderr);
346
- /** Pier put that binary in `bin/`, so `sync` re-bootstraps rather than reports. */
347
- class UbixTooOld extends Error {
348
- }
339
+ /** The first ubix that runs `post_install` / `pre_remove`. An older one parses
340
+ * the keys as unknown and ignores them, so rtk would install without its
341
+ * extension and nothing would say so. Release tags are `vYYYYMMDD-<sha>`. */
342
+ const UBIX_MIN = "v20260910-61f07ae";
349
343
  /** A name in neither list is not an error: a row a future release drops must
350
344
  * not stop the sync of everything else. */
351
345
  function rows(custom) {
352
- return [...MANAGED, ...custom.map((tool) => ({ kind: "tool", summary: "", custom: true, ...tool }))];
346
+ return [...MANAGED, ...custom.map((tool) => ({ summary: "", custom: true, ...tool }))];
353
347
  }
354
348
  export function ubixAsset(tag, platform, arch) {
355
349
  const os = platform === "linux" || platform === "darwin" ? platform : null;
@@ -465,26 +459,11 @@ export class ManagedTools {
465
459
  return { entries: [], failed: false, summary: "no tools switched on" };
466
460
  }
467
461
  fence();
468
- const ubix = await this.bootstrapUbix();
469
462
  const env = this.#env();
463
+ const ubix = await this.#current(await this.bootstrapUbix(), env);
470
464
  const entries = [];
471
- // ubix cannot know that rtk must uninstall its own Pi extension *before*
472
- // its binary goes; tools leaving the set undo their footprint first.
473
- const kept = [];
474
- for (const state of await this.#listing(ubix, env)) {
475
- const leaving = all.find((tool) => tool.name === state.name);
476
- if (!leaving?.deprovision || wanted.some((tool) => tool.name === state.name))
477
- continue;
478
- fence(); // a tool's own uninstall is a change to the machine
479
- const error = await this.#provision(env, leaving, leaving.deprovision);
480
- if (error) {
481
- // Removing it now would orphan what deprovision failed to remove.
482
- kept.push(leaving);
483
- entries.push({ name: leaving.name, action: "kept", version: null, error });
484
- }
485
- }
486
465
  fence();
487
- this.#writeConfig([...wanted, ...kept]);
466
+ this.#writeConfig(wanted);
488
467
  // `--prune` removes what the config no longer declares; `--wait` lets a
489
468
  // hand-typed `pier tools sync` converge behind the managed run.
490
469
  fence();
@@ -492,25 +471,19 @@ export class ManagedTools {
492
471
  for (const state of states) {
493
472
  if (wanted.some((tool) => tool.name === state.name))
494
473
  continue;
495
- if (entries.some((entry) => entry.name === state.name))
496
- continue;
497
- // A failure to remove is as much a failure as one to install.
474
+ // A failure to remove (a `pre_remove` hook included) is as much a failure as one to install.
498
475
  entries.push({ name: state.name, action: state.action ?? "removed", version: null, error: state.error });
499
476
  }
500
477
  for (const tool of wanted) {
501
478
  const state = states.find((s) => s.name === tool.name);
502
- const entry = {
479
+ entries.push({
503
480
  name: tool.name,
504
481
  action: state?.action ?? "missing",
505
482
  version: state?.to ?? state?.version ?? null,
506
- // A tool ubix never mentioned is not a tool that is fine.
483
+ // A tool ubix never mentioned is not a tool that is fine; a failed
484
+ // `post_install` arrives here as `error` beside an `installed` action.
507
485
  error: state?.error ?? (state ? null : "ubix reported nothing about it"),
508
- };
509
- if (!entry.error && tool.provision) {
510
- fence();
511
- entry.error = await this.#provision(env, tool, tool.provision);
512
- }
513
- entries.push(entry);
486
+ });
514
487
  }
515
488
  const failed = entries.some((entry) => entry.error !== null);
516
489
  return { entries, failed, summary: summarize(entries) };
@@ -519,8 +492,6 @@ export class ManagedTools {
519
492
  * version is unknown (§5). */
520
493
  async status(enabled, custom = []) {
521
494
  const base = rows(custom).map((tool) => ({
522
- source: "binary",
523
- kind: tool.kind,
524
495
  name: tool.name,
525
496
  summary: tool.summary,
526
497
  enabled: enabled.includes(tool.name),
@@ -577,12 +548,20 @@ export class ManagedTools {
577
548
  return states;
578
549
  }
579
550
  /** `UBIX_CONFIG_DIR` / `UBIX_DATA_DIR` name the directories directly — not
580
- * XDG parents, which every child ubix spawns (uv, fnm, cargo) would read too. */
551
+ * XDG parents, which every child ubix spawns (uv, fnm, cargo) would read too.
552
+ * Hooks inherit this env: `pier tools sync` typed in a shell has no main.ts
553
+ * parent exporting PI_CODING_AGENT_DIR, and rtk would then write its
554
+ * extension into ~/.pi. */
581
555
  #env() {
556
+ const agentDir = resolveAgentDir(process.env);
557
+ if (process.env.PI_CODING_AGENT_DIR !== agentDir) {
558
+ log.info(`PI_CODING_AGENT_DIR was ${process.env.PI_CODING_AGENT_DIR ?? "unset"} — ubix gets ${agentDir}`);
559
+ }
582
560
  const env = {
583
561
  ...process.env,
584
562
  UBIX_CONFIG_DIR: this.#configDir,
585
563
  UBIX_DATA_DIR: join(this.#root, "state"),
564
+ PI_CODING_AGENT_DIR: agentDir,
586
565
  };
587
566
  prependPath(env, this.bin);
588
567
  return env;
@@ -599,8 +578,9 @@ export class ManagedTools {
599
578
  renameSync(`${path}.writing`, path);
600
579
  }
601
580
  /** A non-zero exit still parses: under `--json` a failed tool is in the
602
- * document as `action: "failed"`, and the run exits non-zero. The two must
603
- * agree — an exit code with no failed entry is a failure this file cannot
581
+ * document as `action: "failed"` — or as `installed`/`upgraded` with an
582
+ * `error`, when its `post_install` hook failed — and the run exits non-zero.
583
+ * The two must agree — an exit code with no failed entry is a failure this file cannot
604
584
  * attribute, and passing it on as clean is the one thing it may never do. */
605
585
  async #states(ubix, env, args) {
606
586
  const result = await this.#exec(ubix, args, env);
@@ -610,43 +590,24 @@ export class ManagedTools {
610
590
  }
611
591
  catch (err) {
612
592
  const failure = failedRun(`ubix ${args.join(" ")}`, result);
613
- // Only the flag being unknown means "too old"; re-bootstrapping over a
614
- // config error would fix nothing and say something false.
615
- if (result.code !== 0 && refusesJson(result.stderr))
616
- throw new UbixTooOld(failure);
617
593
  throw new Error(result.code === 0 ? String(err) : `${failure} (${String(err)})`);
618
594
  }
619
- if (result.code !== 0 && !states.some((state) => state.action === "failed")) {
595
+ if (result.code !== 0 && !states.some((state) => state.error !== null)) {
620
596
  throw new Error(`${failedRun(`ubix ${args.join(" ")}`, result)} — and its report names no failure`);
621
597
  }
622
598
  return states;
623
599
  }
624
- /** The one place a too-old ubix is repaired rather than reported. */
625
- async #listing(ubix, env) {
626
- try {
627
- return await this.#states(ubix, env, ["list", "--json"]);
628
- }
629
- catch (err) {
630
- if (!(err instanceof UbixTooOld))
631
- throw err;
632
- log.warn(`the ubix in ${this.bin} is too old for --json — replacing it`);
633
- return this.#states(await this.bootstrapUbix(true), env, ["list", "--json"]);
634
- }
635
- }
636
- /** Returns the failure text, or null. */
637
- async #provision(env, tool, args) {
638
- const exe = join(this.bin, tool.name);
639
- if (!existsSync(exe))
640
- return `${tool.name} is not in ${this.bin} — ${args.join(" ")} was not run`;
641
- // `pier tools sync` typed in a shell has no main.ts parent exporting
642
- // PI_CODING_AGENT_DIR; rtk would then write its extension into ~/.pi.
643
- const agentDir = resolveAgentDir(env);
644
- if (env.PI_CODING_AGENT_DIR !== agentDir) {
645
- log.info(`PI_CODING_AGENT_DIR was ${env.PI_CODING_AGENT_DIR ?? "unset"} — ${tool.name} gets ${agentDir}`);
646
- }
647
- mkdirSync(join(agentDir, "extensions"), { recursive: true });
648
- const result = await this.#exec(exe, args, { ...env, PI_CODING_AGENT_DIR: agentDir });
649
- return result.code === 0 ? null : failedRun(`${tool.name} ${args.join(" ")}`, result);
600
+ /** The one place a too-old ubix is repaired rather than reported: Pier put
601
+ * that binary in `bin/`, and one below UBIX_MIN would run the config and
602
+ * skip its hooks without a word. */
603
+ async #current(ubix, env) {
604
+ const result = await this.#exec(ubix, ["--version"], env);
605
+ const date = /\bv(\d{8})-/.exec(result.stdout)?.[1];
606
+ if (result.code === 0 && date && date >= UBIX_MIN.slice(1, 9))
607
+ return ubix;
608
+ const found = result.code === 0 ? result.stdout.trim() || "(no version)" : failedRun("ubix --version", result);
609
+ log.warn(`the ubix in ${this.bin} is ${found} and Pier needs ${UBIX_MIN} or newer — replacing it`);
610
+ return this.bootstrapUbix(true);
650
611
  }
651
612
  async #getText(url) {
652
613
  return new TextDecoder().decode(await this.#getBytes(url));
@@ -662,7 +623,7 @@ export class ManagedTools {
662
623
  return new Uint8Array(await res.arrayBuffer());
663
624
  }
664
625
  }
665
- const withBinary = (entry, patch) => entry.source === "binary" ? { ...entry, binary: { ...entry.binary, ...patch } } : entry;
626
+ const withBinary = (entry, patch) => ({ ...entry, binary: { ...entry.binary, ...patch } });
666
627
  /** Never empty: an exit code with no words is not a report. */
667
628
  const failedRun = (what, result) => `${what} exited ${String(result.code)}: ${result.stderr.trim().slice(0, 300) || "(no output)"}`;
668
629
  /** `<sha256> <bare filename>` lines, as `sha256sum` writes them. */
@@ -1,5 +1,7 @@
1
1
  // The agent files a scope is configured by, through the ConfigStore. Not via
2
2
  // web/fs.ts: a scope is "global" or a cwd Pi already knows, never a browser path.
3
+ import { isThinkingLevel } from "../core/types.js";
4
+ import { normalizeModelRef } from "../settings.js";
3
5
  import { guarded } from "./route.js";
4
6
  export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
5
7
  // Only cwds Pi already knows are accepted — never an arbitrary path.
@@ -18,7 +20,6 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
18
20
  // Where this scope's files live on disk — the UI labels "Global" with it.
19
21
  dir: scope.kind === "global" ? config.globalDir : scope.cwd,
20
22
  files: await config.listFiles(scope),
21
- resources: await config.listResources(scope),
22
23
  });
23
24
  });
24
25
  guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
@@ -41,17 +42,30 @@ export function registerConfigRoutes(app, { factory, config, onConfigWritten })
41
42
  onConfigWritten?.();
42
43
  return c.json({ ok: true, content: await config.readFile(scope, name) });
43
44
  });
44
- // Resource names may contain slashes — query params, not path params.
45
- guarded(app, "GET", "/api/config/resource", 400, async (c) => {
45
+ // settings.json is read-only as a file; its two deployment keys go through here.
46
+ guarded(app, "GET", "/api/config/defaults", 400, async (c) => {
46
47
  c.header("cache-control", "no-store");
47
- const scope = await parseScope(c.req.query("scope"));
48
- if (!scope)
49
- return c.json({ error: "unknown scope" }, 400);
50
- const kind = c.req.query("kind");
51
- const name = c.req.query("name");
52
- if ((kind !== "extensions" && kind !== "skills") || !name) {
53
- return c.json({ error: "kind and name required" }, 400);
48
+ return c.json(await config.readDefaults());
49
+ });
50
+ guarded(app, "PUT", "/api/config/defaults", 400, async (c) => {
51
+ const defaults = parseDefaults(await c.req.json().catch(() => null));
52
+ if (!defaults) {
53
+ return c.json({ error: "defaultModel must be {provider, id} or null; defaultThinkingLevel a reasoning level or null" }, 400);
54
54
  }
55
- return c.json({ content: await config.readResource(scope, kind, name) });
55
+ await config.writeDefaults(defaults);
56
+ onConfigWritten?.();
57
+ return c.json(await config.readDefaults());
56
58
  });
57
59
  }
60
+ /** Both fields, each stated: null is "Pi's own default", absent is a malformed body. */
61
+ function parseDefaults(body) {
62
+ if (typeof body !== "object" || body === null)
63
+ return null;
64
+ const { defaultModel, defaultThinkingLevel } = body;
65
+ const model = defaultModel === null ? null : normalizeModelRef(defaultModel);
66
+ if (model === null && defaultModel !== null)
67
+ return null;
68
+ if (defaultThinkingLevel !== null && !isThinkingLevel(defaultThinkingLevel))
69
+ return null;
70
+ return { defaultModel: model, defaultThinkingLevel };
71
+ }
@@ -11,7 +11,7 @@ const REF_RE = /^[^-\s][^\s]*$/;
11
11
  /** Output capped: a diff is display data, not an archive. */
12
12
  const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
13
13
  export function registerExplorerRoutes(app) {
14
- // Not a repo → { branch: null }, which the UI renders as "no git".
14
+ // Not a repo → { branch: null }: the Files view draws no compare, no filter.
15
15
  guarded(app, "GET", "/api/explorer/git", 404, async (c) => {
16
16
  c.header("cache-control", "no-store");
17
17
  const root = await scoped(c.req.query("root"));
@@ -71,9 +71,11 @@ export function registerExplorerRoutes(app) {
71
71
  const file = c.req.query("file");
72
72
  if (file === undefined) {
73
73
  // name-status carries the letter, numstat the +/- counts; joined by path.
74
+ // --relative: the root may be a directory inside the checkout (Browse
75
+ // files on a skill), and the tree reads every path from that root.
74
76
  const [nameStatus, numstat] = await Promise.all([
75
- git(root, "diff", "--name-status", ...range, "--"),
76
- git(root, "diff", "--numstat", ...range, "--"),
77
+ git(root, "diff", "--relative", "--name-status", ...range, "--"),
78
+ git(root, "diff", "--relative", "--numstat", ...range, "--"),
77
79
  ]);
78
80
  // numstat spells a rename "a/{old => new}.ts" — reduce it to the new path.
79
81
  const newPath = (p) => p.replace(/\{([^{}]*) => ([^{}]*)\}/g, "$2").replace(/^(.*) => (.*)$/, "$2");