@timqi/pier 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
package/README.md CHANGED
@@ -9,9 +9,9 @@ something is worth showing.
9
9
  One instance, one account, your own machine. The agent runs shell commands in
10
10
  directories you name — meant for a machine you own, not a shared host.
11
11
 
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.
12
+ **Status: pre-release.** Releases may still change behaviour and the
13
+ database schema between versions; schema upgrades are one-way. Read
14
+ `docs/deploy.md` before putting it anywhere reachable.
15
15
 
16
16
  ## Requirements
17
17
 
@@ -30,15 +30,35 @@ pier serve
30
30
 
31
31
  It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
32
32
  `~/.pier` (`PIER_HOME`): one SQLite database, the boards it serves, the
33
- `master.key` that seals stored credentials, and the Pi runtime with its
34
- session transcripts (`~/.pier/pi`, unless `PI_CODING_AGENT_DIR` says
35
- otherwise).
33
+ `master.key` that seals stored credentials, the conversation's memory
34
+ (`~/.pier/home`), and the Pi runtime with its session transcripts
35
+ (`~/.pier/pi`, unless `PI_CODING_AGENT_DIR` says otherwise).
36
36
 
37
37
  **The first start generates a password and prints it once.** Lost it?
38
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, Vault, the public URL, password and master key; **New session**
41
- picks the directory the agent's shell runs in.
39
+ `http://localhost:3141`, sign in; **Console → Settings** has Models, Agent,
40
+ Channels, Vault, Tasks, Boards, Instance (public URL, accent, notifications,
41
+ reload) and Security (master key, password, passkeys, signed-in devices).
42
+
43
+ ## How the conversation works
44
+
45
+ The web chat is one conversation, Pier. Its current session answers,
46
+ remembers and dispatches; it runs in `~/.pier/home`, which holds
47
+ memory only: `MEMORY.md` (one-line facts) and daily notes in
48
+ `memory/YYYY-MM-DD.md`.
49
+
50
+ - Real work is delegated with `pier task run`: a worker for a small task, a
51
+ lead for a feature, which designs with you or builds with its own workers.
52
+ Each is its own session; its result comes back to Pier as a callback.
53
+ - After an idle hour, or past 60K tokens, the next message starts a fresh
54
+ session, seeded with the memory, the open items, recent runs and the last
55
+ exchanges. The old transcripts stay readable.
56
+ - `/status` lists what is open, `/new` starts a fresh session now, `/stop` stops
57
+ the reply in progress, `/skills` lists the skills. `%` works in place of
58
+ `/` (`%status`), because Slack's client eats an unregistered `/`.
59
+ - One IM DM, on Lark or Slack, can be the conversation: Console → Settings →
60
+ Channels, the DM's row, "This DM is the conversation". Pier then
61
+ answers there, from the phone; other chats keep one session per thread.
42
62
 
43
63
  ## Configure Pi
44
64
 
@@ -58,14 +78,15 @@ Console → Settings:
58
78
  - **Models** — endpoints, API-key/OAuth login, pinned models. Credentials are
59
79
  sealed in Pier's SQLite database, never written back to `models.json`.
60
80
  **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: Pier's own skills
66
- (`pier web search|fetch` reaches the web through the provider's hosted
67
- tools — Anthropic or OpenAI, no key of your own) and the
68
- managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
81
+ - **Agent** — edits `SYSTEM.md`, `AGENTS.md` and the `models.json`
82
+ structure, globally or per project scope (with that project's
83
+ `.pi/skills` and `.pi/extensions` listed); `settings.json` is Pier's and
84
+ shown read-only. Changes apply when a session next opens; saving recycles
85
+ idle sessions; **Settings → Instance → Reload** does the same for files
86
+ changed elsewhere. Also here: packages (Pi's registry, **Add package**),
87
+ Pier's own skills as the built-in `pier` package (`pier web search|fetch`
88
+ reaches the web through the provider's hosted tools — Anthropic or OpenAI,
89
+ no key of your own), **Browse files**, and the managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
69
90
  [ubix](https://github.com/timqi/ubix) block), installed into
70
91
  `~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
71
92
  Pier writes there at start.
@@ -79,7 +100,7 @@ store and renamed `auth.json.imported`; literal keys in `models.json` likewise
79
100
  (`models.json.imported`). The editor rejects plaintext keys or header values.
80
101
  Pi's provider environment variables are inherited from the Pier process; a
81
102
  systemd service does not inherit your shell, so put non-secret settings in a
82
- unit override and API keys in the Providers UI.
103
+ unit override and API keys in Settings → Models.
83
104
 
84
105
  ## Run it as a service
85
106
 
@@ -118,8 +139,9 @@ npm test # vitest
118
139
 
119
140
  ## Releases
120
141
 
121
- Pier asks `registry.npmjs.org` at boot and every 30 minutes; the footer version
122
- becomes `v0.0.1 → 0.0.2` when a release is out and opens a panel: source link,
142
+ Pier asks `registry.npmjs.org` at boot and every 30 minutes; the version in
143
+ the Settings header turns into a badge when a release is out (`Pier <version>
144
+ is out`) and opens a panel: source link,
123
145
  **Update now**, **Update automatically** (idle instance only). Both drain first
124
146
  and hand the install to the updater unit; off systemd the panel says `pier
125
147
  update`. The updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
@@ -128,10 +150,10 @@ update`. The updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
128
150
 
129
151
  `main` is the only development line. `just release [patch|minor|major]` checks,
130
152
  tags and pushes; the tag builds and publishes to npm and a GitHub Release. The
131
- footer version is `package.json`'s.
153
+ version shown is `package.json`'s.
132
154
 
133
155
  ## License
134
156
 
135
157
  [AGPL-3.0-only](LICENSE). Run it, change it, deploy it. If you offer a modified
136
158
  Pier to other people over a network, they are entitled to your source — the
137
- version in the footer links to this repository for exactly that reason.
159
+ version in the Settings header links to this repository for exactly that reason.
@@ -4,7 +4,8 @@
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { promises as fs } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { isProviderApi, isThinkingLevel, validateEndpoint, validateProviderSetup } from "../core/types.js";
7
+ import { isThinkingLevel } from "../core/types.js";
8
+ import { isProviderApi, SNAPSHOT_FILES, validateEndpoint, validateProviderSetup } from "./types.js";
8
9
  import { pierPath } from "../paths.js";
9
10
  import { mergeSnapshotProviders, normalizeAgentSnapshot, snapshotProviders } from "./config-sync.js";
10
11
  const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
@@ -13,7 +14,6 @@ const PROJECT_FILES = ["AGENTS.md"];
13
14
  // go through writeDefaults, and are the only fields a snapshot carries. The
14
15
  // Console shows the file and never edits it; the rest of it is machine-local.
15
16
  const READONLY_FILES = ["settings.json"];
16
- const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
17
17
  /** What a first boot writes (docs/deploy.md says why each key): the list the
18
18
  * Console installs into, Pi's attribution headers off — a server is not a
19
19
  * person to survey — and a retry budget of minutes, because an unattended
@@ -1,7 +1,7 @@
1
1
  // Pure Pi-event → SessionEventPayload translation. Structurally typed: no Pi
2
2
  // imports, so it is unit-testable without Pi and Pi types never leak past the
3
3
  // seam. The golden table in events.test.ts is the mapping's spec.
4
- import { isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
4
+ import { isChatCommand, isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
5
5
  /** An assistant message that calls a tool is work in progress, not a reply. */
6
6
  export const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
7
7
  export function textOf(content) {
@@ -20,14 +20,36 @@ function systemOrigin(message) {
20
20
  const value = message.details;
21
21
  if (!value || typeof value !== "object")
22
22
  return null;
23
- const { source: raw, ...origin } = value;
23
+ const { source: raw, cwd, ...origin } = value;
24
+ if (origin.kind === "session-seed") {
25
+ const { reason, previousSessionId } = origin;
26
+ return (reason === "first" || reason === "idle" || reason === "lost" || reason === "full" || reason === "new") &&
27
+ (previousSessionId === null || typeof previousSessionId === "string")
28
+ ? { kind: "session-seed", reason, previousSessionId }
29
+ : null;
30
+ }
31
+ if (origin.kind === "chat-command") {
32
+ const { command, sessions } = origin;
33
+ if (!isChatCommand(command))
34
+ return null;
35
+ // A malformed map costs the card its links, not the card.
36
+ const links = sessions && typeof sessions === "object" && !Array.isArray(sessions) && Object.values(sessions).every((v) => typeof v === "string")
37
+ ? { sessions: sessions }
38
+ : {};
39
+ return { kind: "chat-command", command, ...links };
40
+ }
24
41
  if (typeof origin.taskId !== "string" ||
25
42
  typeof origin.runId !== "string" ||
26
43
  (origin.sourceSessionId !== null && typeof origin.sourceSessionId !== "string"))
27
44
  return null;
28
45
  // A half-valid `source` drawn by the card is an `undefined` in a chip.
29
46
  const source = inputSource(raw);
30
- const shape = { ...origin, ...(source ? { source } : {}) };
47
+ // A callback's, and relative paths resolve against it, so only an absolute one is kept.
48
+ const shape = {
49
+ ...origin,
50
+ ...(source ? { source } : {}),
51
+ ...(origin.kind === "task-callback" && typeof cwd === "string" && cwd.startsWith("/") ? { cwd } : {}),
52
+ };
31
53
  if (origin.kind === "task-delegation" || origin.kind === "task-callback") {
32
54
  return shape;
33
55
  }
@@ -168,6 +190,13 @@ export function toChatTurns(messages) {
168
190
  }
169
191
  }
170
192
  const text = textOf(m.content);
193
+ // Only the attempt that ended the turn: one Pi retried past is no failure.
194
+ if (m.role === "assistant" && m.stopReason === "error" && messages[i + 1]?.role !== "assistant") {
195
+ flush("assistant", hasTools ? "" : text, turnMetaAt(messages, i), undefined, m.timestamp);
196
+ turns[turns.length - 1].error = m.errorMessage || "unknown agent error";
197
+ candidate = undefined;
198
+ continue;
199
+ }
171
200
  // Tool-bearing messages are intermediate work, even when they include text.
172
201
  if (!text || hasTools)
173
202
  continue;
@@ -5,17 +5,16 @@
5
5
  import { existsSync, promises as fs } from "node:fs";
6
6
  import { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { DefaultPackageManager, loadSkills, SettingsManager, } from "@earendil-works/pi-coding-agent";
8
- import { PackageError, } from "../core/types.js";
8
+ import { PackageError, } from "./types.js";
9
9
  import { logger } from "../log.js";
10
10
  import { defaultAgentDir } from "./config.js";
11
11
  const log = logger("packages");
12
12
  /** Set by how long a released fix may sit unnoticed, like update.ts. */
13
13
  const CHECK_EVERY_MS = 24 * 60 * 60_000;
14
14
  const KINDS = { extensions: "extension", skills: "skill" };
15
- /** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): a `local` file whose
16
- * switch is the rtk tool's, so a settings.json pattern would fight the tool. */
15
+ /** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): the rtk tool's, so it
16
+ * is left out of `local` — the Console switches it as that tool, and a settings.json pattern would fight it. */
17
17
  const RTK_FILE = join("extensions", "rtk.ts");
18
- const RTK_STATE = "installed by the rtk tool";
19
18
  export const kindOf = (source) => source.startsWith("npm:") ? "npm" : /^(git:|(https?|ssh|git):\/\/)/.test(source) ? "git" : "path";
20
19
  /** A trailing `@ref` on an npm or git source; the leading `@` of a scope is not one. */
21
20
  export const pinnedRef = (source) => {
@@ -120,13 +119,12 @@ export class PiPackageStore {
120
119
  row("local", "local", "global", this.agentDir);
121
120
  for (const key of Object.keys(KINDS)) {
122
121
  for (const r of resolved[key]) {
122
+ if (r.path === rtkPath)
123
+ continue;
123
124
  const own = r.metadata.origin === "top-level";
124
125
  const scope = r.metadata.scope === "project" ? "project" : "global";
125
- const resource = resourceRow(KINDS[key], r);
126
- if (r.path === rtkPath)
127
- Object.assign(resource, { state: RTK_STATE, locked: true });
128
126
  row(own ? "local" : r.metadata.source, own ? "local" : kindOf(r.metadata.source), scope, r.metadata.baseDir ?? null)
129
- .resources.push(resource);
127
+ .resources.push(resourceRow(KINDS[key], r));
130
128
  }
131
129
  }
132
130
  // Configured but unresolved (not installed, or empty): still a row.
@@ -277,8 +275,6 @@ export class PiPackageStore {
277
275
  async setEnabled(change) {
278
276
  const { source, kind, path, enabled, cwd } = change;
279
277
  const { pkg, resource } = await this.#find(change);
280
- if (resource.locked)
281
- throw new PackageError("refused", `${resource.name} is ${resource.state} — its switch is under Tools`);
282
278
  if (pkg.kind === "pier") {
283
279
  this.pier.settings.setSkillsOff(withName(this.pier.settings.get().skillsOff, resource.name, !enabled));
284
280
  }
package/dist/agent/pi.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // Pi SDK. No Pi type may appear in an exported signature.
4
4
  import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
- import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
6
+ import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, sessionEntryToContextMessages, } from "@earendil-works/pi-coding-agent";
7
7
  import { SESSION_TITLE_MAX } from "../core/types.js";
8
8
  import { logger } from "../log.js";
9
+ import { pierPath } from "../paths.js";
10
+ import { DISPATCHER, LEAD } from "./roles.js";
9
11
  import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
10
12
  import { defaultAgentDir, PiConfigStore } from "./config.js";
11
13
  import { IndexedListing } from "./listing.js";
@@ -99,7 +101,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
99
101
  - Before touching files: list and search first. Never guess a path or a line number.
100
102
  - Read before you edit. Match the surrounding code's style, naming, and comment density.
101
103
  - Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
102
- - Each bash call is a fresh shell in the working directory; chain what must share state.
104
+ - Each bash call is a fresh shell in the working directory; chain what must share state. Don't prefix commands with \`cd\` to that same directory — use relative paths; \`cd\` only to go elsewhere.
103
105
  - Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
104
106
  - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
105
107
  export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
@@ -111,25 +113,37 @@ const bashTimeoutDefault = (pi) => {
111
113
  }
112
114
  });
113
115
  };
116
+ /** The transcript's current branch in order, compacted entries included. */
117
+ const branchMessages = (sessionManager) => sessionManager.getBranch().flatMap((entry) => sessionEntryToContextMessages(entry));
118
+ /** Where a session compacts — a main session
119
+ * and a session a run launched from a session made (a lead or a worker, which
120
+ * never rotate); above 200K input, 1M-context models price higher. */
121
+ const MAIN_COMPACTION_CAP = 100_000;
122
+ const CHILD_COMPACTION_CAP = 150_000;
114
123
  export class PiSession {
115
124
  pi;
116
125
  pinned;
117
126
  wrote;
118
127
  retention;
119
128
  suggestTitle;
129
+ cap;
120
130
  constructor(pi,
121
131
  /** Read per call — the menu can change while we run. */
122
132
  pinned = () => [],
123
- /** Drops the factory's retained listing: a rename lands in exactly the
124
- * window it covers, and every surface would keep the old title. */
133
+ /** Drops the factory's retained listing: a title lands in exactly the
134
+ * window it covers, and every surface would keep the old one. */
125
135
  wrote = () => { }, retention = { value: "long" },
126
136
  /** Read per turn: switching auto-titling on takes effect without a restart. */
127
- suggestTitle = () => undefined) {
137
+ suggestTitle = () => undefined,
138
+ /** Auto-compaction triggers once the context passes this many tokens. */
139
+ cap) {
128
140
  this.pi = pi;
129
141
  this.pinned = pinned;
130
142
  this.wrote = wrote;
131
143
  this.retention = retention;
132
144
  this.suggestTitle = suggestTitle;
145
+ this.cap = cap;
146
+ this.applyCap();
133
147
  }
134
148
  /** A turn started after Pi's dispose runs for real and lands nowhere — no
135
149
  * transcript, no event, a promise that resolves. Refusing makes it a failure (§5). */
@@ -153,7 +167,7 @@ export class PiSession {
153
167
  }
154
168
  get contextUsage() {
155
169
  const u = this.pi.getContextUsage();
156
- return u ? { tokens: u.tokens, contextWindow: u.contextWindow } : undefined;
170
+ return u ? { tokens: u.tokens, contextWindow: u.contextWindow, compactAt: u.contextWindow - this.reserve(u.contextWindow) } : undefined;
157
171
  }
158
172
  async setModel(ref) {
159
173
  const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
@@ -163,6 +177,8 @@ export class PiSession {
163
177
  throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
164
178
  }
165
179
  await this.pi.setModel(m);
180
+ // The reserve is per context window, so a cap outlives a model switch only if recomputed.
181
+ this.applyCap();
166
182
  }
167
183
  async availableModels() {
168
184
  const available = await this.pi.modelRuntime.getAvailable();
@@ -183,6 +199,27 @@ export class PiSession {
183
199
  setCacheRetention(retention) {
184
200
  this.retention.value = retention;
185
201
  }
202
+ instanceReserve;
203
+ /** Pi compacts past `contextWindow − reserveTokens`; this session's settings
204
+ * manager is its own, so the override reaches no other session. Never later
205
+ * than the instance's own reserve. */
206
+ applyCap() {
207
+ const window = this.pi.model?.contextWindow;
208
+ if (this.cap === undefined || !window)
209
+ return;
210
+ this.pi.settingsManager.applyOverrides({ compaction: { reserveTokens: this.reserve(window) } });
211
+ }
212
+ /** The instance's reserve is read once under a cap, before the first override replaces it. */
213
+ reserve(window) {
214
+ const settings = this.pi.settingsManager;
215
+ if (this.cap === undefined)
216
+ return settings.getCompactionSettings().reserveTokens;
217
+ this.instanceReserve ??= settings.getCompactionSettings().reserveTokens;
218
+ return Math.max(window - this.cap, this.instanceReserve);
219
+ }
220
+ skills() {
221
+ return this.pi.resourceLoader.getSkills().skills.map(({ name, description }) => ({ name, description }));
222
+ }
186
223
  async pendingQueue() {
187
224
  return {
188
225
  steering: [...this.pi.getSteeringMessages()],
@@ -209,12 +246,11 @@ export class PiSession {
209
246
  }
210
247
  async history() {
211
248
  this.live();
212
- return toChatTurns(this.pi.messages);
249
+ return toChatTurns(branchMessages(this.pi.sessionManager));
213
250
  }
214
251
  async rewindToUserTurn(index) {
215
252
  const total = (await this.history()).filter((t) => t.role === "user").length;
216
- // Branch entries keep compacted-away history that history() no longer
217
- // shows, so only end-relative indices line up.
253
+ // End-relative: a user entry toChatTurns would not count cannot shift the target.
218
254
  const back = total - index;
219
255
  const users = this.pi.sessionManager
220
256
  .getBranch()
@@ -227,49 +263,41 @@ export class PiSession {
227
263
  if (cancelled)
228
264
  throw new Error("rewind cancelled");
229
265
  }
230
- /** Pi keeps no lock of its own: a second `compact()` summarizes a transcript
231
- * being replaced under it, and two POSTs a millisecond apart both pass the
232
- * route's idle check. */
233
- compacting = null;
234
- async compact() {
235
- this.live();
236
- if (this.compacting)
237
- throw new Error(`session ${this.pi.sessionId} is already compacting`);
238
- // Recorded in the same tick, no await between: that is what makes the check a gate.
239
- const running = this.pi.compact().then(() => undefined);
240
- this.compacting = running;
241
- try {
242
- await running;
243
- }
244
- finally {
245
- this.compacting = null;
246
- }
247
- }
248
- /** An append: Pi's reader takes the latest `session_info`. Never refused for
249
- * being busy. TODO: renaming a cold session costs a whole resume for one
250
- * appended line; revisit when Pi offers a lightweight append. */
251
- async rename(name) {
252
- this.live();
253
- this.pi.sessionManager.appendSessionInfo(name);
254
- this.wrote();
255
- }
256
- /** A dispatch landing mid-compaction waits for the summary instead of
257
- * starting a turn over it. Not Pi's follow-up queue: that is drained only by
258
- * the *next* turn, so a message parked there while idle would sit unsent. */
259
- async whenCompacted() {
260
- while (this.compacting)
261
- await this.compacting.catch(() => undefined);
262
- }
263
266
  // Async, so a refusal is a rejected promise the seam lets callers `.catch()`.
264
267
  async prompt(text) {
265
- this.live();
266
- await this.whenCompacted();
267
- // The wait above is long enough for a dispose to land.
268
268
  this.live();
269
269
  // A turn may have started since the caller read the state. Bare, Pi throws
270
270
  // "already processing" and the message is gone (§5); queued, it is the
271
271
  // same "delivered when idle" core/queue.ts picks for a mid-turn message.
272
- return this.pi.prompt(text, { streamingBehavior: "followUp" });
272
+ let refused = false;
273
+ try {
274
+ await this.pi.prompt(text, { streamingBehavior: "followUp", preflightResult: (ok) => { refused = !ok; } });
275
+ }
276
+ catch (error) {
277
+ if (refused)
278
+ this.recordRefusal(text, error);
279
+ throw error;
280
+ }
281
+ }
282
+ /** Pi refuses before writing anything (no model, no key), so without this the
283
+ * message and its reason would exist only as a live event (§5). Recorded the
284
+ * way a provider failure is — an errored reply the model's context drops. */
285
+ recordRefusal(text, error) {
286
+ const now = Date.now();
287
+ const manager = this.pi.sessionManager;
288
+ manager.appendMessage({ role: "user", content: [{ type: "text", text }], timestamp: now });
289
+ manager.appendMessage({
290
+ role: "assistant",
291
+ content: [],
292
+ api: this.pi.model?.api ?? "",
293
+ provider: this.pi.model?.provider ?? "",
294
+ model: this.pi.model?.id ?? "",
295
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
296
+ stopReason: "error",
297
+ errorMessage: error instanceof Error ? error.message : String(error),
298
+ timestamp: now,
299
+ });
300
+ this.pi.agent.state.messages = manager.buildSessionProjection().messages;
273
301
  }
274
302
  async steer(text) {
275
303
  this.live();
@@ -281,15 +309,13 @@ export class PiSession {
281
309
  }
282
310
  async systemInput(text, origin, mode) {
283
311
  this.live();
284
- // Same gate as prompt(): an idle session takes a system input as a turn.
285
- await this.whenCompacted();
286
- this.live();
287
- // Read after the wait: a running turn means the call below queues.
288
- const queued = this.pi.isStreaming;
312
+ // A running turn means the call below queues.
313
+ const queued = this.pi.isStreaming && mode !== "append";
289
314
  if (queued)
290
315
  this.queuedInputs.push(origin);
291
316
  try {
292
- return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
317
+ // Without a turn Pi appends it now, or after a running turn's tool results.
318
+ return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: mode !== "append", deliverAs: mode === "prompt" || mode === "append" ? undefined : mode });
293
319
  }
294
320
  catch (error) {
295
321
  // Refused: nothing is in flight. The entry may be gone already.
@@ -338,8 +364,7 @@ export class PiSession {
338
364
  if (!first.trim())
339
365
  return;
340
366
  void suggest(first, reply).then((title) => {
341
- // Named while we waited, by a person: their word beats the model's.
342
- if (this.disposed || this.pi.sessionManager.getSessionName())
367
+ if (this.disposed)
343
368
  return;
344
369
  this.pi.sessionManager.appendSessionInfo(title);
345
370
  this.wrote();
@@ -371,6 +396,7 @@ export class PiAgentFactory {
371
396
  pier;
372
397
  titleModel;
373
398
  listings;
399
+ roleOf;
374
400
  constructor(
375
401
  /** Read per session open, so a Console change reaches the next session
376
402
  * without a restart; appended as a context file so the user's own
@@ -384,7 +410,9 @@ export class PiAgentFactory {
384
410
  /** The built-in `pier` package's one switch list: Pier's own skills switched off. */
385
411
  pier = () => ({ skillsOff: [] }), titleModel = () => undefined,
386
412
  /** Injected so a test needs no session directory or database. */
387
- listings = new IndexedListing()) {
413
+ listings = new IndexedListing(),
414
+ /** A reopened session's role, which only its runs record (tasks/). */
415
+ roleOf = () => undefined) {
388
416
  this.instructions = instructions;
389
417
  this.skillPaths = skillPaths;
390
418
  this.credentials = credentials;
@@ -393,6 +421,7 @@ export class PiAgentFactory {
393
421
  this.pier = pier;
394
422
  this.titleModel = titleModel;
395
423
  this.listings = listings;
424
+ this.roleOf = roleOf;
396
425
  }
397
426
  /** Catalogs are global, not per session. */
398
427
  catalog;
@@ -583,8 +612,9 @@ export class PiAgentFactory {
583
612
  const modelRegistry = new ModelRegistry(await this.refreshedRuntime());
584
613
  return { modelRegistry, model: active && modelRegistry.find(active.provider, active.id) };
585
614
  }
586
- async resourceLoader(cwd) {
587
- const { skillsOff } = this.pier();
615
+ async resourceLoader(cwd, role, dispatcher) {
616
+ // A worker never delegates (tasks/operations.ts refuses it), so it is not taught how.
617
+ const skillsOff = role === "worker" ? [...this.pier().skillsOff, "pier-tasks"] : this.pier().skillsOff;
588
618
  const loader = new DefaultResourceLoader({
589
619
  cwd,
590
620
  agentDir: defaultAgentDir(),
@@ -601,19 +631,27 @@ export class PiAgentFactory {
601
631
  agentsFilesOverride: (current) => {
602
632
  const content = this.instructions();
603
633
  return {
604
- agentsFiles: content
605
- ? [...current.agentsFiles, { path: "<pier>/AGENTS.md", content }]
606
- : current.agentsFiles,
634
+ agentsFiles: [
635
+ ...current.agentsFiles,
636
+ ...(content ? [{ path: "<pier>/AGENTS.md", content }] : []),
637
+ ...(dispatcher ? [{ path: "<pier>/dispatcher.md", content: DISPATCHER }] : []),
638
+ ...(role === "lead" ? [{ path: "<pier>/lead.md", content: LEAD }] : []),
639
+ ],
607
640
  };
608
641
  },
609
642
  });
610
643
  await loader.reload();
611
644
  return loader;
612
645
  }
613
- open(cwd, sessionManager, opts = { cwd }) {
614
- return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
646
+ open(sessionManager, opts) {
647
+ return this.providerConfig.withWrite(() => this.openSnapshot(sessionManager, opts));
615
648
  }
616
- async openSnapshot(cwd, sessionManager, opts) {
649
+ async openSnapshot(sessionManager, opts) {
650
+ const { cwd, role } = opts;
651
+ // The home is where the continuous conversation's sessions run: only they
652
+ // dispatch, and they compact at main's cap.
653
+ const main = realPath(cwd) === realPath(pierPath("home"));
654
+ const cap = main ? MAIN_COMPACTION_CAP : role ? CHILD_COMPACTION_CAP : undefined;
617
655
  // A locked store is a refusal with a reason here, not "provider not
618
656
  // configured" later. Before appendSessionInfo, so nothing is written.
619
657
  await this.credentials?.assertUnlocked();
@@ -630,7 +668,7 @@ export class PiAgentFactory {
630
668
  cwd,
631
669
  sessionManager,
632
670
  modelRuntime: runtime,
633
- resourceLoader: await this.resourceLoader(cwd),
671
+ resourceLoader: await this.resourceLoader(cwd, role, main),
634
672
  });
635
673
  const live = created.session;
636
674
  // Pi defaults to one follow-up per turn boundary, so N queued messages cost
@@ -642,7 +680,7 @@ export class PiAgentFactory {
642
680
  }, retention, () => {
643
681
  const model = this.titleModel();
644
682
  return model && ((first, reply) => this.suggestTitle(model, first, reply));
645
- });
683
+ }, cap);
646
684
  if (opts.model)
647
685
  await session.setModel(opts.model);
648
686
  if (opts.thinking)
@@ -654,13 +692,14 @@ export class PiAgentFactory {
654
692
  this.listing = undefined;
655
693
  // Resolved before Pi records it, so every later listing names it the same way.
656
694
  const cwd = realPath(opts.cwd);
657
- return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
695
+ return this.open(SessionManager.create(cwd), { ...opts, cwd });
658
696
  }
659
697
  async resume(sessionId) {
698
+ const role = this.roleOf(sessionId);
660
699
  const known = this.located.get(sessionId);
661
700
  if (known) {
662
701
  try {
663
- return await this.open(known.cwd, SessionManager.open(known.path));
702
+ return await this.open(SessionManager.open(known.path), { cwd: known.cwd, role });
664
703
  }
665
704
  catch (err) {
666
705
  log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
@@ -670,7 +709,7 @@ export class PiAgentFactory {
670
709
  const info = await this.locate(sessionId);
671
710
  if (!info)
672
711
  throw new Error(`unknown session: ${sessionId}`);
673
- return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
712
+ return this.open(SessionManager.open(info.path), { cwd: info.cwd || process.cwd(), role });
674
713
  }
675
714
  /** The one place "no such session" is decided. A retained listing is not
676
715
  * evidence a session is gone, and callers read a miss as permission to start
@@ -681,6 +720,10 @@ export class PiAgentFactory {
681
720
  return find(await this.listed()) ??
682
721
  (reused && this.listing === reused ? find(await this.listed(true)) : undefined);
683
722
  }
723
+ async readHistory(sessionId) {
724
+ const info = await this.locate(sessionId);
725
+ return info && toChatTurns(branchMessages(SessionManager.open(info.path)));
726
+ }
684
727
  async find(sessionId) {
685
728
  const info = await this.locate(sessionId);
686
729
  return info ? summaryOf(info) : undefined;