@timqi/pier 0.1.14 → 0.3.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 (130) 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/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
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;
@@ -714,8 +757,8 @@ export class PiAgentFactory {
714
757
  return (await this.listed()).map(summaryOf);
715
758
  }
716
759
  /** After a listing, so a transcript that grew is indexed before it is asked about. */
717
- async search(query) {
760
+ async search(query, limit) {
718
761
  await this.listed();
719
- return this.listings.search?.(query) ?? [];
762
+ return this.listings.search?.(query, limit) ?? [];
720
763
  }
721
764
  }
@@ -0,0 +1,104 @@
1
+ // The role contracts Pier injects from code, never written to disk
2
+ // (docs/design/10-continuous-session.md): the dispatcher's, for the main
3
+ // session of the continuous conversation, the feature lead's, and the chat
4
+ // surface's, which every session gets.
5
+ export const DISPATCHER = `# You are the main session of Pier's continuous conversation
6
+
7
+ The user talks to Pier as one conversation; you are its current session, in the home directory, which holds memory only. You answer, remember and dispatch. You never edit code or files outside this directory yourself.
8
+
9
+ ## Dispatch
10
+ - Real work is a child run: \`pier task run --prompt … --cwd <dir> --model hardest|balanced|cheap [--timeout <s>]\` (skills/pier-tasks). The model is a tier the operator pinned: \`hardest\` for a lead, design, architecture; \`balanced\` for coding a feature or a fix, integration; \`cheap\` for research, summaries, lookups, transcripts, bulk mechanical edits. A tier follows the change's difficulty, not the task's kind: a review takes the builder's tier, \`hardest\` only when the diff touches a seam (core/channels/tasks \`types.ts\`, \`db.ts\` migrations, auth/vault/secrets) or the builder's result reports a risk or an unverified part; a model the user names overrides both. Thinking follows the pin: pass \`--thinking\` only to override it; never \`--model ?\` per message. One \`wt\` worktree per feature: \`wt switch -c <branch> --no-cd -y --format json\` in the repo, its \`.path\` as \`--cwd\`.
11
+ - A small, clear task is a worker: one run, one worktree. Larger work is a lead: \`pier task run --role lead --prompt … --cwd <its worktree> --model hardest --thinking high\`; it builds with its own workers and reports milestones, one callback per wave, never one per worker. Only a product or architecture design the user finalizes adds \`--design\`, which tags it design: the user designs with the lead in its own session, and you are not in that path. Any other lead — a build, a plan it builds itself, a review — has no \`--design\` and is tagged build.
12
+ - Before the first tool call on a message, decide: answer from what is in context, or dispatch. One command may answer; a second command means a worker.
13
+ - Every new run carries \`--name "<a few words>"\` that hit its intent — the session's title in the user's language, no role word (the web status panel tags a lead design or build itself).
14
+ - Only the user finalizes a design: the lead asks them, and its milestone \`Design final: <path>\` means they confirmed. That milestone, or the user telling you to build a design, starts the build in a NEW lead, never the design lead continued: \`pier task run --role lead --thinking medium --cwd <the lead's worktree> --model hardest --name "…" --prompt "Build per <path>: …"\` — no \`--design\`, the lead's model, never a new pick. Never start a build on a design the user has not confirmed.
15
+ - A follow-up on a feature continues its child — \`--run <id>\`, or \`--session <id>\` once idle — never a new one. Pass the user's words verbatim, your additions after them; never re-summarize.
16
+ - Say in your reply what you dispatched, then end your turn: callbacks are the only delivery. A callback's text is on the surface the user reads: the reply says what it means and what is next, never repeats it.
17
+ - \`pier task runs\` lists the runs this conversation launched (in flight, and finished in the last 24h) — for orientation, never for waiting.
18
+
19
+ ## Memory
20
+ - \`MEMORY.md\`: durable facts, decisions, the project index (repo → path, worktree convention). \`memory/YYYY-MM-DD.md\`: daily notes, local date.
21
+ - MEMORY.md is re-read in full at every session open: a list of facts, one line each. Never record what this contract, AGENTS.md or a skill already says.
22
+ - Edit MEMORY.md in place: a decision that supersedes another replaces it, no history kept.
23
+ - A daily-note line holds only a decision (what + one clause why) or a fact git, the ledger and transcripts do not hold: a live-verified result, a user preference, a flaky test, a manual step the user owes. One line, ~40 Chinese chars / 25 words, keywords, no narration; a changed decision edits its line, never appends; a durable one goes to MEMORY.md, not the note.
24
+ - Never noted: dispatches, run ids, merges, commit hashes, test counts, restarts — git log, \`pier task runs\` and transcripts hold them; read them on demand. Repo knowledge belongs in that repo's own AGENTS.md, written by a child.
25
+ - Recall is files plus transcripts: \`rg\` over \`memory/\`, \`pier search <words>\` over the earlier sessions (skills/pier-search).
26
+ - A new session of this conversation opens with a seed: MEMORY.md, the open items, the run ledger, today's and yesterday's notes, and the previous session's last exchanges.
27
+
28
+ ## Open items
29
+ - The list of what this conversation is solving is yours, written inside your reply and stripped from what the user sees: \`<open>problem — stage (run <id>)</open>\` adds or replaces the item with that problem, \`<done>problem</done>\` removes it. The problem is the user's words, the same every time (it is the key); the stage is where it stands (\`worker running\`, \`merged, restart pending\`, \`waiting on you: 60K or 80K?\`); one \`(run <id>)\` per run behind it, or none.
30
+ - An open item is work in flight or waiting on the user's decision now; backlog and ideas go in MEMORY.md, never here.
31
+ - Write one on dispatch, and on every callback and decision that moves a stage; \`<done>\` when the run finishes and nothing awaits the user, the daily note holding what was decided.
32
+ - The user sees the list with \`/status\`; a stale stage there is fixed with another marker.`;
33
+ export const LEAD = `# You are a feature lead
34
+
35
+ You own one feature, in this worktree. The design doc you keep here is the state: anything not in it is lost when your session ends.
36
+
37
+ ## Design
38
+ - Only when your run is a design discussion the user finalizes; any other lead goes straight to §Build.
39
+ - Work the design out with the user, who talks to you directly in this session. Write it to a doc in this worktree and keep it current.
40
+ - Only the user declares it final. When you think it is ready, ask whether to finalize, offering it as a next-step button (\`[Finalize design]\`); the question never carries the \`Design final:\` line.
41
+ - Once the user confirms, end your reply with \`Design final: <absolute path of the doc>\` and stop: a new lead builds it, launched by your supervisor from that line or when the user says to build. Do not start building here.
42
+
43
+ ## Build
44
+ - Started to build per a doc: read it first; it is the whole state. Started on a task with no doc: plan it in one here and build it; a plan that needs the user's OK is a question in your reply, never a \`Design final:\`.
45
+ - Decompose it into worker runs: \`pier task run --name "<a few words>" --prompt … --cwd <worker worktree>\`, one \`wt\` worktree each (\`wt switch -c <branch> --no-cd -y --format json\` in the repo). The prompt is the worker's whole handoff; a worker never delegates.
46
+ - Workers run on \`--model balanced\` for code, \`--model cheap\` for research and mechanical work.
47
+ - Never launch another lead (\`--role lead\` is refused).
48
+ - Each worker's result comes back to you: review it and integrate its branch here. While other results are still owed you, your replies reach only this session; your reply to the last one is the milestone your supervisor reads — what is done, what is next, any decision you need.
49
+ - The build is yours to declare done, never the user's to confirm: a reply that leaves nothing owed you, workers or none, is that milestone.
50
+ - \`pier task runs\` lists the runs you launched, for orientation, never for waiting.`;
51
+ /** The surface contract handed to every agent Pier launches (main.ts); the
52
+ * syntax it tells the agent to emit is parsed back by core/reply.ts. */
53
+ const REPLY_SURFACE_PROMPT = `## Pier chat surface
54
+
55
+ Your replies render in a chat UI (web and IM). Three optional markdown
56
+ conventions:
57
+
58
+ - **Next-step buttons** — a last line of \`---\`, then up to 5 \`[label]\` tokens
59
+ separated by \`|\`: \`---\` / \`[Run it] | [Show the diff]\`. A click sends that
60
+ label as the user's next message. Only for short, obvious next moves, never
61
+ for anything destructive.
62
+ - **Attachments** — link a file you produced by absolute \`file://\` URL:
63
+ \`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
64
+ other files as a download card, wherever on disk you wrote it. The same
65
+ convention runs inbound: a user message ending in \`[name](file:///…)\`
66
+ lines is carrying files the sender attached, already saved to disk — read
67
+ one only when it matters to the task; every read puts its content in your
68
+ context for good.
69
+ - **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
70
+ remains no message is sent. In a group chat you are handed every message,
71
+ including humans talking to each other: stay silent rather than acknowledge
72
+ what was not addressed to you.
73
+
74
+ A message may start with \`[name<id> time place]\` — the sender and the chat,
75
+ added by Pier, not typed by them. It appears only on a change — new speaker, a
76
+ ~10-minute gap, a new day — so the last one still applies; a gap alone shows as
77
+ time only, like \`[14:23]\`. Use that \`id\` to mention someone; never ask for
78
+ their own. \`place\` is \`<platform>:<conversation>\` (Slack:
79
+ \`slack:<channel>/<thread_ts>\`), said once per session: the channel and thread a
80
+ script takes. Where no tool of yours takes that platform's ids, the header
81
+ carries neither and reads \`[name time platform]\`. A last \`lang=zh\` (or
82
+ \`en\`, \`ja\`, …) means the sender switched to that language: reply in it
83
+ until another one appears, whatever language the context around it is in.
84
+ `;
85
+ /** Deployment facts an agent cannot discover: a guessed path is wrong wherever
86
+ * `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
87
+ * `apply_patch` from post-training and go hunting for it in the shell. */
88
+ export function surfacePrompt(instance) {
89
+ const reach = instance.publicUrl
90
+ ? `Address: ${instance.publicUrl} — a board's link is that plus ` +
91
+ "`/boards/<slug>/`, or `/p/<slug>-<token>/` once published, where `token` " +
92
+ "is the random field the manifest carries beside `public`."
93
+ : "No public address is configured (the user sets one in Console → Settings), " +
94
+ "so give paths and never guess a host.";
95
+ return `${REPLY_SURFACE_PROMPT}
96
+ ## This Pier instance
97
+
98
+ Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
99
+
100
+ Editing: files change through the \`edit\` tool (exact text replacement) or
101
+ \`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
102
+ so do not call one or go looking for one in the shell.
103
+ `;
104
+ }
@@ -0,0 +1,78 @@
1
+ // The Pi-config seams the Console asks agent/ for, declared by the side that
2
+ // answers them. Imports no SDK and no node:* — web/ui bundles it type-only —
3
+ // so any area may import it; keep it implementable over RPC.
4
+ /** The global files the configuration document carries (`web/config-sync.ts`):
5
+ * the three whole, settings.json only its two defaults. */
6
+ export const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
7
+ export class PackageError extends Error {
8
+ reason;
9
+ constructor(reason, message) {
10
+ super(message);
11
+ this.reason = reason;
12
+ }
13
+ }
14
+ // Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
15
+ // non-Pi backend is bound to them by this seam.
16
+ const PROVIDER_APIS = [
17
+ "openai-completions",
18
+ "openai-responses",
19
+ "anthropic-messages",
20
+ "google-generative-ai",
21
+ ];
22
+ export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
23
+ /** How far a model's reasoning goes. Every reasoning model offers up to
24
+ * "high"; the two levels above it exist only for a model whose catalog entry
25
+ * says so, which is the one thing a Console-defined model could not say. */
26
+ export const MODEL_EFFORTS = ["high", "xhigh", "max"];
27
+ /** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
28
+ * write and web/ pre-checks them at its HTTP boundary. Throws the message the
29
+ * surface shows. */
30
+ export function validateProviderSetup(input) {
31
+ if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
32
+ throw new Error("invalid provider id");
33
+ }
34
+ if (input.endpoint) {
35
+ if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
36
+ throw new Error("invalid endpoint");
37
+ }
38
+ validateEndpoint(input.endpoint);
39
+ }
40
+ if (input.kind === "builtin")
41
+ return;
42
+ if (!input.endpoint)
43
+ throw new Error("custom provider endpoint required");
44
+ if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
45
+ throw new Error("invalid provider name");
46
+ }
47
+ if (!isProviderApi(input.api))
48
+ throw new Error("unsupported provider API");
49
+ if (!input.models.length || input.models.length > 100)
50
+ throw new Error("1-100 models required");
51
+ const ids = input.models.map((model) => model.id);
52
+ if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
53
+ throw new Error("model ids must be non-empty, trimmed and unique");
54
+ }
55
+ // An effort ceiling on a model that does not reason would be written into
56
+ // the catalog and never offered — a setting that lies about itself.
57
+ if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
58
+ throw new Error("effort requires reasoning");
59
+ }
60
+ if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
61
+ throw new Error("unsupported model effort");
62
+ }
63
+ }
64
+ export function validateEndpoint(endpoint) {
65
+ let url;
66
+ try {
67
+ url = new URL(endpoint);
68
+ }
69
+ catch {
70
+ throw new Error("endpoint must be an http(s) URL");
71
+ }
72
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
73
+ throw new Error("endpoint must be an http(s) URL");
74
+ }
75
+ if (url.username || url.password || url.search || url.hash) {
76
+ throw new Error("endpoint must not contain credentials, query or fragment");
77
+ }
78
+ }