@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/dist/main.js CHANGED
@@ -15,18 +15,19 @@ 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
23
  import { pierDb } from "./db.js";
24
24
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
25
- import { surfacePrompt } from "./core/reply.js";
25
+ import { surfacePrompt } from "./agent/roles.js";
26
26
  import { Router } from "./core/router.js";
27
27
  import { acquireInstanceLock } from "./lock.js";
28
28
  import { parseWebParams, runWeb } from "./websearch/run.js";
29
29
  import { logger } from "./log.js";
30
+ import { openItemsStatus } from "./tasks/open-items.js";
30
31
  import { registerTaskRoutes } from "./tasks/routes.js";
31
32
  import { TaskService } from "./tasks/service.js";
32
33
  import { TaskStore } from "./tasks/store.js";
@@ -92,6 +93,7 @@ const configSync = new ConfigSync({
92
93
  reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
93
94
  });
94
95
  const skillsDir = fileURLToPath(new URL("../skills", import.meta.url));
96
+ const taskStore = new TaskStore(db);
95
97
  const factory = new PiAgentFactory(
96
98
  // Getters, read per session open: a Console change reaches the next session
97
99
  // without a restart.
@@ -99,7 +101,7 @@ const factory = new PiAgentFactory(
99
101
  // Documents Pier's own tools, so it loads only inside a Pier session.
100
102
  [skillsDir], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get(), () => settings.get().titleModel,
101
103
  // Transcripts carry the speaker header core wrote for the model.
102
- new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
104
+ new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text), (id) => taskStore.roleOf(id));
103
105
  const hub = new EventHub();
104
106
  const router = new Router(hub, (key) => {
105
107
  // Web conversation ids are session ids; an IM id is a chat, resolved through
@@ -108,11 +110,28 @@ const router = new Router(hub, (key) => {
108
110
  return factory.resume(key.conversationId);
109
111
  }
110
112
  return resolveIm(key);
111
- }, (key) => conversations.get(key), (id) => conversations.keyOf(id));
113
+ }, (key) => conversations.get(key), (id) => chatKeyOf(id));
114
+ const chain = new MainChain(db, {
115
+ factory, router, home: pierPath("home"),
116
+ ledger: (ids, since) => tasks.ledger(ids, since),
117
+ status: (now) => openItemsStatus(tasks.openItems(now), now),
118
+ defaults: () => piConfig.readDefaults(),
119
+ });
112
120
  const stopEviction = router.startIdleEviction();
113
- tasks = new TaskService(new TaskStore(db), factory, router, hub, {
121
+ tasks = new TaskService(taskStore, factory, router, hub, {
114
122
  modelMenu: () => settings.get().modelMenu,
115
- systemActions: { "config-sync": (signal) => configSync.sync(signal) },
123
+ systemActions: {
124
+ // A schema gap pauses the subscription mid-run; the task's own switch follows it.
125
+ "config-sync": async (signal) => {
126
+ try {
127
+ return await configSync.sync(signal);
128
+ }
129
+ finally {
130
+ await configurationSync.reconcile();
131
+ }
132
+ },
133
+ },
134
+ continuous: chain,
116
135
  });
117
136
  const configurationSync = configSyncTask(tasks, configSync);
118
137
  const managedTools = new ManagedTools();
@@ -130,24 +149,17 @@ const control = createControl({
130
149
  router, factory, conversations, store: channelStore,
131
150
  modelMenu: () => settings.get().modelMenu,
132
151
  });
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);
152
+ const channels = new ChannelRuntime(channelStore, router, chain, control);
153
+ /** The head answers in the home chat while its adapter runs (docs/design/11), else on
154
+ * the web with Web Push; an older member gets no home key, or an adapter's start
155
+ * would re-key it over the head. Any other session: its conversations row. */
156
+ function chatKeyOf(sessionId) {
157
+ const home = channelStore.home();
158
+ if (home && channels.live(home.platform) && chain.chainOf(sessionId)?.[0] === sessionId) {
159
+ return { channelId: home.platform, conversationId: home.chatId };
160
+ }
161
+ return conversations.keyOf(sessionId);
162
+ }
151
163
  resolveIm = resolveConversation(conversations, factory, control.launchFor, (key, message) => {
152
164
  logger("channels").warn(`${key.channelId}:${key.conversationId} ${message}`);
153
165
  void channels.notify(key.channelId, key.conversationId, message)
@@ -169,16 +181,8 @@ const reloadInstance = async (includeWatched = false) => {
169
181
  await channels.reload();
170
182
  return router.evictIdle(0, Date.now(), { includeWatched });
171
183
  };
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
- }
184
+ // No sync at boot: a restart is not a reason to replace the configuration;
185
+ // the owned task's next check is.
182
186
  await configurationSync.reconcile();
183
187
  tasks.start();
184
188
  readyForConfigReload = true;
@@ -278,8 +282,8 @@ registerConfigSyncRoutes(app, {
278
282
  reconcile: configurationSync.reconcile,
279
283
  run: configurationSync.run,
280
284
  });
281
- registerTaskRoutes(app, tasks, { factory, router });
282
- registerChannelRoutes(app, channelStore, channels, handoff, conversations);
285
+ registerTaskRoutes(app, tasks);
286
+ registerChannelRoutes(app, channelStore, channels, conversations);
283
287
  registerVaultRoutes(app, { vault, doctor: () => secrets.doctor() });
284
288
  registerBoardRoutes(app);
285
289
  registerPushRoutes(app, {
@@ -299,6 +303,7 @@ app.route("/", createServer({
299
303
  packages,
300
304
  providers: factory,
301
305
  settings,
306
+ subscribed: () => configSync.status().enabled,
302
307
  // Assembled here: the catalog spawns ubix, which web/ may not.
303
308
  catalog: async () => {
304
309
  const { tools, customTools } = settings.get();
@@ -320,8 +325,12 @@ app.route("/", createServer({
320
325
  reload: () => reloadInstance(true),
321
326
  backgroundRuns: (id) => tasks.backgroundRuns(id),
322
327
  activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
328
+ parkedMessages: (id) => tasks.parkedMessages(id),
323
329
  taskSessions: () => tasks.taskSessions(),
324
- channelOf: (id) => conversations.keyOf(id)?.channelId,
330
+ leads: () => taskStore.leads(),
331
+ openItems: () => tasks.openItems(),
332
+ channelOf: (id) => chatKeyOf(id)?.channelId,
333
+ continuous: chain,
325
334
  }));
326
335
  const port = Number(process.env.PORT ?? 3141);
327
336
  const hostname = process.env.HOST ?? "127.0.0.1";
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
  }
@@ -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") ?? [],
@@ -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 }) }),
@@ -1,6 +1,6 @@
1
1
  // What a task *is* before it ever runs: the id it is minted with, the draft
2
2
  // validated into a definition, and when its trigger is next due. Every way a
3
- // definition can be created — HTTP, `pier task`, Pier's own owned task —
3
+ // definition can be created — `pier task`, Pier's own owned task —
4
4
  // arrives here, so a field is checked in one place or nowhere.
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { stat } from "node:fs/promises";
@@ -69,13 +69,27 @@ function parseTrigger(raw) {
69
69
  }
70
70
  /** Always computed from `from` (boot recomputes from *now*): cron runs missed
71
71
  * while Pier was down are skipped, never caught up — no double fire. */
72
- export function nextRunAt(trigger, from) {
72
+ function nextRunAt(trigger, from) {
73
73
  if (trigger.type === "manual")
74
74
  return null;
75
75
  if (trigger.type === "watch")
76
76
  return from + trigger.intervalSeconds * 1000;
77
77
  return new Cron(trigger.expression, { timezone: trigger.timezone }).nextRun(new Date(from))?.getTime() ?? null;
78
78
  }
79
+ function parseModel(raw) {
80
+ const model = record(raw);
81
+ if (!model)
82
+ throw new Error("agent model must be an object");
83
+ return {
84
+ provider: requiredString(model.provider, "model provider"),
85
+ id: requiredString(model.id, "model id"),
86
+ };
87
+ }
88
+ function parseThinking(raw) {
89
+ if (!isThinkingLevel(raw))
90
+ throw new Error("invalid agent thinking level");
91
+ return raw;
92
+ }
79
93
  function parseLaunch(raw) {
80
94
  if (raw === undefined)
81
95
  return undefined;
@@ -83,20 +97,19 @@ function parseLaunch(raw) {
83
97
  if (!value)
84
98
  throw new Error("agent launch policy must be an object");
85
99
  const launch = {};
86
- if (value.model !== undefined) {
87
- const model = record(value.model);
88
- if (!model)
89
- throw new Error("agent model must be an object");
90
- launch.model = {
91
- provider: requiredString(model.provider, "model provider"),
92
- id: requiredString(model.id, "model id"),
93
- };
100
+ if (value.model !== undefined)
101
+ launch.model = parseModel(value.model);
102
+ if (value.thinking !== undefined)
103
+ launch.thinking = parseThinking(value.thinking);
104
+ if (value.role !== undefined) {
105
+ if (value.role !== "lead")
106
+ throw new Error("agent role must be lead");
107
+ launch.role = "lead";
94
108
  }
95
- if (value.thinking !== undefined) {
96
- if (!isThinkingLevel(value.thinking)) {
97
- throw new Error("invalid agent thinking level");
98
- }
99
- launch.thinking = value.thinking;
109
+ if (value.design !== undefined) {
110
+ if (value.design !== true || launch.role !== "lead")
111
+ throw new Error("agent design must be true, on a lead");
112
+ launch.design = true;
100
113
  }
101
114
  return Object.keys(launch).length ? launch : undefined;
102
115
  }
@@ -136,7 +149,7 @@ export class TaskDefinitions {
136
149
  revision: 1,
137
150
  trigger: draft.trigger,
138
151
  action: draft.action,
139
- callback: draft.callback ?? { type: "none" },
152
+ callback: draft.callback ?? { type: "conversation" },
140
153
  timeoutSeconds: draft.timeoutSeconds ?? DEFAULT_TIMEOUT,
141
154
  nextRunAt: null,
142
155
  creator,
@@ -321,7 +334,7 @@ export class TaskDefinitions {
321
334
  if (raw === undefined)
322
335
  return undefined;
323
336
  const value = record(raw);
324
- if (!value || (value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
337
+ if (!value || (value.type !== "conversation" && value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
325
338
  throw new Error("invalid callback");
326
339
  }
327
340
  if (value.type !== "session")
@@ -1,6 +1,7 @@
1
1
  // One queued run carried to a result: dispatched by action kind, abortable,
2
2
  // settled exactly once. The lifecycle command.ts and agent.ts share.
3
3
  import { logger } from "../log.js";
4
+ import { settleCallback } from "./callbacks.js";
4
5
  import { runBash } from "./command.js";
5
6
  const log = logger("tasks");
6
7
  export class TaskExecution {
@@ -92,8 +93,7 @@ export class TaskExecution {
92
93
  log.debug(`${settled} (watch did not match)`);
93
94
  else
94
95
  log.info(settled);
95
- if (run.callbackSessionId)
96
- run.callbackState = "pending";
96
+ settleCallback(run, this.store);
97
97
  this.controllers.delete(run.id);
98
98
  try {
99
99
  this.store.saveRun(run);
@@ -13,12 +13,14 @@ export class TaskGroups {
13
13
  host;
14
14
  changed;
15
15
  unreachable;
16
+ headOf;
16
17
  outbox;
17
- constructor(store, router, host, changed, unreachable) {
18
+ constructor(store, router, host, changed, unreachable, headOf = (id) => id, milestone) {
18
19
  this.store = store;
19
20
  this.host = host;
20
21
  this.changed = changed;
21
22
  this.unreachable = unreachable;
23
+ this.headOf = headOf;
22
24
  this.outbox = new Outbox(router, {
23
25
  id: (group) => group.id,
24
26
  reload: (id) => this.store.getGroup(id),
@@ -31,6 +33,7 @@ export class TaskGroups {
31
33
  origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
32
34
  }),
33
35
  abandoned: (group, sessionId, why) => unreachable(sessionId, `the result of a ${String(group.memberRunIds.length)}-run group`, why),
36
+ milestone,
34
37
  });
35
38
  }
36
39
  /** Enqueues every member or none: a partially started group is worse than
@@ -141,7 +144,7 @@ export class TaskGroups {
141
144
  const group = this.store.getGroup(candidate.id);
142
145
  if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
143
146
  return;
144
- await this.outbox.deliver(group.callbackSessionId, [group]);
147
+ await this.outbox.deliver(this.headOf(group.callbackSessionId), [group]);
145
148
  }
146
149
  text(group) {
147
150
  const members = group.memberRunIds.map((id) => this.host.getRun(id));