@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/README.md CHANGED
@@ -2,17 +2,16 @@
2
2
 
3
3
  A self-hosted workspace for coding agents. Pier puts a web workbench and your
4
4
  IM channels in front of [Pi](https://github.com/earendil-works/pi) sessions:
5
- you talk to the same agent from a browser, from Slack, Telegram or Lark, steer a
6
- running turn, schedule tasks, watch what every session is doing, and publish a
7
- static page when something is worth showing.
5
+ talk to the same agent from a browser, Slack, Telegram or Lark, steer a running
6
+ turn, schedule tasks, watch every session, and publish a static page when
7
+ something is worth showing.
8
8
 
9
9
  One instance, one account, your own machine. The agent runs shell commands in
10
- directories you name, so Pier is meant for a machine you own and a boundary you
11
- control — not for a shared host.
10
+ directories you name — meant for a machine you own, not a shared host.
12
11
 
13
- **Status: pre-release.** The version is `0.0.x` and the database schema is
14
- versioned from `0.0.1` on — earlier databases are not migrated. Read
15
- `docs/deploy.md` before putting it anywhere reachable.
12
+ **Status: pre-release.** The version is `0.0.x`; the database schema is
13
+ versioned from `0.0.1` on. Read `docs/deploy.md` before putting it anywhere
14
+ reachable.
16
15
 
17
16
  ## Requirements
18
17
 
@@ -35,75 +34,47 @@ It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
35
34
  session transcripts (`~/.pier/pi`, unless `PI_CODING_AGENT_DIR` says
36
35
  otherwise).
37
36
 
38
- **The first start generates a password and prints it once.** Every HTTP surface
39
- is behind it — there is no default password and no unclaimed window. Lost it?
40
- `sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart; a new one is printed.
41
-
42
- Open `http://localhost:3141`, sign in, then:
43
-
44
- - **Console → Settings** — everything the instance is configured with, one
45
- tab per topic: Models (API-key/OAuth logins, and the menu of favored models
46
- agents are advised with), Agent (Pi configuration, skills, extensions),
47
- Channels (Slack Socket-Mode or Telegram bot tokens; chats are discovered
48
- when the bot first sees traffic, gated by the mention/bind rules you set),
49
- plus the public URL, password and master key
50
- - **New session** — pick a directory; that is where the agent's shell runs
37
+ **The first start generates a password and prints it once.** Lost it?
38
+ `sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart. Open
39
+ `http://localhost:3141`, sign in; **Console → Settings** configures Models,
40
+ Agent, Channels, the public URL, password and master key; **New session** picks
41
+ the directory the agent's shell runs in.
51
42
 
52
43
  ## Configure Pi
53
44
 
54
- Pier gives Pi a dedicated agent directory instead of changing your normal Pi
55
- installation. By default it is `$PIER_HOME/pi` (`~/.pier/pi`). Set
56
- `PI_CODING_AGENT_DIR` before starting Pier to use another directory, including
57
- an existing Pi setup:
45
+ Pier gives Pi its own agent directory, `$PIER_HOME/pi` by default; set
46
+ `PI_CODING_AGENT_DIR` to use another, including an existing Pi setup:
58
47
 
59
48
  ```sh
60
49
  PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier serve
61
50
  ```
62
51
 
63
- Pier exports that variable for the SDK, so anything it starts inherits it — an
64
- agent's shell included. A second Pier launched from there with its
65
- own `PIER_HOME` derives its own agent directory rather than adopting the first
66
- one's; setting `PI_CODING_AGENT_DIR` again on that command line still wins.
67
-
68
- Console → Settings is the normal setup path:
69
-
70
- - **Models** configures built-in or custom endpoints and API-key/OAuth login,
71
- then pins the few models this deployment favors. Stored credentials are
72
- sealed in Pier's SQLite database; they are not written back to `models.json`.
73
- **Test** sends one real request on a model you pick and shows both halves of
74
- it — the body as the provider received it, and what came back — so a wrong
75
- base URL, a revoked key, a gateway rewriting the request or a model the
76
- endpoint never had says so here instead of in a session.
77
- - **Agent** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
78
- `models.json` structure in the Pi agent directory — globally, or per project
79
- scope, where it also shows that project's `.pi/skills` and `.pi/extensions`
80
- resources. Changes apply when a session next opens; saving here recycles the
81
- idle ones for you, and **Settings → Instance → Reload** does it for files
82
- something else changed — an agent, or an editor on the box.
83
- The same tab lists the extensions Pier ships with, under Global — they live
84
- inside the package, so there is nothing to install and an update never
85
- touches your own `extensions` directory, and if an extension of yours
86
- already registers the same tool, Pier's copy stands down. `web` is the one
87
- shipped today: the public web through the provider's own hosted web tools —
88
- `web_search` on an authenticated Anthropic or OpenAI model, `web_fetch` on an
89
- Anthropic one (OpenAI hosts no fetch tool), and no other key or service. The
90
- page says which tools a switch adds and what each needs before you flip it.
91
- Below it, the same tab switches on the command-line tools Pier manages —
92
- `rtk`, `rg`, `fd`, `wt`, `jq`, or a tool of your own written as a
93
- [ubix](https://github.com/timqi/ubix) block. A switch installs the binary
94
- into `~/.pier/tools/bin`, which is first on the PATH every session and task
95
- inherits, and a task you can read keeps them current.
96
-
97
- On first credential access, Pier imports an existing `auth.json` into its sealed
98
- store and renames the source to `auth.json.imported`. Literal provider keys left
99
- in `models.json` are moved the same way, with the original retained as
100
- `models.json.imported`. Use Providers for new secrets; the advanced editor will
101
- not accept plaintext keys or header values.
102
-
103
- Provider environment variables supported by Pi are inherited from the Pier
104
- process. A service installed with `pier service install` does not inherit your
105
- interactive shell, so put non-secret Pi environment settings in a systemd unit
106
- override; for API keys, prefer the sealed Providers UI.
52
+ Pier exports that variable, so anything it starts inherits it. A second Pier
53
+ launched from there with its own `PIER_HOME` derives its own agent directory
54
+ unless `PI_CODING_AGENT_DIR` is set again on that command line.
55
+
56
+ Console → Settings:
57
+
58
+ - **Models** — endpoints, API-key/OAuth login, pinned models. Credentials are
59
+ sealed in Pier's SQLite database, never written back to `models.json`.
60
+ **Test** sends one real request and shows the body as sent and the reply.
61
+ - **Agent** — edits `SYSTEM.md`, `AGENTS.md`, `settings.json` and the
62
+ `models.json` structure, globally or per project scope (with that project's
63
+ `.pi/skills` and `.pi/extensions` listed). Changes apply when a session next
64
+ opens; saving recycles idle sessions; **Settings → Instance → Reload** does
65
+ the same for files changed elsewhere. Also here: the bundled extensions
66
+ (`web`: `web_search` on Anthropic or OpenAI, `web_fetch` on Anthropic; a
67
+ copy of yours registering the same tool makes Pier's stand down) and the
68
+ managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
69
+ [ubix](https://github.com/timqi/ubix) block), installed into
70
+ `~/.pier/tools/bin`, first on every session's PATH.
71
+
72
+ On first credential access an existing `auth.json` is imported into the sealed
73
+ store and renamed `auth.json.imported`; literal keys in `models.json` likewise
74
+ (`models.json.imported`). The editor rejects plaintext keys or header values.
75
+ Pi's provider environment variables are inherited from the Pier process; a
76
+ systemd service does not inherit your shell, so put non-secret settings in a
77
+ unit override and API keys in the Providers UI.
107
78
 
108
79
  ## Run it as a service
109
80
 
@@ -118,32 +89,11 @@ pier update # latest release, then hard-stop/restart the service
118
89
  pier tools sync # install/update the managed CLI tools by hand
119
90
  ```
120
91
 
121
- `pier restart` refuses new work, waits up to five minutes for active turns and
122
- Task runs, then restarts; if its deadline aborts an IM turn, the next process
123
- tells that conversation. `pier reload` stays in-process: adapters re-read their
124
- configuration and idle, unwatched sessions reopen on their next message.
125
- Streaming or watched sessions keep running and pick changes up after eviction.
126
- Both commands target the installed systemd service. `pier update` is still a
127
- hard stop because its separate updater replaces the installed code; let active
128
- work finish before starting it.
129
-
130
- Linux only, because it is systemd. It writes `~/.config/systemd/user/pier.service`
131
- with the absolute path of the node you installed with (systemd's PATH would not
132
- find a version-managed one), a memory drop-in it never rewrites afterwards, and
133
- turns on linger so scheduled tasks survive your logout. Install also records the
134
- exact npm executable in a separate updater unit. Re-run `pier service install
135
- --force` after changing the service settings or its Node/npm installation; this
136
- rewrites both units and restarts Pier. On macOS run `pier serve` in a terminal,
137
- or under whatever supervisor you already use — `pier` on its own only prints
138
- the usage.
139
-
140
- `docs/deploy.md` is the same thing written out by hand, plus what the memory
141
- limits mean, how updates work (and why the updater is a second unit), how to
142
- read the first-run password out of the journal, and what to back up.
143
-
144
- Exposing it needs two things: a reverse proxy or tunnel that terminates TLS
145
- (Pier binds the loopback and expects `X-Forwarded-For`/`-Proto`), and the
146
- understanding that whoever gets past the password gets a shell.
92
+ Linux only (systemd); on macOS run `pier serve` under your own supervisor.
93
+ `docs/deploy.md` is the runbook: units, memory limits, updates and rollback,
94
+ the first-run password, remote access, backups. Expose it only behind a
95
+ TLS-terminating proxy or tunnel (`X-Forwarded-For`/`-Proto`) — whoever gets
96
+ past the password gets a shell.
147
97
 
148
98
  ## Develop
149
99
 
@@ -157,40 +107,23 @@ npm run lint # oxlint
157
107
  npm test # vitest
158
108
  ```
159
109
 
160
- - `AGENTS.md` — the principles this codebase is held to, and the budgets that
161
- say when a change is too big. Read it before writing code here.
110
+ - `AGENTS.md` — the principles and budgets this codebase is held to
162
111
  - `docs/architecture.md` — the seams, the areas, and what is deliberately absent
163
- - `docs/design/` — one document per subsystem, written before it was built
112
+ - `docs/design/` — one document per subsystem
164
113
 
165
114
  ## Releases
166
115
 
167
- Pier asks `registry.npmjs.org` at boot and every 30 minutes, and the version
168
- beside the title turns into `v0.0.1 → 0.0.2` when a release is out. Clicking it
169
- opens the panel: the source link, **Update now**, and **Update automatically**.
170
-
171
- Nothing here installs anything itself — the work is handed to the second
172
- systemd unit written at install time, because an npm running as a child of the
173
- process being restarted would be killed by that restart. Off systemd there is
174
- nothing to hand it to, so the panel says `pier update` instead.
175
-
176
- `pier update` typed in a terminal hard-stops the service. The Console and the
177
- automatic path both **drain first** — new work refused, running turns given
178
- time to finish, whatever the deadline still had to cut off written to the chat
179
- it belonged to — and only then hand over. The automatic path additionally waits
180
- for an idle instance: no turn streaming, no task run in flight.
181
-
182
- Either way the updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
183
- before npm touches the package — named for the release being replaced, which is
184
- the one to reinstall beside it; the three newest are kept. Then it updates the
185
- npm installation recorded when the service was installed, and starts Pier again.
186
-
187
- `main` is the only development line. `just release [patch|minor|major]` runs the
188
- checks, writes the tag and pushes it; the tag builds and publishes to npm and as
189
- a GitHub Release. The version in the web footer is the one from `package.json`
190
- — so the number on screen always names a commit.
191
- Schema upgrades are one-way: a database migrated by a newer Pier is refused by
192
- an older one. The release backup above is the way back; `docs/deploy.md` has the
193
- restore procedure and the additional snapshots taken before schema migrations.
116
+ Pier asks `registry.npmjs.org` at boot and every 30 minutes; the footer version
117
+ becomes `v0.0.1 → 0.0.2` when a release is out and opens a panel: source link,
118
+ **Update now**, **Update automatically** (idle instance only). Both drain first
119
+ and hand the install to the updater unit; off systemd the panel says `pier
120
+ update`. The updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
121
+ (the release being replaced; three kept) first. Schema upgrades are one-way;
122
+ `docs/deploy.md` has the rollback.
123
+
124
+ `main` is the only development line. `just release [patch|minor|major]` checks,
125
+ tags and pushes; the tag builds and publishes to npm and a GitHub Release. The
126
+ footer version is `package.json`'s.
194
127
 
195
128
  ## License
196
129
 
@@ -10,16 +10,26 @@ import { mergeSnapshotProviders, normalizeAgentSnapshot, snapshotProviders } fro
10
10
  const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
11
11
  const PROJECT_FILES = ["AGENTS.md"];
12
12
  // settings.json is on the list for two fields: the default model and its
13
- // reasoning effort. Everything else in it is machine-local and survives an
14
- // import untouched.
13
+ // reasoning effort. Everything else in it is machine-local.
15
14
  const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
16
15
  const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
17
- /** Pier owns the Pi runtime dir: config lives in the syncable `~/.pier/pi`
18
- * repo, not `~/.pi`. main.ts exports this as PI_CODING_AGENT_DIR so the SDK's
19
- * own path resolution (auth.json, sessions, bin) lands in the same place. */
16
+ /** Pier owns the Pi runtime dir; main.ts exports it as PI_CODING_AGENT_DIR. */
20
17
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
21
18
  /** Stable mask: mapped back by field, without exposing key fragments. */
22
19
  const maskKey = (_key) => "••••••••";
20
+ /** Pi reads the two highest levels off the model's own map. The rest of a
21
+ * hand-written map (an `off: null`) is left alone: the Console does not offer it. */
22
+ const effortOf = (map) => typeof map?.max === "string" ? "max" : typeof map?.xhigh === "string" ? "xhigh" : undefined;
23
+ function withEffort(map, effort) {
24
+ const next = { ...map };
25
+ delete next.xhigh;
26
+ delete next.max;
27
+ if (effort === "xhigh" || effort === "max")
28
+ next.xhigh = "xhigh";
29
+ if (effort === "max")
30
+ next.max = "max";
31
+ return Object.keys(next).length ? next : undefined;
32
+ }
23
33
  const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
24
34
  const readNullable = async (path) => {
25
35
  try {
@@ -131,9 +141,11 @@ export class PiConfigStore {
131
141
  if (typeof model !== "object" || model === null || typeof model.id !== "string") {
132
142
  return [];
133
143
  }
144
+ const effort = effortOf(asRecord(model.thinkingLevelMap));
134
145
  return [{
135
146
  id: model.id,
136
147
  reasoning: model.reasoning === true,
148
+ ...(effort ? { effort } : {}),
137
149
  }];
138
150
  })
139
151
  : undefined;
@@ -180,6 +192,11 @@ export class PiConfigStore {
180
192
  next.reasoning = true;
181
193
  else
182
194
  delete next.reasoning;
195
+ const map = withEffort(asRecord(next.thinkingLevelMap), model.effort);
196
+ if (map)
197
+ next.thinkingLevelMap = map;
198
+ else
199
+ delete next.thinkingLevelMap;
183
200
  return next;
184
201
  });
185
202
  }
@@ -319,12 +336,8 @@ export class PiConfigStore {
319
336
  return fs.readFile(path, "utf8");
320
337
  }
321
338
  }
322
- /**
323
- * Relative paths of all files under root, bounded depth, sorted; [] if absent.
324
- * Symlinks are followed (skills and extensions are routinely linked in from a
325
- * checkout elsewhere) and everything reached through one is flagged, so the UI
326
- * can say where it really came from. The depth bound is also the cycle guard.
327
- */
339
+ /** Symlinks are followed (skills are routinely linked in from elsewhere) and
340
+ * flagged; the depth bound is also the cycle guard. */
328
341
  async function listDir(root, prefix = "", depth = RESOURCE_DEPTH, linked = false) {
329
342
  if (depth === 0)
330
343
  return [];
@@ -1,11 +1,8 @@
1
- // Provider credentials — what Pi kept in <agentDir>/auth.json, and the
2
- // literal API keys models.json used to carry — at rest in pier.db, sealed by
3
- // Secrets. Implements pi-ai's CredentialStore contract structurally (shapes
4
- // mirrored below, no SDK import: only pi.ts names SDK modules), so
5
- // ModelRuntime reads through here and an OAuth refresh writes the rotated
6
- // token back through here instead of a file. A stored credential wins over a
7
- // models.json apiKey in pi-ai's resolution order, which is what lets the
8
- // sweep below leave models.json purely structural — and safely syncable.
1
+ // Provider credentials at rest in pier.db, sealed by Secrets. Implements
2
+ // pi-ai's CredentialStore structurally (no SDK import), so an OAuth refresh
3
+ // writes back here instead of a file. A stored credential wins over a
4
+ // models.json apiKey in pi-ai's resolution order, which is what lets the sweep
5
+ // leave models.json purely structural and syncable.
9
6
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
7
  import { join } from "node:path";
11
8
  import { isDeepStrictEqual } from "node:util";
@@ -25,9 +22,7 @@ export class CredentialStore {
25
22
  this.secrets = secrets;
26
23
  this.agentDir = agentDir;
27
24
  }
28
- /** Locked Secrets must fail a session open loudly, with the reason — not
29
- * surface later as "provider is not configured". Called by pi.ts before
30
- * every open; encrypt() throws the `secrets locked: ...` error we want. */
25
+ /** encrypt() throws the `secrets locked: ...` error a session open must fail with. */
31
26
  assertUnlocked() {
32
27
  if (this.secrets.state === "locked")
33
28
  this.secrets.encrypt("");
@@ -93,10 +88,8 @@ export class CredentialStore {
93
88
  "ON CONFLICT(key) DO UPDATE SET value = excluded.value")
94
89
  .run(providerId, this.secrets.encrypt(JSON.stringify(credential)));
95
90
  }
96
- /** One-time move of <agentDir>/auth.json into the database. Lazy because
97
- * sealing needs an unlocked Secrets; retried until it succeeds (the flag is
98
- * set only then, and #put is an idempotent upsert). The file is renamed,
99
- * never deleted: auth.json.imported is the operator's receipt and way back. */
91
+ /** Lazy: sealing needs an unlocked Secrets, and #put is an idempotent upsert,
92
+ * so this retries until it succeeds. The file is renamed, never deleted. */
100
93
  #ensureImported() {
101
94
  if (this.#imported)
102
95
  return;
@@ -123,14 +116,9 @@ export class CredentialStore {
123
116
  this.#sweepModelsJson();
124
117
  this.#imported = true;
125
118
  }
126
- /**
127
- * models.json apiKeys are secrets in a file that should be pure structure
128
- * (it is what a config repo syncs between hosts). Literal keys move into the
129
- * database; `!command` and `$ENV` references are already not plaintext and
130
- * stay — the SDK resolves those forms itself, and a sealed copy of a
131
- * reference would freeze its meaning. The pre-sweep file is kept whole as
132
- * models.json.imported: keys leave the file only with a receipt.
133
- */
119
+ /** models.json is what a config repo syncs, so literal keys move into the
120
+ * database. `!command` and `$ENV` references stay: a sealed copy would
121
+ * freeze their meaning. The pre-sweep file is kept as models.json.imported. */
134
122
  #sweepModelsJson() {
135
123
  const path = join(this.agentDir, "models.json");
136
124
  if (!existsSync(path))
@@ -1,9 +1,9 @@
1
- // Pure Pi-event → SessionEventPayload translation. Structurally typed on
2
- // purpose: no @earendil-works/pi-* imports, so it stays unit-testable without Pi
3
- // and Pi types never leak past the seam. The golden-table test in
4
- // events.test.ts is the mapping's spec; extend types.ts before adding events.
1
+ // Pure Pi-event → SessionEventPayload translation. Structurally typed: no Pi
2
+ // imports, so it is unit-testable without Pi and Pi types never leak past the
3
+ // seam. The golden table in events.test.ts is the mapping's spec.
5
4
  import { isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
6
- const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
5
+ /** An assistant message that calls a tool is work in progress, not a reply. */
6
+ export const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
7
7
  export function textOf(content) {
8
8
  if (typeof content === "string")
9
9
  return content;
@@ -25,9 +25,7 @@ function systemOrigin(message) {
25
25
  typeof origin.runId !== "string" ||
26
26
  (origin.sourceSessionId !== null && typeof origin.sourceSessionId !== "string"))
27
27
  return null;
28
- // Rebuilt around a checked `source` rather than cast through it: a
29
- // half-valid one drawn by the card is an `undefined` in a chip, which reads
30
- // as a bug in the card rather than as bad metadata.
28
+ // A half-valid `source` drawn by the card is an `undefined` in a chip.
31
29
  const source = inputSource(raw);
32
30
  const shape = { ...origin, ...(source ? { source } : {}) };
33
31
  if (origin.kind === "task-delegation" || origin.kind === "task-callback") {
@@ -40,9 +38,6 @@ function systemOrigin(message) {
40
38
  return shape;
41
39
  return null;
42
40
  }
43
- /** What produced a system input, as read back off disk: the name is the whole
44
- * point of it, the model and the effort are each kept only if they are the
45
- * shape they claim to be (core/types.ts). */
46
41
  function inputSource(value) {
47
42
  if (!value || typeof value !== "object")
48
43
  return undefined;
@@ -58,6 +53,9 @@ function inputSource(value) {
58
53
  ...(isThinkingLevel(thinking) ? { thinking } : {}),
59
54
  };
60
55
  }
56
+ /** `length`, `aborted` and `error` are Pi's to recover from (a truncated answer
57
+ * is compacted and asked again), so they stay on the `agent_end` path. */
58
+ const isAnswer = (m) => m?.role === "assistant" && m.stopReason === "stop" && !hasToolCalls(m);
61
59
  function lastAssistant(messages) {
62
60
  if (!messages)
63
61
  return undefined;
@@ -67,17 +65,9 @@ function lastAssistant(messages) {
67
65
  }
68
66
  return undefined;
69
67
  }
70
- /**
71
- * Completion metadata for the assistant message at `index` (bubble hover
72
- * hints). `completedAt` defaults to the message's own timestamp — Pi stamps
73
- * that at stream start, so callers with a real clock (live turn-end) pass
74
- * their own; history accepts the approximation.
75
- *
76
- * `tokens` is the context size at that point, not a sum: each assistant
77
- * message's `totalTokens` already covers the whole request (prompt + cache +
78
- * output), so adding them up double-counts the context on every turn. Pi
79
- * itself reads context usage off the last assistant message the same way.
80
- */
68
+ /** Pi stamps a message's timestamp at stream start, so a live turn-end passes
69
+ * its own `completedAt`. `tokens` is not a sum: each `totalTokens` already
70
+ * covers the whole request. */
81
71
  export function turnMetaAt(messages, index, completedAt) {
82
72
  const m = messages[index];
83
73
  if (m?.role !== "assistant" || typeof m.timestamp !== "number")
@@ -101,11 +91,8 @@ export function turnMetaAt(messages, index, completedAt) {
101
91
  }
102
92
  return { completedAt: end, durationMs: Math.max(0, end - started), tokens };
103
93
  }
104
- /**
105
- * Rebuild the renderable transcript: user/assistant turns plus the activity
106
- * (thinking + tool calls) that preceded each assistant answer. This is what
107
- * makes a page reload show the real step counts instead of restarting at zero.
108
- */
94
+ /** The renderable transcript, activity included, so a reload shows the same
95
+ * step counts the live stream built. */
109
96
  export function toChatTurns(messages) {
110
97
  const turns = [];
111
98
  let steps = []; // activity seen since the last emitted turn
@@ -117,9 +104,8 @@ export function toChatTurns(messages) {
117
104
  turn.meta = meta;
118
105
  if (origin)
119
106
  turn.origin = origin;
120
- // An assistant turn already says when it finished; these two would have no
121
- // time at all after a reload, which is the one place the live stream's own
122
- // stamp is gone.
107
+ // An assistant turn's meta says when it finished; user and system turns
108
+ // would have no time at all after a reload.
123
109
  if (at !== undefined && role !== "assistant")
124
110
  turn.at = at;
125
111
  if (steps.length) {
@@ -132,8 +118,7 @@ export function toChatTurns(messages) {
132
118
  if (m.role === "toolResult") {
133
119
  const step = pendingTools.get(m.toolCallId ?? "");
134
120
  if (step) {
135
- // Capped where the transcript is rebuilt, not where it is rendered: a
136
- // long session's tool results are megabytes nobody ever sees.
121
+ // Capped here: a long session's tool results are megabytes nobody sees.
137
122
  const output = textOf(m.content);
138
123
  step.output = output.length > MAX_STEP_OUTPUT ? output.slice(0, MAX_STEP_OUTPUT) + "…" : output;
139
124
  step.isError = m.isError ?? false;
@@ -201,18 +186,21 @@ export function toSessionEvents(e) {
201
186
  switch (e.type) {
202
187
  case "agent_start":
203
188
  return [{ type: "state", state: "streaming" }, { type: "turn-start" }];
189
+ // One turn-end per answer, not per run: Pi drains a queued follow-up
190
+ // inside the run and emits a single agent_end for all of it.
191
+ case "turn_end":
192
+ return isAnswer(e.message) ? [{ type: "turn-end", text: textOf(e.message?.content) }] : [];
204
193
  case "agent_end": {
205
- // Pi retries a retryable provider error itself and emits one agent_end
206
- // per attempt. Only the last one ends the turn: translating the others
207
- // posts a reply and an error per attempt for a failure Pi is still
208
- // recovering from, and an `idle` the session is not in.
194
+ // Pi emits one agent_end per retry attempt; only the last ends the turn.
209
195
  if (e.willRetry)
210
196
  return [];
211
197
  const final = lastAssistant(e.messages);
212
- // A turn can end without the model ever answering. Carried twice on
213
- // purpose: on turn-end because it is *how this turn ended*, which is what
214
- // a task run settles on (tasks/agent.ts), and as the error event that is
215
- // already every chat surface's failure path (core/router.ts).
198
+ // An answer ended its own turn above; what is left is every way a run
199
+ // ends without one.
200
+ if (isAnswer(final))
201
+ return [];
202
+ // Carried twice: on turn-end because it is how the turn ended (what a
203
+ // task run settles on), and as the error event every chat surface reports.
216
204
  const failure = final?.stopReason === "error"
217
205
  ? final.errorMessage || "unknown agent error"
218
206
  : undefined;
@@ -223,31 +211,27 @@ export function toSessionEvents(e) {
223
211
  out.push({ type: "error", message: failure });
224
212
  return out;
225
213
  }
226
- // Pi's own "the run-active flag is now false": `_emitAgentSettled` clears
227
- // `isStreaming` one statement before emitting this, and reaches it from the
228
- // finally of the prompt — many microtasks after the last `agent_end`. Idle
229
- // rides on it rather than on the turn ending, so the seam's `state` getter
230
- // and this stream stop being two derivations of one fact (§5): a waiter the
231
- // idle wakes now re-reads `state` as idle instead of re-arming for an event
232
- // that is already spent. It is also the truthful moment — Pi's
233
- // auto-compaction and queued continuations run past `agent_end`.
214
+ // Pi clears `isStreaming` one statement before emitting this, many
215
+ // microtasks after the last `agent_end`; idle rides on it so the `state`
216
+ // getter and this stream agree. Auto-compaction runs past `agent_end` too.
234
217
  case "agent_settled":
235
218
  return [{ type: "state", state: "idle" }];
236
219
  case "message_start": {
237
- // Pi emits this for every message entering the context; the user ones are
238
- // what a client can't know about (queued/steered messages, IM traffic).
239
220
  const m = e.message;
240
221
  if (!m)
241
222
  return [];
242
223
  if (m.role === "assistant")
243
224
  return [{ type: "text-start" }];
244
225
  const origin = systemOrigin(m);
245
- const text = textOf(m.content);
246
- if (origin)
247
- return text ? [{ type: "system-input", text, origin }] : [];
248
- if (m.role !== "user")
226
+ if (!origin && m.role !== "user")
249
227
  return [];
250
- return text ? [{ type: "user-message", text }] : [];
228
+ // A message Pi drains mid-run opens a turn `agent_start` never announces.
229
+ // On the message, not its text: an attachment with no caption is still a turn.
230
+ const out = [{ type: "turn-start" }];
231
+ const text = textOf(m.content);
232
+ if (text)
233
+ out.push(origin ? { type: "system-input", text, origin } : { type: "user-message", text });
234
+ return out;
251
235
  }
252
236
  case "message_update": {
253
237
  const ame = e.assistantMessageEvent;
@@ -277,10 +261,8 @@ export function toSessionEvents(e) {
277
261
  },
278
262
  ];
279
263
  case "compaction_end": {
280
- // The only trace compaction leaves anywhere: Pi replaces the summarized
281
- // entries with a `compactionSummary` message, which `toChatTurns` above
282
- // renders nothing for — so without this event the button's effect is
283
- // invisible and the automatic one is invisible twice over (§5b).
264
+ // The only trace compaction leaves: `toChatTurns` renders nothing for the
265
+ // summary message (§5).
284
266
  const r = e.result;
285
267
  if (r && typeof r.tokensBefore === "number") {
286
268
  return [{
@@ -289,9 +271,8 @@ export function toSessionEvents(e) {
289
271
  after: r.estimatedTokensAfter ?? r.tokensBefore,
290
272
  }];
291
273
  }
292
- // No result means the context was *not* shrunk — cancelled, or the
293
- // summarization call failed. A manual compact reports through its route
294
- // as well; an automatic one has no route, and this is all it has.
274
+ // No result: cancelled or failed. An automatic compaction has no route
275
+ // to report through; this is all it has.
295
276
  return [{ type: "error", message: e.errorMessage ?? "compaction cancelled" }];
296
277
  }
297
278
  case "tool_execution_end":