@timqi/pier 0.1.0 → 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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  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 +7 -23
  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 +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  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 +4 -12
  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 +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  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 +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  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-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  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-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  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-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  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-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  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 +30 -16
  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/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.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,20 +10,15 @@ 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) => "••••••••";
23
- /** Pi reads the two highest levels off the model's own map, so a ceiling is
24
- * read back from it and written into it — leaving the rest of a hand-written
25
- * map (an `off: null` that forbids thinking-off) alone, because the Console
26
- * does not offer it and therefore may not drop it. */
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. */
27
22
  const effortOf = (map) => typeof map?.max === "string" ? "max" : typeof map?.xhigh === "string" ? "xhigh" : undefined;
28
23
  function withEffort(map, effort) {
29
24
  const next = { ...map };
@@ -341,12 +336,8 @@ export class PiConfigStore {
341
336
  return fs.readFile(path, "utf8");
342
337
  }
343
338
  }
344
- /**
345
- * Relative paths of all files under root, bounded depth, sorted; [] if absent.
346
- * Symlinks are followed (skills and extensions are routinely linked in from a
347
- * checkout elsewhere) and everything reached through one is flagged, so the UI
348
- * can say where it really came from. The depth bound is also the cycle guard.
349
- */
339
+ /** Symlinks are followed (skills are routinely linked in from elsewhere) and
340
+ * flagged; the depth bound is also the cycle guard. */
350
341
  async function listDir(root, prefix = "", depth = RESOURCE_DEPTH, linked = false) {
351
342
  if (depth === 0)
352
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,11 +1,8 @@
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
- /** An assistant message that calls a tool is work in progress, not a reply —
7
- * the rule `toChatTurns` rebuilds a transcript by, and agent/listing.ts
8
- * indexes one by. */
5
+ /** An assistant message that calls a tool is work in progress, not a reply. */
9
6
  export const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
10
7
  export function textOf(content) {
11
8
  if (typeof content === "string")
@@ -28,9 +25,7 @@ function systemOrigin(message) {
28
25
  typeof origin.runId !== "string" ||
29
26
  (origin.sourceSessionId !== null && typeof origin.sourceSessionId !== "string"))
30
27
  return null;
31
- // Rebuilt around a checked `source` rather than cast through it: a
32
- // half-valid one drawn by the card is an `undefined` in a chip, which reads
33
- // 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.
34
29
  const source = inputSource(raw);
35
30
  const shape = { ...origin, ...(source ? { source } : {}) };
36
31
  if (origin.kind === "task-delegation" || origin.kind === "task-callback") {
@@ -43,9 +38,6 @@ function systemOrigin(message) {
43
38
  return shape;
44
39
  return null;
45
40
  }
46
- /** What produced a system input, as read back off disk: the name is the whole
47
- * point of it, the model and the effort are each kept only if they are the
48
- * shape they claim to be (core/types.ts). */
49
41
  function inputSource(value) {
50
42
  if (!value || typeof value !== "object")
51
43
  return undefined;
@@ -61,6 +53,9 @@ function inputSource(value) {
61
53
  ...(isThinkingLevel(thinking) ? { thinking } : {}),
62
54
  };
63
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);
64
59
  function lastAssistant(messages) {
65
60
  if (!messages)
66
61
  return undefined;
@@ -70,17 +65,9 @@ function lastAssistant(messages) {
70
65
  }
71
66
  return undefined;
72
67
  }
73
- /**
74
- * Completion metadata for the assistant message at `index` (bubble hover
75
- * hints). `completedAt` defaults to the message's own timestamp — Pi stamps
76
- * that at stream start, so callers with a real clock (live turn-end) pass
77
- * their own; history accepts the approximation.
78
- *
79
- * `tokens` is the context size at that point, not a sum: each assistant
80
- * message's `totalTokens` already covers the whole request (prompt + cache +
81
- * output), so adding them up double-counts the context on every turn. Pi
82
- * itself reads context usage off the last assistant message the same way.
83
- */
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. */
84
71
  export function turnMetaAt(messages, index, completedAt) {
85
72
  const m = messages[index];
86
73
  if (m?.role !== "assistant" || typeof m.timestamp !== "number")
@@ -104,11 +91,8 @@ export function turnMetaAt(messages, index, completedAt) {
104
91
  }
105
92
  return { completedAt: end, durationMs: Math.max(0, end - started), tokens };
106
93
  }
107
- /**
108
- * Rebuild the renderable transcript: user/assistant turns plus the activity
109
- * (thinking + tool calls) that preceded each assistant answer. This is what
110
- * makes a page reload show the real step counts instead of restarting at zero.
111
- */
94
+ /** The renderable transcript, activity included, so a reload shows the same
95
+ * step counts the live stream built. */
112
96
  export function toChatTurns(messages) {
113
97
  const turns = [];
114
98
  let steps = []; // activity seen since the last emitted turn
@@ -120,9 +104,8 @@ export function toChatTurns(messages) {
120
104
  turn.meta = meta;
121
105
  if (origin)
122
106
  turn.origin = origin;
123
- // An assistant turn already says when it finished; these two would have no
124
- // time at all after a reload, which is the one place the live stream's own
125
- // 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.
126
109
  if (at !== undefined && role !== "assistant")
127
110
  turn.at = at;
128
111
  if (steps.length) {
@@ -135,8 +118,7 @@ export function toChatTurns(messages) {
135
118
  if (m.role === "toolResult") {
136
119
  const step = pendingTools.get(m.toolCallId ?? "");
137
120
  if (step) {
138
- // Capped where the transcript is rebuilt, not where it is rendered: a
139
- // long session's tool results are megabytes nobody ever sees.
121
+ // Capped here: a long session's tool results are megabytes nobody sees.
140
122
  const output = textOf(m.content);
141
123
  step.output = output.length > MAX_STEP_OUTPUT ? output.slice(0, MAX_STEP_OUTPUT) + "…" : output;
142
124
  step.isError = m.isError ?? false;
@@ -204,18 +186,21 @@ export function toSessionEvents(e) {
204
186
  switch (e.type) {
205
187
  case "agent_start":
206
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) }] : [];
207
193
  case "agent_end": {
208
- // Pi retries a retryable provider error itself and emits one agent_end
209
- // per attempt. Only the last one ends the turn: translating the others
210
- // posts a reply and an error per attempt for a failure Pi is still
211
- // 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.
212
195
  if (e.willRetry)
213
196
  return [];
214
197
  const final = lastAssistant(e.messages);
215
- // A turn can end without the model ever answering. Carried twice on
216
- // purpose: on turn-end because it is *how this turn ended*, which is what
217
- // a task run settles on (tasks/agent.ts), and as the error event that is
218
- // 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.
219
204
  const failure = final?.stopReason === "error"
220
205
  ? final.errorMessage || "unknown agent error"
221
206
  : undefined;
@@ -226,31 +211,27 @@ export function toSessionEvents(e) {
226
211
  out.push({ type: "error", message: failure });
227
212
  return out;
228
213
  }
229
- // Pi's own "the run-active flag is now false": `_emitAgentSettled` clears
230
- // `isStreaming` one statement before emitting this, and reaches it from the
231
- // finally of the prompt — many microtasks after the last `agent_end`. Idle
232
- // rides on it rather than on the turn ending, so the seam's `state` getter
233
- // and this stream stop being two derivations of one fact (§5): a waiter the
234
- // idle wakes now re-reads `state` as idle instead of re-arming for an event
235
- // that is already spent. It is also the truthful moment — Pi's
236
- // 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.
237
217
  case "agent_settled":
238
218
  return [{ type: "state", state: "idle" }];
239
219
  case "message_start": {
240
- // Pi emits this for every message entering the context; the user ones are
241
- // what a client can't know about (queued/steered messages, IM traffic).
242
220
  const m = e.message;
243
221
  if (!m)
244
222
  return [];
245
223
  if (m.role === "assistant")
246
224
  return [{ type: "text-start" }];
247
225
  const origin = systemOrigin(m);
248
- const text = textOf(m.content);
249
- if (origin)
250
- return text ? [{ type: "system-input", text, origin }] : [];
251
- if (m.role !== "user")
226
+ if (!origin && m.role !== "user")
252
227
  return [];
253
- 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;
254
235
  }
255
236
  case "message_update": {
256
237
  const ame = e.assistantMessageEvent;
@@ -280,10 +261,8 @@ export function toSessionEvents(e) {
280
261
  },
281
262
  ];
282
263
  case "compaction_end": {
283
- // The only trace compaction leaves anywhere: Pi replaces the summarized
284
- // entries with a `compactionSummary` message, which `toChatTurns` above
285
- // renders nothing for — so without this event the button's effect is
286
- // 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).
287
266
  const r = e.result;
288
267
  if (r && typeof r.tokensBefore === "number") {
289
268
  return [{
@@ -292,9 +271,8 @@ export function toSessionEvents(e) {
292
271
  after: r.estimatedTokensAfter ?? r.tokensBefore,
293
272
  }];
294
273
  }
295
- // No result means the context was *not* shrunk — cancelled, or the
296
- // summarization call failed. A manual compact reports through its route
297
- // 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.
298
276
  return [{ type: "error", message: e.errorMessage ?? "compaction cancelled" }];
299
277
  }
300
278
  case "tool_execution_end":