@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/db.js CHANGED
@@ -143,7 +143,7 @@ const MIGRATIONS = [
143
143
  created_at INTEGER NOT NULL
144
144
  );
145
145
  `,
146
- // 6 — manual order in the rail.
146
+ // 6 — manual order in the session list.
147
147
  `
148
148
  -- NULL sorts on top, so a fresh database needs no backfill.
149
149
  ALTER TABLE session_state ADD COLUMN sort INTEGER;
@@ -246,7 +246,7 @@ const MIGRATIONS = [
246
246
  `
247
247
  UPDATE session_state SET pinned = 0;
248
248
  `,
249
- // 20 — sort becomes the rank in the working set the rail keeps on top; pinned goes.
249
+ // 20 — sort becomes the rank in the working set the session list keeps on top; pinned goes.
250
250
  `
251
251
  -- Pinned rows seed the set at rank -1, capped at the set's size (8).
252
252
  UPDATE session_state SET sort = -1 WHERE pinned = 1 AND sort IS NULL;
@@ -317,6 +317,45 @@ const MIGRATIONS = [
317
317
  created_at INTEGER NOT NULL,
318
318
  last_used_at INTEGER
319
319
  );
320
+ `,
321
+ // 28 — the continuous conversation's sessions in order (core/chain.ts); the
322
+ // head is the newest, and the transcripts are the record.
323
+ `
324
+ CREATE TABLE main_chain (
325
+ session_id TEXT PRIMARY KEY,
326
+ started_at INTEGER NOT NULL,
327
+ reason TEXT NOT NULL
328
+ );
329
+ `,
330
+ // 29 — runs by the session they run in: a session's role, asked on every open,
331
+ // every `pier task` call and every callback.
332
+ `
333
+ CREATE INDEX task_runs_target ON task_runs(json_extract(json, '$.targetSessionId'), queued_at);
334
+ `,
335
+ // 30 — a session the operator closed leaves the session list until a human speaks to it (web/session-state.ts).
336
+ `ALTER TABLE session_state ADD COLUMN closed INTEGER NOT NULL DEFAULT 0;`,
337
+ // 31 — the continuous conversation's open items, written from the head's
338
+ // `<open>`/`<done>` markers (tasks/open-items.ts); run_ids a JSON array.
339
+ `
340
+ CREATE TABLE open_items (
341
+ problem TEXT PRIMARY KEY,
342
+ stage TEXT NOT NULL,
343
+ run_ids TEXT NOT NULL,
344
+ updated_at INTEGER NOT NULL
345
+ );
346
+ `,
347
+ // 32 — the working set and the closed flag go; unread is all the workbench keeps.
348
+ `
349
+ ALTER TABLE session_state DROP COLUMN sort;
350
+ ALTER TABLE session_state DROP COLUMN closed;
351
+ `,
352
+ // 33 — columns written and never read.
353
+ `
354
+ ALTER TABLE session_state DROP COLUMN cwd;
355
+ ALTER TABLE session_state DROP COLUMN project_sort;
356
+ ALTER TABLE conversations DROP COLUMN updated_at;
357
+ ALTER TABLE restart_ledger DROP COLUMN created_at;
358
+ ALTER TABLE push_identity DROP COLUMN created_at;
320
359
  `,
321
360
  ];
322
361
  /** `BEGIN IMMEDIATE`: taking the write lock up front turns a race with another
package/dist/drain.js CHANGED
@@ -15,7 +15,7 @@ export class RestartLedger {
15
15
  this.db = db;
16
16
  }
17
17
  record(entry) {
18
- this.db.prepare("INSERT INTO restart_ledger (channel_id, conversation_id, note, created_at) VALUES (?, ?, ?, ?)").run(entry.channelId, entry.conversationId, entry.note, Date.now());
18
+ this.db.prepare("INSERT INTO restart_ledger (channel_id, conversation_id, note) VALUES (?, ?, ?)").run(entry.channelId, entry.conversationId, entry.note);
19
19
  }
20
20
  list() {
21
21
  const rows = this.db.prepare("SELECT id, channel_id, conversation_id, note FROM restart_ledger ORDER BY id").all();
package/dist/main.js CHANGED
@@ -15,18 +15,22 @@ import { defaultBoardsDir, registerBoardRoutes, rotateBoardViews } from "./board
15
15
  import { ChannelStore } from "./channels/config.js";
16
16
  import { createControl } from "./channels/control.js";
17
17
  import { ConversationStore, resolveConversation } from "./channels/conversations.js";
18
- import { createHandoff } from "./channels/handoff.js";
19
18
  import { registerChannelRoutes } from "./channels/routes.js";
20
19
  import { ChannelRuntime } from "./channels/runtime.js";
20
+ import { MainChain } from "./core/chain.js";
21
21
  import { EventHub } from "./core/hub.js";
22
22
  import { splitSpeaker } from "./core/identity.js";
23
+ import { saidText } from "./core/reply.js";
23
24
  import { pierDb } from "./db.js";
24
25
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
25
- import { surfacePrompt } from "./core/reply.js";
26
+ import { surfacePrompt } from "./agent/roles.js";
26
27
  import { Router } from "./core/router.js";
28
+ import { searchSessions } from "./core/search.js";
27
29
  import { acquireInstanceLock } from "./lock.js";
28
30
  import { parseWebParams, runWeb } from "./websearch/run.js";
29
31
  import { logger } from "./log.js";
32
+ import { runResultText, runSource } from "./tasks/callbacks.js";
33
+ import { openItemsStatus } from "./tasks/open-items.js";
30
34
  import { registerTaskRoutes } from "./tasks/routes.js";
31
35
  import { TaskService } from "./tasks/service.js";
32
36
  import { TaskStore } from "./tasks/store.js";
@@ -92,14 +96,16 @@ const configSync = new ConfigSync({
92
96
  reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
93
97
  });
94
98
  const skillsDir = fileURLToPath(new URL("../skills", import.meta.url));
99
+ const taskStore = new TaskStore(db);
95
100
  const factory = new PiAgentFactory(
96
101
  // Getters, read per session open: a Console change reaches the next session
97
102
  // without a restart.
98
103
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
99
104
  // Documents Pier's own tools, so it loads only inside a Pier session.
100
105
  [skillsDir], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get(), () => settings.get().titleModel,
101
- // Transcripts carry the speaker header core wrote for the model.
102
- new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
106
+ // Transcripts carry the speaker header core wrote for the model, and replies
107
+ // the chat markup core strips before anyone sees them.
108
+ new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text, saidText), (id) => taskStore.roleOf(id));
103
109
  const hub = new EventHub();
104
110
  const router = new Router(hub, (key) => {
105
111
  // Web conversation ids are session ids; an IM id is a chat, resolved through
@@ -108,11 +114,39 @@ const router = new Router(hub, (key) => {
108
114
  return factory.resume(key.conversationId);
109
115
  }
110
116
  return resolveIm(key);
111
- }, (key) => conversations.get(key), (id) => conversations.keyOf(id));
117
+ }, (key) => conversations.get(key), (id) => chatKeyOf(id));
118
+ const chain = new MainChain(db, {
119
+ factory, router, home: pierPath("home"),
120
+ ledger: (ids, since) => tasks.ledger(ids, since),
121
+ status: (now) => openItemsStatus(tasks.openItems(now), now),
122
+ defaults: () => piConfig.readDefaults(),
123
+ });
112
124
  const stopEviction = router.startIdleEviction();
113
- tasks = new TaskService(new TaskStore(db), factory, router, hub, {
125
+ tasks = new TaskService(taskStore, factory, router, hub, {
114
126
  modelMenu: () => settings.get().modelMenu,
115
- systemActions: { "config-sync": (signal) => configSync.sync(signal) },
127
+ systemActions: {
128
+ // A schema gap pauses the subscription mid-run; the task's own switch follows it.
129
+ "config-sync": async (signal) => {
130
+ try {
131
+ return await configSync.sync(signal);
132
+ }
133
+ finally {
134
+ await configurationSync.reconcile();
135
+ }
136
+ },
137
+ },
138
+ continuous: chain,
139
+ // `channels` is assigned below, before any run can settle.
140
+ designLead: (run, state) => {
141
+ if (!run.targetSessionId)
142
+ return;
143
+ void channels.designLead({
144
+ sessionId: run.targetSessionId,
145
+ name: run.context.definition.name,
146
+ origin: { kind: "task-callback", taskId: run.taskId, runId: run.id, sourceSessionId: run.targetSessionId, source: runSource(run), state: run.state },
147
+ text: state === "failed" ? run.error ?? run.state : runResultText(run),
148
+ }, state).catch((err) => log.error(`lead ${run.targetSessionId ?? "?"}: its ${state} state did not reach the home chat`, err));
149
+ },
116
150
  });
117
151
  const configurationSync = configSyncTask(tasks, configSync);
118
152
  const managedTools = new ManagedTools();
@@ -130,24 +164,17 @@ const control = createControl({
130
164
  router, factory, conversations, store: channelStore,
131
165
  modelMenu: () => settings.get().modelMenu,
132
166
  });
133
- // The panel pulls through the handoff and the handoff posts through the
134
- // runtime: the runtime's half is reached lazily so both can be built.
135
- const handoff = createHandoff({
136
- store: channelStore,
137
- runtime: {
138
- running: () => channels.running(),
139
- openThread: (platform, chatId, note) => channels.openThread(platform, chatId, note),
140
- },
141
- conversations,
142
- factory,
143
- router,
144
- hub,
145
- publicUrl: () => settings.get().publicUrl,
146
- taskSessions: () => tasks.taskSessions(),
147
- workingSet: () => sessionState.flags(),
148
- log: (m) => logger("channels").info(m),
149
- });
150
- const channels = new ChannelRuntime(channelStore, router, control, handoff);
167
+ const channels = new ChannelRuntime(channelStore, router, chain, control, conversations);
168
+ /** The head answers in the home chat while its adapter runs (docs/design/11), else on
169
+ * the web with Web Push; an older member gets no home key, or an adapter's start
170
+ * would re-key it over the head. Any other session: its conversations row. */
171
+ function chatKeyOf(sessionId) {
172
+ const home = channelStore.home();
173
+ if (home && channels.live(home.platform) && chain.chainOf(sessionId)?.[0] === sessionId) {
174
+ return { channelId: home.platform, conversationId: home.chatId };
175
+ }
176
+ return conversations.keyOf(sessionId);
177
+ }
151
178
  resolveIm = resolveConversation(conversations, factory, control.launchFor, (key, message) => {
152
179
  logger("channels").warn(`${key.channelId}:${key.conversationId} ${message}`);
153
180
  void channels.notify(key.channelId, key.conversationId, message)
@@ -169,16 +196,8 @@ const reloadInstance = async (includeWatched = false) => {
169
196
  await channels.reload();
170
197
  return router.evictIdle(0, Date.now(), { includeWatched });
171
198
  };
172
- // Remote outages keep the last local configuration available.
173
- if (configSync.status().enabled) {
174
- // sync() already logged the cause; this line says what the boot did about it.
175
- try {
176
- await configSync.sync();
177
- }
178
- catch {
179
- log.error("Startup configuration sync failed; using the last local configuration");
180
- }
181
- }
199
+ // No sync at boot: a restart is not a reason to replace the configuration;
200
+ // the owned task's next check is.
182
201
  await configurationSync.reconcile();
183
202
  tasks.start();
184
203
  readyForConfigReload = true;
@@ -278,8 +297,8 @@ registerConfigSyncRoutes(app, {
278
297
  reconcile: configurationSync.reconcile,
279
298
  run: configurationSync.run,
280
299
  });
281
- registerTaskRoutes(app, tasks, { factory, router });
282
- registerChannelRoutes(app, channelStore, channels, handoff, conversations);
300
+ registerTaskRoutes(app, tasks);
301
+ registerChannelRoutes(app, channelStore, channels, conversations);
283
302
  registerVaultRoutes(app, { vault, doctor: () => secrets.doctor() });
284
303
  registerBoardRoutes(app);
285
304
  registerPushRoutes(app, {
@@ -299,6 +318,7 @@ app.route("/", createServer({
299
318
  packages,
300
319
  providers: factory,
301
320
  settings,
321
+ subscribed: () => configSync.status().enabled,
302
322
  // Assembled here: the catalog spawns ubix, which web/ may not.
303
323
  catalog: async () => {
304
324
  const { tools, customTools } = settings.get();
@@ -320,8 +340,12 @@ app.route("/", createServer({
320
340
  reload: () => reloadInstance(true),
321
341
  backgroundRuns: (id) => tasks.backgroundRuns(id),
322
342
  activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
343
+ parkedMessages: (id) => tasks.parkedMessages(id),
323
344
  taskSessions: () => tasks.taskSessions(),
324
- channelOf: (id) => conversations.keyOf(id)?.channelId,
345
+ leads: () => taskStore.leads(),
346
+ openItems: () => tasks.openItems(),
347
+ channelOf: (id) => chatKeyOf(id)?.channelId,
348
+ continuous: chain,
325
349
  }));
326
350
  const port = Number(process.env.PORT ?? 3141);
327
351
  const hostname = process.env.HOST ?? "127.0.0.1";
@@ -339,6 +363,7 @@ servePier({
339
363
  note(`done ${JSON.stringify(result.details)}`);
340
364
  return result;
341
365
  },
366
+ search: searchSessions(factory),
342
367
  // Live in the router, or on disk: the same two places a callback target is looked for.
343
368
  knows: async (id) => router.stateOf(id) !== undefined || (await factory.find(id)) !== undefined,
344
369
  login: () => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/login/${auth.mintLink()}`,
@@ -374,8 +399,8 @@ const shutdown = (stopTasks = true) => {
374
399
  // 90-second wait for SIGKILL.
375
400
  setTimeout(() => process.exit(0), 3000).unref();
376
401
  stopEviction();
377
- // The drain path leaves task runs alone: aborting would record them cancelled,
378
- // when the boot-time interrupted marking is the recovery that was promised.
402
+ // Aborted runs end interrupted, as boot recovery records them; the drain path
403
+ // leaves the ones it outlasted to that recovery.
379
404
  if (stopTasks)
380
405
  tasks.stop();
381
406
  void channels.stop().finally(() => {
package/dist/settings.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Instance settings: the facts about *this* Pier that are neither a credential
2
2
  // nor per-session. A key-value table, so the next setting is not the next table.
3
- import { isThinkingLevel } from "./core/types.js";
3
+ import { isModelTier, isThinkingLevel, MODEL_TIERS, THINKING_LEVELS } from "./core/types.js";
4
4
  import { pierDb, transact } from "./db.js";
5
5
  import { logger } from "./log.js";
6
6
  import { normalizeCustomTools } from "./tools.js";
@@ -48,29 +48,33 @@ export function normalizePublicUrl(raw) {
48
48
  return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
49
49
  }
50
50
  /** Rejecting rather than repairing: a "fixed" entry would advertise a model the
51
- * operator never picked. Notes are capped; every session pays for their tokens. */
52
- export function normalizeModelMenu(raw) {
53
- if (!Array.isArray(raw) || raw.length > 32)
54
- return null;
51
+ * operator never picked. The refusal names the row (1-based, as the Console
52
+ * lists them) and the field. A `note` left by an older Pier is dropped. One
53
+ * model may be pinned at several levels; the same model at the same level twice
54
+ * is refused, naming both rows. */
55
+ export function parseModelMenu(raw) {
56
+ if (!Array.isArray(raw))
57
+ return "modelMenu must be a list";
58
+ if (raw.length > 32)
59
+ return `modelMenu has ${String(raw.length)} rows; at most 32`;
55
60
  const menu = [];
56
- for (const item of raw) {
61
+ for (const [i, item] of raw.entries()) {
62
+ const row = `modelMenu row ${String(i + 1)}`;
57
63
  const ref = normalizeModelRef(item);
58
64
  if (!ref)
59
- return null;
60
- const { thinking, note } = item;
65
+ return `${row}: provider and id must be non-empty strings`;
66
+ const { thinking, tier } = item;
61
67
  // Repaired, not rejected: rows stored before the level was required have
62
68
  // none, and dropping the menu over it would lose the pins.
63
69
  const level = thinking === undefined ? "medium" : thinking;
64
70
  if (!isThinkingLevel(level))
65
- return null;
66
- if (note !== undefined && typeof note !== "string")
67
- return null;
68
- const cleaned = note?.trim().slice(0, 200);
69
- menu.push({
70
- ...ref,
71
- thinking: level,
72
- ...(cleaned ? { note: cleaned } : {}),
73
- });
71
+ return `${row} (${ref.provider}/${ref.id}): thinking must be one of ${THINKING_LEVELS.join(", ")}`;
72
+ if (tier !== undefined && !isModelTier(tier))
73
+ return `${row} (${ref.provider}/${ref.id}): tier must be one of ${MODEL_TIERS.join(", ")}, or none`;
74
+ const twin = menu.findIndex((e) => e.provider === ref.provider && e.id === ref.id && e.thinking === level);
75
+ if (twin >= 0)
76
+ return `${row} (${ref.provider}/${ref.id}): already pinned at ${level} by row ${String(twin + 1)}`;
77
+ menu.push({ ...ref, thinking: level, ...(tier !== undefined ? { tier } : {}) });
74
78
  }
75
79
  return menu;
76
80
  }
@@ -88,7 +92,7 @@ export function normalizeModelRef(raw) {
88
92
  }
89
93
  /** Shape only: tools.ts owns the catalog, and an unknown name is ignored
90
94
  * there, so a downgrade cannot lose a setting it cannot explain. */
91
- export function normalizeTools(raw) {
95
+ function normalizeTools(raw) {
92
96
  return normalizeNames(raw);
93
97
  }
94
98
  function normalizeNames(raw) {
@@ -117,7 +121,10 @@ export class SettingsStore {
117
121
  log.warn("settings.accent is not a preset — ignoring it");
118
122
  return {
119
123
  publicUrl: this.#value("publicUrl") ?? "",
120
- modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
124
+ modelMenu: this.#json("modelMenu", (raw) => {
125
+ const menu = parseModelMenu(raw);
126
+ return typeof menu === "string" ? null : menu;
127
+ }, "a valid menu") ?? [],
121
128
  ...(titleModel ? { titleModel } : {}),
122
129
  autoUpdate: this.#value("autoUpdate") === "1",
123
130
  skillsOff: this.#json("skillsOff", normalizeNames, "a list of names") ?? [],
package/dist/socket.js CHANGED
@@ -33,6 +33,7 @@ const ROUTES = {
33
33
  },
34
34
  "/task": operation("task"),
35
35
  "/web": operation("web"),
36
+ "/search": operation("search"),
36
37
  async "/login"({ login }, _body, _sessionId, answer) {
37
38
  const url = login();
38
39
  log.info("sign-in link minted for pier login");
@@ -4,13 +4,14 @@
4
4
  import { quietLabel, splitReply } from "../core/reply.js";
5
5
  import { logger } from "../log.js";
6
6
  import { runSource } from "./callbacks.js";
7
+ import { createdRole } from "./types.js";
7
8
  // Agent runs are I/O-bound: the cap is there for API pressure and runaway
8
9
  // fan-out, not for this machine's CPU.
9
10
  const MAX_ACTIVE_AGENTS = 6;
10
11
  const log = logger("tasks");
11
12
  /** Every session gets the chat-surface contract, task runs included, so the
12
13
  * delegation prompt says which of it does not apply. Skipped on resume. */
13
- const preamble = (run, supervised) => {
14
+ const preamble = (run, supervised, role) => {
14
15
  // A cron/watch task with a session callback is read by an agent too.
15
16
  const audience = run.invokedBySessionId
16
17
  ? "read by the agent that delegated this run"
@@ -19,8 +20,10 @@ const preamble = (run, supervised) => {
19
20
  : "read by the operator";
20
21
  return `[Pier task run ${run.id} — "${run.context.definition.name}"] ` +
21
22
  `Your final reply is recorded verbatim as the run result, ${audience}; ` +
22
- `next-step buttons and file:// attachments do not render there. A question only that reader can answer is your result: state it and end your turn; the answer resumes this session.` +
23
- (supervised ? " You cannot delegate from here — `pier task` is refused; if the work needs another agent, say so in your result and your supervisor will run it." : "") +
23
+ `next-step buttons and file:// attachments do not render there. Return the conclusion and the paths it rests on — no process, no log of attempts; a deliverable longer than a screen goes to a file the result names. A question only that reader can answer is your result: state it and end your turn; the answer resumes this session.` +
24
+ (role === "lead"
25
+ ? " You are a feature lead: you may delegate to workers with `pier task run` (never `--role lead`), and their results come back to you."
26
+ : supervised || role === "worker" ? " You cannot delegate from here — `pier task` is refused; if the work needs another agent, say so in your result and your supervisor will run it." : "") +
24
27
  "\n\n";
25
28
  };
26
29
  export class AgentTaskRunner {
@@ -61,7 +64,8 @@ export class AgentTaskRunner {
61
64
  const input = run.input === undefined || run.input === null
62
65
  ? ""
63
66
  : `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
64
- const prompt = run.context.resumePrompt ?? `${preamble(run, this.store.supervised(run))}${action.prompt}${input}`;
67
+ const prompt = run.context.resumePrompt ??
68
+ `${preamble(run, this.store.supervised(run), this.store.roleOf(session.id))}${action.prompt}${input}`;
65
69
  run.context.sessionId = session.id;
66
70
  run.context.model = session.model;
67
71
  // The level the session settled on: an unspecified effort inherits the caller's.
@@ -144,12 +148,14 @@ export class AgentTaskRunner {
144
148
  throw new Error("could not resolve child working directory");
145
149
  const opts = {
146
150
  cwd,
147
- name: `${run.context.definition.name} [${run.id.slice(0, 8)}]`,
151
+ // The session list's title: the caller's `--name`, else the prompt's first line.
152
+ name: run.context.definition.name,
148
153
  // Unspecified model inherits the caller's live model, not the global
149
154
  // default; falls back to the default when the caller isn't attached.
150
155
  model: action.launch?.model ??
151
156
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
152
157
  thinking: action.launch?.thinking,
158
+ role: createdRole(run),
153
159
  };
154
160
  const opening = this.factory.create(opts).then(async (session) => {
155
161
  // SDK creation cannot be cancelled; a late session still belongs to this
@@ -10,6 +10,45 @@ export const runSource = (run) => ({
10
10
  ...(run.context.model ? { model: run.context.model } : {}),
11
11
  ...(run.context.thinking ? { thinking: run.context.thinking } : {}),
12
12
  });
13
+ /** The directory a run worked in: the one its session opened on, else the one its action names. */
14
+ export const runCwd = (run) => {
15
+ const action = run.context.definition.action;
16
+ return run.context.cwd ??
17
+ (action.type === "bash" ? action.cwd : action.type === "agent" && action.session.mode === "fresh" ? action.session.cwd : null);
18
+ };
19
+ /** What a callback's card says about the one run it carries. */
20
+ const oneRun = (run) => {
21
+ const cwd = runCwd(run);
22
+ return { source: runSource(run), state: run.state, ...(cwd ? { cwd } : {}) };
23
+ };
24
+ /** Heads a milestone resume's prompt: the lead's reply is what its supervisor reads. */
25
+ export const MILESTONE = "[Pier: the last result you were waiting on follows; nothing owed to you is still running. Your reply is the milestone your supervisor reads: what is done, what is next, any decision you need.]";
26
+ /** The line a design lead ends on once the user confirms: its milestone, whether a run's or a turn outside any. */
27
+ export const DESIGN_FINAL = /^Design final:/m;
28
+ /** On the record of a lead run that owed its supervisor nothing, so the run says why. */
29
+ export const LEAD_TURN = "a lead's turn, not a milestone";
30
+ /** Decided once, as the run finishes. A lead reports milestones only: any
31
+ * other turn of its the user reads in its session, and settles as `--callback none`.
32
+ * A build lead's turn that leaves nothing coming to it is its milestone: it
33
+ * declares its own completion, where only the user finalizes a design.
34
+ * A turn that did not succeed is not a turn the user read: it calls back. */
35
+ export function settleCallback(run, store) {
36
+ if (!run.callbackSessionId)
37
+ return;
38
+ // A watch probe that did not match is the interval passing, not a result.
39
+ if (run.matched === false && run.state === "succeeded")
40
+ return;
41
+ // The session's creator fixes its role: a `--session` or `--run` turn on a lead's session is a lead's.
42
+ const lead = run.targetSessionId;
43
+ const phase = lead === null ? undefined : store.leadPhaseOf(lead);
44
+ const milestone = () => run.context.resumePrompt?.startsWith(MILESTONE) === true ||
45
+ (run.result?.type === "agent" && DESIGN_FINAL.test(run.result.text)) ||
46
+ (phase === "build" && lead !== null && !store.awaitsResults(lead));
47
+ if (phase !== undefined && run.state === "succeeded" && !milestone())
48
+ run.callbackError = LEAD_TURN;
49
+ else
50
+ run.callbackState = "pending";
51
+ }
13
52
  export function runResultText(run) {
14
53
  let result = run.error ?? "No result";
15
54
  if (run.result?.type === "agent" || run.result?.type === "system")
@@ -26,9 +65,17 @@ export function runResultText(run) {
26
65
  }
27
66
  export class TaskCallbacks {
28
67
  store;
68
+ headOf;
69
+ conversation;
29
70
  outbox;
30
- constructor(store, router, changed, unreachable) {
71
+ constructor(store, router, changed, unreachable,
72
+ /** Where a result owed to a session goes now: the continuous conversation's head, for a member. */
73
+ headOf = (id) => id, milestone,
74
+ /** The continuous conversation's head, null before its first message. */
75
+ conversation = () => null) {
31
76
  this.store = store;
77
+ this.headOf = headOf;
78
+ this.conversation = conversation;
32
79
  this.outbox = new Outbox(router, {
33
80
  id: (run) => run.id,
34
81
  reload: (id) => this.store.getRun(id),
@@ -43,11 +90,12 @@ export class TaskCallbacks {
43
90
  sourceSessionId: runs[0].targetSessionId,
44
91
  runIds: runs.map((run) => run.id),
45
92
  // Only for one run: a batch's caption would attribute every result
46
- // to the first run's name and model.
47
- ...(runs.length === 1 ? { source: runSource(runs[0]), state: runs[0].state } : {}),
93
+ // to the first run's name, model and directory.
94
+ ...(runs.length === 1 ? oneRun(runs[0]) : {}),
48
95
  },
49
96
  }),
50
97
  abandoned: (run, sessionId, why) => unreachable(sessionId, `the result of "${run.context.definition.name}"`, why),
98
+ milestone,
51
99
  });
52
100
  }
53
101
  target(callback, origin) {
@@ -55,6 +103,8 @@ export class TaskCallbacks {
55
103
  return callback.sessionId;
56
104
  if (callback.type === "origin")
57
105
  return origin;
106
+ if (callback.type === "conversation")
107
+ return this.conversation();
58
108
  return null;
59
109
  }
60
110
  recover(now = Date.now()) {
@@ -67,10 +117,10 @@ export class TaskCallbacks {
67
117
  const first = this.store.getRun(candidate.id);
68
118
  if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
69
119
  return;
70
- const sessionId = first.callbackSessionId;
120
+ const sessionId = this.headOf(first.callbackSessionId);
71
121
  // Once one callback is deliverable, everything pending for the session rides along.
72
122
  const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
73
- .filter((run) => run.callbackSessionId === sessionId);
123
+ .filter((run) => run.callbackSessionId !== null && this.headOf(run.callbackSessionId) === sessionId);
74
124
  if (!batch.some((run) => run.id === first.id))
75
125
  return;
76
126
  await this.outbox.deliver(sessionId, batch);
package/dist/tasks/cli.js CHANGED
@@ -10,7 +10,7 @@ const OPTIONS = {
10
10
  timeout: { type: "string" }, callback: { type: "string" }, "callback-session": { type: "string" }, join: { type: "string" },
11
11
  bash: { type: "string" }, cron: { type: "string" }, tz: { type: "string" },
12
12
  watch: { type: "string" }, every: { type: "string" }, repeat: { type: "boolean" },
13
- group: { type: "string" }, reason: { type: "string" },
13
+ group: { type: "string" }, reason: { type: "string" }, role: { type: "string" }, design: { type: "boolean" },
14
14
  help: { type: "boolean", short: "h" },
15
15
  };
16
16
  /** The usage line is the contract (docs/design/09-tasks-cli.md); the flags a
@@ -18,17 +18,21 @@ const OPTIONS = {
18
18
  const COMMANDS = {
19
19
  run: {
20
20
  usage: "run [--prompt <text|-> | --bash <script>] [--run <id> [--after]] [--task-id <id>] [--session <id>]\n" +
21
- " [--model <name|?>] [--thinking <level>] [--cwd <dir>] [--name <text>] [--timeout <seconds>]\n" +
21
+ " [--model <name|?>] [--thinking <level>] [--role lead [--design]] [--cwd <dir>] [--name <text>] [--timeout <seconds>]\n" +
22
22
  " [--callback origin|none|steer] [--callback-session <id>] [--join all|first] [--member <flags…>]…",
23
23
  help: "a new run (--prompt | --bash | --task-id | --session … --prompt), a batch (--member), or a prompt on an existing one (--run)",
24
24
  },
25
25
  save: {
26
26
  usage: "save [--task-id <id>] --name <text> (--prompt <text|-> | --bash <script>)\n" +
27
27
  " [--cron \"<expr>\" --tz <zone> | --watch <script> --every <seconds> [--repeat]]\n" +
28
- " [--cwd <dir>] [--timeout <seconds>] [--model <name>] [--thinking <level>] [--callback-session <id>]",
29
- help: "file a definition the operator sees, or update one by --task-id; no trigger means manual",
28
+ " [--cwd <dir>] [--timeout <seconds>] [--model <name>] [--thinking <level>] [--callback-session <id|none>]",
29
+ help: "file a definition the operator sees, or update one by --task-id; no trigger means manual; results reach the continuous conversation unless --callback-session",
30
30
  },
31
- list: { usage: "list", help: "stored definitions, as JSON" },
31
+ list: { usage: "list", help: "stored definitions with nextRun and lastRun, as JSON" },
32
+ pause: { usage: "pause --task-id <id>", help: "stop a definition's schedule; run --task-id still runs it" },
33
+ resume: { usage: "resume --task-id <id>", help: "restart a paused definition's schedule" },
34
+ archive: { usage: "archive --task-id <id>", help: "retire a definition for good" },
35
+ runs: { usage: "runs", help: "the runs you launched (in the continuous conversation, any of its sessions), in flight and finished in the last 24h, as JSON" },
32
36
  cancel: { usage: "cancel (--run <id> | --group <id>)", help: "a run or a group, descendants included" },
33
37
  recover: { usage: "recover (--run <id> | --group <id>) --reason <text>", help: "a finished result after its callback settled; never a progress check" },
34
38
  };
@@ -58,7 +62,9 @@ const seconds = (flag, raw) => {
58
62
  return Number.isInteger(n) ? n : refuse(`--${flag} must be a whole number of seconds`);
59
63
  };
60
64
  /** `launch` as the server takes it: `model` is a menu name it resolves. */
61
- const launchOf = (v) => v.model === undefined && v.thinking === undefined ? undefined : compact({ model: v.model, thinking: v.thinking });
65
+ const launchOf = (v) => v.model === undefined && v.thinking === undefined && v.role === undefined && v.design === undefined
66
+ ? undefined
67
+ : compact({ model: v.model, thinking: v.thinking, role: v.role, design: v.design });
62
68
  /** Argv is split at each bare `--member`; a value equal to it is unreachable,
63
69
  * since parseArgs (strict) refuses option-like values anyway. */
64
70
  function segments(argv) {
@@ -125,8 +131,8 @@ function build(name, parsed, io) {
125
131
  if (parsed.some((v) => v.model === "?"))
126
132
  return { operation: "run", launch: { model: "?" } };
127
133
  const [values, ...members] = parsed;
128
- if (name === "list")
129
- return { operation: "list" };
134
+ if (name === "list" || name === "runs")
135
+ return { operation: name };
130
136
  if (name === "cancel" || name === "recover") {
131
137
  if ((values.run === undefined) === (values.group === undefined))
132
138
  refuse(`${name} takes exactly one of --run or --group`);
@@ -136,13 +142,16 @@ function build(name, parsed, io) {
136
142
  }
137
143
  if (name === "save")
138
144
  return saveParams(values, text);
145
+ if (name === "pause" || name === "resume" || name === "archive") {
146
+ return { operation: name, task_id: values["task-id"] ?? refuse(`${name} needs --task-id`) };
147
+ }
139
148
  /** One new run, in the three shapes `tasks[]` accepts. A saved
140
149
  * definition runs as is: batch defaults pass it by, its own flags are refused. */
141
150
  const entry = (v, own = v) => {
142
151
  const launch = launchOf(v);
143
152
  const timeoutSeconds = seconds("timeout", v.timeout);
144
153
  if (v["task-id"] !== undefined) {
145
- const extra = flagsOf(own).find((flag) => ["prompt", "bash", "session", "cwd", "model", "thinking", "name", "timeout"].includes(flag));
154
+ const extra = flagsOf(own).find((flag) => ["prompt", "bash", "session", "cwd", "model", "thinking", "role", "design", "name", "timeout"].includes(flag));
146
155
  if (extra)
147
156
  refuse(`--${extra} does not apply to a saved definition (--task-id)`);
148
157
  return { task_id: v["task-id"] };
@@ -151,7 +160,7 @@ function build(name, parsed, io) {
151
160
  if (v.prompt !== undefined)
152
161
  refuse("a run takes exactly one of --prompt or --bash");
153
162
  if (launch)
154
- refuse("--model/--thinking apply to a prompt, not --bash");
163
+ refuse("--model/--thinking/--role/--design apply to a prompt, not --bash");
155
164
  if (v.session !== undefined)
156
165
  refuse("--session continues an agent session; --bash is a command, not a turn");
157
166
  return compact({ name: v.name, timeoutSeconds, action: compact({ type: "bash", script: v.bash, cwd: v.cwd }) });
@@ -216,7 +225,9 @@ function saveParams(v, text) {
216
225
  name: v.name,
217
226
  timeoutSeconds: seconds("timeout", v.timeout),
218
227
  trigger,
219
- callback: v["callback-session"] === undefined ? undefined : { type: "session", sessionId: v["callback-session"] },
228
+ callback: v["callback-session"] === undefined
229
+ ? { type: "conversation" }
230
+ : v["callback-session"] === "none" ? { type: "none" } : { type: "session", sessionId: v["callback-session"] },
220
231
  ...(v.bash === undefined
221
232
  ? { prompt: text(v.prompt), cwd: v.cwd, launch }
222
233
  : { action: compact({ type: "bash", script: v.bash, cwd: v.cwd }) }),