@timqi/pier 0.0.9 → 0.0.15

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 (59) hide show
  1. package/dist/agent/events.js +53 -7
  2. package/dist/agent/listing.js +253 -0
  3. package/dist/agent/pi.js +177 -28
  4. package/dist/boards/boards.js +65 -16
  5. package/dist/boards/pier.css +1 -1
  6. package/dist/channels/attach.js +87 -0
  7. package/dist/channels/control.js +2 -2
  8. package/dist/channels/lark-api.js +38 -0
  9. package/dist/channels/lark-outbound.js +11 -2
  10. package/dist/channels/slack-api.js +36 -0
  11. package/dist/channels/slack-outbound.js +12 -2
  12. package/dist/channels/slack-tool.js +49 -9
  13. package/dist/channels/telegram-api.js +21 -2
  14. package/dist/channels/telegram.js +23 -8
  15. package/dist/cli.js +34 -0
  16. package/dist/core/identity.js +18 -0
  17. package/dist/core/inbound-file.js +3 -1
  18. package/dist/core/reply.js +2 -1
  19. package/dist/core/router.js +72 -0
  20. package/dist/db.js +78 -0
  21. package/dist/extensions/index.js +5 -2
  22. package/dist/extensions/web/artifacts.js +7 -2
  23. package/dist/extensions/web/tools.js +28 -8
  24. package/dist/limits.js +14 -0
  25. package/dist/main.js +47 -6
  26. package/dist/paths.js +6 -1
  27. package/dist/settings.js +44 -0
  28. package/dist/tasks/agent.js +18 -4
  29. package/dist/tasks/callbacks.js +20 -1
  30. package/dist/tasks/definitions.js +56 -12
  31. package/dist/tasks/execution.js +5 -1
  32. package/dist/tasks/groups.js +4 -4
  33. package/dist/tasks/messages.js +4 -2
  34. package/dist/tasks/runs.js +2 -2
  35. package/dist/tasks/service.js +16 -6
  36. package/dist/tasks/tool.js +0 -12
  37. package/dist/tools-task.js +155 -0
  38. package/dist/tools.js +875 -0
  39. package/dist/web/auth.js +5 -3
  40. package/dist/web/explorer.js +15 -2
  41. package/dist/web/files.js +1 -1
  42. package/dist/web/instance.js +165 -36
  43. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-xcUrfRRs.js} +1 -1
  44. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  45. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  46. package/dist/web/public/index.html +5 -8
  47. package/dist/web/public/sw.js +4 -0
  48. package/dist/web/push.js +22 -7
  49. package/dist/web/repos.js +75 -0
  50. package/dist/web/server.js +145 -64
  51. package/dist/web/session-state.js +33 -51
  52. package/dist/web/types.js +5 -0
  53. package/package.json +1 -1
  54. package/skills/pier-boards/SKILL.md +23 -13
  55. package/skills/pier-help/SKILL.md +1 -1
  56. package/skills/pier-slack/SKILL.md +21 -1
  57. package/skills/pier-tasks/SKILL.md +2 -2
  58. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  59. package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
@@ -16,11 +16,14 @@ const FULL_MAX_CHARS = 60_000;
16
16
  * not be the binding constraint: `DEFAULT_CONTEXT_CHARS` is what we are willing
17
17
  * to hand back (6k characters, which is ~1.5k English tokens and ~4k Chinese
18
18
  * ones), so a budget below that only produces briefings that stop mid-sentence.
19
- * It used to be 900, half the smaller of those. Output tokens are not the cost
20
- * here either a hosted search is worth an order of magnitude more than the
21
- * prose about it and a truncated answer is paid for twice.
19
+ * It used to be 900, half the smaller of those, and then 2k, which is the same
20
+ * bug in Chinese: it covered the English reading of 6k characters and cut every
21
+ * CJK briefing at the point this comment claimed was fixed. So the budget is
22
+ * the *larger* reading plus room for the search calls themselves. Output tokens
23
+ * are not the cost here either — a hosted search is worth an order of magnitude
24
+ * more than the prose about it — and a truncated answer is paid for twice.
22
25
  */
23
- const SEARCH_TOKENS = 2_000;
26
+ const SEARCH_TOKENS = 4_500;
24
27
  /**
25
28
  * Same rule for a fetch, and one dial for it: `mode` says how much of the page
26
29
  * matters, so it decides all three sizes — what the provider fetches, what the
@@ -107,23 +110,36 @@ export const webSearch = defineTool({
107
110
  const run = { ctx, query: params.query, domains, backend: params.backend, signal: until, note };
108
111
  let outcome = await runSearch({ ...run, mode, maxUses: searchRounds(mode) });
109
112
  const wantedLanguage = languageLabel(params.query);
110
- const inLanguage = (o) => preservesLanguage(params.query, o.queries[0]?.query);
111
- let preserved = inLanguage(outcome);
113
+ const strayed = (o) => o.queries.filter((q) => !preservesLanguage(params.query, q.query)).map((q) => q.query);
114
+ // The prompt pins the first query verbatim, so auditing only that one
115
+ // audits the query that cannot fail. `preserve` promised every search
116
+ // stays in the language, so it audits all of them; auto and expand buy
117
+ // English supplements on purpose, so there the first query still decides
118
+ // and the strays are named in `details` instead of warned about.
119
+ const inLanguage = (o, auditAll) => auditAll
120
+ ? o.queries.length > 0 && strayed(o).length === 0
121
+ : preservesLanguage(params.query, o.queries[0]?.query);
122
+ let preserved = inLanguage(outcome, mode === "preserve");
112
123
  if (outcome.queries.length && !preserved) {
113
124
  note(`the backend left ${wantedLanguage} — searching again, that language only`);
114
125
  const retried = await runSearch({ ...run, mode: "preserve", maxUses: 1 });
115
126
  // Only if it worked. The retry is one narrowed search against the
116
127
  // first's three rounds, so a retry that *also* leaves the language is
117
128
  // a worse answer, and swapping it in spent a search to get there.
118
- if (inLanguage(retried)) {
129
+ if (inLanguage(retried, true)) {
119
130
  outcome = retried;
120
131
  preserved = true;
121
132
  }
122
133
  }
123
134
  const auditAvailable = outcome.queries.length > 0;
135
+ const offLanguage = strayed(outcome);
136
+ // No query metadata means the audit never ran — under `preserve`, the one
137
+ // mode that promised it, an unaudited answer must not read like a clean one.
124
138
  const warning = auditAvailable && !preserved
125
139
  ? `Warning: the search backend translated the query out of ${wantedLanguage} despite strict preservation.`
126
- : "";
140
+ : mode === "preserve" && !auditAvailable
141
+ ? "Note: the backend returned no query metadata, so strict language preservation could not be audited."
142
+ : "";
127
143
  // A briefing that stopped at the output ceiling reads exactly like a
128
144
  // finished one; the caller decides whether to ask again, but only if it
129
145
  // is told (§5b).
@@ -153,6 +169,10 @@ export const webSearch = defineTool({
153
169
  languageMode: mode,
154
170
  queries: outcome.queries,
155
171
  queryLanguagePreserved: auditAvailable ? preserved : undefined,
172
+ // Legitimate under auto/expand, so not a warning — but the caller
173
+ // cannot weigh a briefing built partly from English searches if it
174
+ // is never told which searches those were.
175
+ queriesOffLanguage: offLanguage.length ? offLanguage : undefined,
156
176
  originalQueryVerbatim: auditAvailable
157
177
  ? outcome.queries[0]?.query === params.query
158
178
  : undefined,
package/dist/limits.js ADDED
@@ -0,0 +1,14 @@
1
+ // The numbers more than one area has to agree on.
2
+ //
3
+ // Not policy or behaviour — a value several modules must spell the same way,
4
+ // and a wrong copy makes two surfaces disagree about one session: a title
5
+ // truncated to a different length depending on which path derived it.
6
+ //
7
+ // Here because a leaf may be imported by every area and depends on nothing
8
+ // itself, which is the only shape that fits: agent/ derives a title and must
9
+ // not import core/, web/ derives one too and must not import agent/, and the
10
+ // browser needs the same numbers with no runtime behind them.
11
+ /** How much of a message becomes a title, wherever one is derived: the listing
12
+ * reading a transcript (agent/listing.ts), a rename's fallback (agent/pi.ts),
13
+ * the fill at first prompt and the rename boundary (web/). */
14
+ export const SESSION_TITLE_MAX = 80;
package/dist/main.js CHANGED
@@ -14,7 +14,7 @@ import { registerChannelRoutes } from "./channels/routes.js";
14
14
  import { ChannelRuntime } from "./channels/runtime.js";
15
15
  import { SlackApi } from "./channels/slack-api.js";
16
16
  import { SlackDirectory } from "./channels/slack-directory.js";
17
- import { handleSlackTool, slackToolSpec } from "./channels/slack-tool.js";
17
+ import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
18
18
  import { parseConversation as parseSlackConversation } from "./channels/slack.js";
19
19
  import { EventHub } from "./core/hub.js";
20
20
  import { pierDb } from "./db.js";
@@ -28,6 +28,8 @@ import { TaskService } from "./tasks/service.js";
28
28
  import { TaskStore } from "./tasks/store.js";
29
29
  import { taskToolSpec } from "./tasks/tool.js";
30
30
  import { PIER_HOME, pierPath, resolveAgentDir } from "./paths.js";
31
+ import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath } from "./tools.js";
32
+ import { toolsTask } from "./tools-task.js";
31
33
  import { Secrets } from "./secrets.js";
32
34
  import { startUpdate, unitPath, updaterProblem } from "./service.js";
33
35
  import { SettingsStore } from "./settings.js";
@@ -52,6 +54,10 @@ const log = logger("pier");
52
54
  // again from this instance's own PIER_HOME.
53
55
  process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
54
56
  process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
57
+ // Ahead of everything Pier spawns — sessions, tasks, the Web Terminal all
58
+ // inherit this process's env. A tool switched on in the Console is Pier's
59
+ // copy at Pier's version, so it goes first, not last.
60
+ prependPath(process.env);
55
61
  // First, and explicitly: every store below shares this one connection, and a
56
62
  // schema that cannot be migrated must stop the process here — before a port is
57
63
  // open and before anything has written a row.
@@ -92,8 +98,9 @@ const factory = new PiAgentFactory([
92
98
  const config = channelStore.get("slack");
93
99
  return config.token ? new SlackApi(config.token, config.appToken) : null;
94
100
  },
95
- // Which Slack thread this session is answering, so "post here" needs no
96
- // ids. Looked up per call: the mapping is durable, the session is not.
101
+ // Which Slack thread this session is answering, so "post here" needs
102
+ // no ids. Looked up per call: the mapping is durable, the session is
103
+ // not.
97
104
  here: (sessionId) => {
98
105
  const key = router.conversationOf(sessionId);
99
106
  if (key?.channelId !== "slack")
@@ -102,7 +109,10 @@ const factory = new PiAgentFactory([
102
109
  return channel && threadTs ? { channel, threadTs } : null;
103
110
  },
104
111
  log: (m) => logger("slack.tool").warn(m),
105
- }, params, callerSessionId)),
112
+ }, params, callerSessionId),
113
+ // No Slack, no schema: an unconfigured tool would sit in every prompt of
114
+ // every session and be able to answer nothing.
115
+ () => slackToolAvailable(channelStore)),
106
116
  ],
107
117
  // Called per session open, so a setting changed in the Console reaches the
108
118
  // next session without a restart.
@@ -135,6 +145,16 @@ tasks = new TaskService(new TaskStore(db), factory, router, hub, {
135
145
  modelMenu: () => settings.get().modelMenu,
136
146
  });
137
147
  tasks.start();
148
+ // The managed CLI tools (src/tools.ts), and the daily task that keeps them
149
+ // current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
150
+ // wired here because tools.ts may not import tasks/.
151
+ const managedTools = new ManagedTools();
152
+ const toolsUpdate = toolsTask(tasks);
153
+ // Before any route exists: two first flips could otherwise both find no task
154
+ // and create one each. A failure here is logged, and the next flip retries.
155
+ const reconciled = await toolsUpdate.reconcile();
156
+ if ("problem" in reconciled)
157
+ log.error(`tools cannot be managed: ${reconciled.problem}`);
138
158
  channelStore = new ChannelStore(db, secrets);
139
159
  const control = createControl({ router, factory, conversations, store: channelStore });
140
160
  const channels = new ChannelRuntime(channelStore, router, control);
@@ -283,7 +303,7 @@ registerPushRoutes(app, {
283
303
  hub,
284
304
  unread: (id) => sessionState.unread(id),
285
305
  channelOf: (id) => router.conversationOf(id)?.channelId,
286
- name: (id) => sessionState.name(id),
306
+ summary: (id) => factory.find(id),
287
307
  publicUrl: () => settings.get().publicUrl,
288
308
  });
289
309
  app.route("/", createServer({
@@ -296,7 +316,28 @@ app.route("/", createServer({
296
316
  settings,
297
317
  // The catalog is code, so the composition root is where it is read: web/
298
318
  // gets names and summaries, not a module that imports the Pi SDK.
299
- extensions: () => bundledInfo(settings.get().extensions),
319
+ // One list, assembled where both halves are visible: the extensions Pier
320
+ // loads from inside itself and the binaries it installs are the same kind of
321
+ // switch to the person flipping it, and rtk is both.
322
+ catalog: async () => {
323
+ const { extensions, tools, customTools } = settings.get();
324
+ return {
325
+ entries: [...bundledInfo(extensions), ...await managedTools.status(tools, customTools)],
326
+ toolsTaskId: toolsUpdate.id(),
327
+ };
328
+ },
329
+ // Names only, and the same two lists the catalog above is built from: the
330
+ // route validates a switch against what this Pier *can* switch, which is
331
+ // code, never against a catalog whose custom half the request may be
332
+ // rewriting.
333
+ names: { extensions: bundledInfo([]).map((entry) => entry.name), tools: MANAGED.map((tool) => tool.name) },
334
+ onToolsChanged: toolsUpdate.changed,
335
+ // The rule lives with the installer; the names the bundled catalog already
336
+ // owns live with the extensions. Only here are both in scope.
337
+ validateCustomTools: (raw) => {
338
+ const validated = normalizeCustomTools(raw, bundledInfo([]).map((entry) => entry.name));
339
+ return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
340
+ },
300
341
  secrets,
301
342
  updates,
302
343
  updater,
package/dist/paths.js CHANGED
@@ -7,8 +7,13 @@
7
7
  // depend on and that depends on nothing.
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
+ /** Empty is unset, not a value: `PIER_HOME=` in a shell would otherwise
11
+ * resolve every path below relative to the working directory, and the
12
+ * database, the boards and the master key would land wherever the process
13
+ * happened to start. Pure, because that rule is worth a test. */
14
+ export const resolveHome = (value, home = homedir()) => value || join(home, ".pier");
10
15
  /** `$PIER_HOME`, or `~/.pier`. Fixed for the life of the process. */
11
- export const PIER_HOME = process.env.PIER_HOME ?? join(homedir(), ".pier");
16
+ export const PIER_HOME = resolveHome(process.env.PIER_HOME);
12
17
  /** A path inside it — `pierPath("boards")`. */
13
18
  export const pierPath = (...parts) => join(PIER_HOME, ...parts);
14
19
  /** The one SQLite file; every store opens this same path. In its own
package/dist/settings.js CHANGED
@@ -9,6 +9,11 @@
9
9
  import { isThinkingLevel } from "./core/types.js";
10
10
  import { pierDb } from "./db.js";
11
11
  import { logger } from "./log.js";
12
+ // The one place the custom-tool vocabulary lives (names, ubix sources, the
13
+ // names Pier already owns). Imported rather than copied: a second validator
14
+ // would be the third-copy bug one release later, and tools.ts is root-layer
15
+ // like this file, so nothing crosses a seam.
16
+ import { normalizeCustomTools } from "./tools.js";
12
17
  const log = logger("settings");
13
18
  /**
14
19
  * `""` clears it, `null` rejects it. Rejecting rather than repairing: a
@@ -94,6 +99,15 @@ export function normalizeTerminalInitCommand(raw) {
94
99
  * setting it cannot currently explain.
95
100
  */
96
101
  export function normalizeExtensions(raw) {
102
+ return normalizeNames(raw);
103
+ }
104
+ /** Same shape, same contract, same cap — the managed-tool set (src/tools.ts).
105
+ * Shape only again: tools.ts owns the catalog, and a name it does not know is
106
+ * ignored there rather than rejected here, so a downgrade cannot lose one. */
107
+ export function normalizeTools(raw) {
108
+ return normalizeNames(raw);
109
+ }
110
+ function normalizeNames(raw) {
97
111
  if (!Array.isArray(raw) || raw.length > 32)
98
112
  return null;
99
113
  const names = new Set();
@@ -119,6 +133,8 @@ export class SettingsStore {
119
133
  autoUpdate: this.#value("autoUpdate") === "1",
120
134
  terminalInitCommand: this.#value("terminalInitCommand") ?? "",
121
135
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
136
+ tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
137
+ customTools: this.#json("customTools", normalizeCustomTools, "a list of {name, spec}") ?? [],
122
138
  };
123
139
  }
124
140
  /**
@@ -168,6 +184,34 @@ export class SettingsStore {
168
184
  this.#set("extensions", JSON.stringify(names));
169
185
  return this.get();
170
186
  }
187
+ /** Same contract again: hand this `normalizeTools`'s output. */
188
+ setTools(names) {
189
+ this.#set("tools", JSON.stringify(names));
190
+ return this.get();
191
+ }
192
+ /** Same contract again: hand this `normalizeCustomTools`'s output. */
193
+ setCustomTools(tools) {
194
+ this.#set("customTools", JSON.stringify(tools));
195
+ return this.get();
196
+ }
197
+ /**
198
+ * Several setters as one write. A request that declares a tool *and* the
199
+ * switch that turns it on must not be able to store one without the other:
200
+ * half of that pair is a switch nobody can explain — on and undeclared, or
201
+ * declared and invisible.
202
+ */
203
+ transact(work) {
204
+ this.#db.exec("BEGIN IMMEDIATE");
205
+ try {
206
+ const result = work();
207
+ this.#db.exec("COMMIT");
208
+ return result;
209
+ }
210
+ catch (err) {
211
+ this.#db.exec("ROLLBACK");
212
+ throw err;
213
+ }
214
+ }
171
215
  #set(key, value) {
172
216
  this.#db.prepare(`
173
217
  INSERT INTO settings(key, value) VALUES (?, ?)
@@ -1,5 +1,6 @@
1
1
  import { quietLabel, splitReply } from "../core/reply.js";
2
2
  import { Router } from "../core/router.js";
3
+ import { runSource } from "./callbacks.js";
3
4
  import { TaskMessenger } from "./messages.js";
4
5
  import { TaskStore } from "./store.js";
5
6
  const MAX_ACTIVE_AGENTS = 4;
@@ -44,6 +45,12 @@ export class AgentTaskRunner {
44
45
  const session = await this.resolveSession(run, action);
45
46
  return await this.withSession(session.id, async () => {
46
47
  await this.waitUntilIdle(session, signal);
48
+ // Task requests come seconds apart — the 1h Anthropic cache-write premium
49
+ // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
50
+ // resolveSession: this covers create, fork, reuse and resume alike, on
51
+ // every attempt — and only after the session is idle, so a reused
52
+ // interactive session's in-flight turn keeps its 1h writes.
53
+ session.setCacheRetention("short");
47
54
  start();
48
55
  // No input is no block: `<task_input>\nnull\n</task_input>` is four
49
56
  // lines telling the agent nothing, on every run that has no input.
@@ -55,6 +62,10 @@ export class AgentTaskRunner {
55
62
  const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
56
63
  run.context.sessionId = session.id;
57
64
  run.context.model = session.model;
65
+ // The level the session settled on, not the one the task asked for:
66
+ // an unspecified effort inherits the caller's, and the card in the
67
+ // subagent's own transcript should say which one that was.
68
+ run.context.thinking = session.thinkingLevel;
58
69
  run.context.renderedPrompt = prompt;
59
70
  this.store.saveRun(run);
60
71
  let text = "";
@@ -83,6 +94,7 @@ export class AgentTaskRunner {
83
94
  taskId: run.taskId,
84
95
  runId: run.id,
85
96
  sourceSessionId: run.sourceSessionId,
97
+ source: runSource(run),
86
98
  }, "prompt");
87
99
  await Promise.resolve();
88
100
  this.messages.deliverPendingControls(run);
@@ -102,6 +114,10 @@ export class AgentTaskRunner {
102
114
  return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
103
115
  }
104
116
  finally {
117
+ // The run is what earns "short"; a reused interactive session goes
118
+ // back to chat afterwards. For task-created sessions this is moot —
119
+ // idle until the next run downgrades them again.
120
+ session.setCacheRetention("long");
105
121
  signal.removeEventListener("abort", abort);
106
122
  unsubscribe();
107
123
  }
@@ -115,9 +131,8 @@ export class AgentTaskRunner {
115
131
  if (run.targetSessionId) {
116
132
  return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
117
133
  }
118
- const listed = await this.factory.list();
119
134
  const source = run.sourceSessionId
120
- ? listed.find((session) => session.id === run.sourceSessionId)
135
+ ? await this.factory.find(run.sourceSessionId)
121
136
  : undefined;
122
137
  const policy = action.session;
123
138
  let cwd;
@@ -130,7 +145,7 @@ export class AgentTaskRunner {
130
145
  cwd = policy.cwd;
131
146
  }
132
147
  else if (policy.mode === "reuse") {
133
- cwd = listed.find((session) => session.id === policy.sessionId)?.cwd ?? "";
148
+ cwd = (await this.factory.find(policy.sessionId))?.cwd ?? "";
134
149
  }
135
150
  else {
136
151
  cwd = policy.cwd ?? source?.cwd ?? "";
@@ -145,7 +160,6 @@ export class AgentTaskRunner {
145
160
  model: action.launch?.model ??
146
161
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
147
162
  thinking: action.launch?.thinking,
148
- capabilities: action.launch?.capabilities,
149
163
  };
150
164
  const session = run.sessionMode === "fork"
151
165
  ? await this.factory.fork(run.sourceSessionId, opts)
@@ -3,6 +3,20 @@
3
3
  import { Router } from "../core/router.js";
4
4
  import { Outbox } from "./outbox.js";
5
5
  import { TaskStore } from "./store.js";
6
+ /** How a run is named to the session that gets its result: the run id, and the
7
+ * session that did the work — a relayer's next move is a deep link to it
8
+ * (`#/session/<id>`), and without this line that costs a second tool call.
9
+ * Shared with the group callback, which has always carried both. */
10
+ export const runRef = (run) => `Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`;
11
+ /** What the card in the recipient's transcript says the input came from. Part
12
+ * of the run vocabulary for the same reason `runRef` is: the callback, the
13
+ * subagent messages and the delegation prompt all name a run, and three
14
+ * spellings of "which task, on what model" would be three cards. */
15
+ export const runSource = (run) => ({
16
+ taskName: run.context.definition.name,
17
+ ...(run.context.model ? { model: run.context.model } : {}),
18
+ ...(run.context.thinking ? { thinking: run.context.thinking } : {}),
19
+ });
6
20
  export function runResultText(run) {
7
21
  let result = run.error ?? "No result";
8
22
  if (run.result?.type === "agent")
@@ -35,6 +49,11 @@ export class TaskCallbacks {
35
49
  runId: runs[0].id,
36
50
  sourceSessionId: runs[0].targetSessionId,
37
51
  runIds: runs.map((run) => run.id),
52
+ // Only when the input is about one run: a batch is several tasks in
53
+ // one delivery, and the first one's name and model as the card's
54
+ // caption would attribute every other result to it. The text names
55
+ // each run; the card says nothing rather than something false.
56
+ ...(runs.length === 1 ? { source: runSource(runs[0]) } : {}),
38
57
  },
39
58
  }),
40
59
  describe: (run) => `the result of "${run.context.definition.name}"`,
@@ -69,7 +88,7 @@ export class TaskCallbacks {
69
88
  text(runs) {
70
89
  const sections = runs.map((run) => [
71
90
  `Task "${run.context.definition.name}" finished with state: ${run.state}`,
72
- `Run: ${run.id}`,
91
+ runRef(run),
73
92
  "",
74
93
  runResultText(run),
75
94
  ].join("\n"));
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomBytes } from "node:crypto";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { Cron } from "croner";
4
4
  import { isThinkingLevel } from "../core/types.js";
@@ -7,6 +7,46 @@ import { Router } from "../core/router.js";
7
7
  import { TaskStore } from "./store.js";
8
8
  const DEFAULT_TIMEOUT = 900;
9
9
  const MIN_WATCH_SECONDS = 5;
10
+ /** Crockford's base32, lowercased: i, l, o and u are gone — the three that
11
+ * misread as 1/0 and the one that completes most accidental words — and one
12
+ * case throughout, so a model re-types an id it read verbatim. */
13
+ const ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
14
+ /** The one byte → symbol step, exported only so a test can walk all 256 byte
15
+ * values: a repeated or omitted character in the alphabet above biases every
16
+ * id ever minted, and no sample of finished ids can show it. */
17
+ export const idSymbol = (byte) => ID_ALPHABET.charAt(byte & 31);
18
+ /** The id every task record is minted with — runs, definitions, groups and
19
+ * messages alike — because these ids ride through model context constantly:
20
+ * every run summary, callback, get, steer and reply echoes one, and a UUID
21
+ * spends ~12 tokens where this spends ~6. Nothing parses or orders by them —
22
+ * every comparison in the area is string equality and every listing orders by
23
+ * a timestamp column — so rows minted as UUIDs before this keep working
24
+ * untouched; there is nothing to migrate. It lives here rather than in
25
+ * types.ts because the browser type-checks that file and it stays node-free.
26
+ *
27
+ * Sixteen characters, 80 bits (`& 31` is unbiased because 256 is a multiple
28
+ * of 32). Sixty would have read the same and cost a token less, but a watch
29
+ * task on a 5-second interval mints ~6M runs a year, and a collision here is
30
+ * not an error: `saveRun`'s ON CONFLICT DO UPDATE would quietly overwrite the
31
+ * older run with the newer one. Four more characters buy ~16 million times
32
+ * the headroom for four bytes — cheaper than the alternative fix, which is a
33
+ * strict INSERT and a retry loop on every one of the four mint sites. */
34
+ export const newId = () => Array.from(randomBytes(16), idSymbol).join("");
35
+ /**
36
+ * Seam decision (tasks/): a definition Pier's own code created is reconciled by
37
+ * that code, and edited by nobody.
38
+ *
39
+ * `creator` is `"http"` for the Console and `session:<id>` for the task tool.
40
+ * Anything else is an instance-layer owner — today the tools update task
41
+ * (src/tools-task.ts), whose script a switch in Settings runs on demand and
42
+ * cron runs nightly. Both
43
+ * public surfaces could rename it, point it at another script, pause it or
44
+ * archive it, and the switch would go on claiming Pier keeps the tools current
45
+ * while the run did something else entirely. The owner names itself in `by`;
46
+ * neither the routes nor the tool has a `by` to pass, so this one function
47
+ * closes both.
48
+ */
49
+ const ownerOf = (task) => task.creator === "http" || task.creator.startsWith("session:") ? null : task.creator;
10
50
  export const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
11
51
  ? value
12
52
  : null;
@@ -76,12 +116,6 @@ function parseLaunch(raw) {
76
116
  }
77
117
  launch.thinking = value.thinking;
78
118
  }
79
- if (value.capabilities !== undefined) {
80
- if (value.capabilities !== "read" && value.capabilities !== "write") {
81
- throw new Error("agent capabilities must be read or write");
82
- }
83
- launch.capabilities = value.capabilities;
84
- }
85
119
  return Object.keys(launch).length ? launch : undefined;
86
120
  }
87
121
  export class TaskDefinitions {
@@ -110,7 +144,7 @@ export class TaskDefinitions {
110
144
  const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw);
111
145
  const now = Date.now();
112
146
  const task = {
113
- id: randomUUID(),
147
+ id: newId(),
114
148
  kind,
115
149
  name: draft.name,
116
150
  description: draft.description ?? "",
@@ -133,8 +167,9 @@ export class TaskDefinitions {
133
167
  this.changed();
134
168
  return task;
135
169
  }
136
- async update(id, raw) {
170
+ async update(id, raw, by) {
137
171
  const old = this.get(id);
172
+ this.assertOwner(old, by, "edited");
138
173
  if (old.archived)
139
174
  throw new Error("archived tasks cannot be edited");
140
175
  const draft = await this.parseDraft(raw);
@@ -158,8 +193,9 @@ export class TaskDefinitions {
158
193
  this.changed();
159
194
  return task;
160
195
  }
161
- setEnabled(id, enabled) {
196
+ setEnabled(id, enabled, by) {
162
197
  const task = this.get(id);
198
+ this.assertOwner(task, by, enabled ? "resumed" : "paused");
163
199
  if (task.archived && enabled)
164
200
  throw new Error("archived tasks cannot be resumed");
165
201
  task.enabled = enabled;
@@ -169,8 +205,9 @@ export class TaskDefinitions {
169
205
  this.changed();
170
206
  return task;
171
207
  }
172
- archive(id) {
208
+ archive(id, by) {
173
209
  const task = this.get(id);
210
+ this.assertOwner(task, by, "archived");
174
211
  task.archived = true;
175
212
  task.enabled = false;
176
213
  task.nextRunAt = null;
@@ -200,7 +237,14 @@ export class TaskDefinitions {
200
237
  }
201
238
  async sessionExists(sessionId) {
202
239
  return this.router.stateOf(sessionId) !== undefined ||
203
- (await this.factory.list()).some((session) => session.id === sessionId);
240
+ (await this.factory.find(sessionId)) !== undefined;
241
+ }
242
+ /** The guard `ownerOf` exists for, on the three ways a definition changes. */
243
+ assertOwner(task, by, what) {
244
+ const owner = ownerOf(task);
245
+ if (owner && by !== owner) {
246
+ throw new Error(`"${task.name}" is Pier's own ${owner} task: it is reconciled by Pier, not ${what}`);
247
+ }
204
248
  }
205
249
  async parseDraft(raw) {
206
250
  const value = record(raw);
@@ -56,7 +56,11 @@ export class TaskExecution {
56
56
  run.result = await this.executeAction(run, controller.signal);
57
57
  run.state = "succeeded";
58
58
  if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
59
- this.definitions.setEnabled(definition.id, false);
59
+ // As the definition's own creator: a one-shot watch retiring itself is
60
+ // the task layer keeping its own promise, not a surface editing
61
+ // somebody's task, and the owner guard (definitions.ts) would
62
+ // otherwise fail the run that had just succeeded.
63
+ this.definitions.setEnabled(definition.id, false, definition.creator);
60
64
  }
61
65
  }
62
66
  catch (error) {
@@ -1,7 +1,7 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { Router } from "../core/router.js";
3
2
  import { logger } from "../log.js";
4
- import { runResultText } from "./callbacks.js";
3
+ import { runRef, runResultText } from "./callbacks.js";
4
+ import { newId } from "./definitions.js";
5
5
  import { Outbox } from "./outbox.js";
6
6
  import { TaskStore } from "./store.js";
7
7
  import { isTerminal } from "./types.js";
@@ -61,7 +61,7 @@ export class TaskGroups {
61
61
  }
62
62
  create(join, invokedBySessionId, callbackSessionId) {
63
63
  const group = {
64
- id: randomUUID(),
64
+ id: newId(),
65
65
  join,
66
66
  invokedBySessionId,
67
67
  callbackSessionId,
@@ -137,7 +137,7 @@ export class TaskGroups {
137
137
  const sections = members.map((run) => {
138
138
  const head = [
139
139
  `- "${run.context.definition.name}" \u2014 state: ${run.state}`,
140
- ` Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`,
140
+ ` ${runRef(run)}`,
141
141
  ];
142
142
  const decision = this.host.openDecisionId(run.id);
143
143
  if (decision)
@@ -1,7 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { EventHub } from "../core/hub.js";
3
2
  import { Router } from "../core/router.js";
4
3
  import { logger } from "../log.js";
4
+ import { runSource } from "./callbacks.js";
5
+ import { newId } from "./definitions.js";
5
6
  import { TaskStore } from "./store.js";
6
7
  import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
7
8
  const log = logger("tasks");
@@ -152,7 +153,7 @@ export class TaskMessenger {
152
153
  }
153
154
  create(run, kind, fromSessionId, toSessionId, content, replyTo) {
154
155
  const message = {
155
- id: randomUUID(),
156
+ id: newId(),
156
157
  runId: run.id,
157
158
  kind,
158
159
  fromSessionId,
@@ -319,6 +320,7 @@ export class TaskMessenger {
319
320
  sourceSessionId: message.fromSessionId,
320
321
  messageId: message.id,
321
322
  messageKind: message.kind,
323
+ source: runSource(run),
322
324
  };
323
325
  }
324
326
  require(id) {
@@ -1,6 +1,6 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { logger } from "../log.js";
3
2
  import { TaskCallbacks } from "./callbacks.js";
3
+ import { newId } from "./definitions.js";
4
4
  import { TaskStore } from "./store.js";
5
5
  const log = logger("tasks");
6
6
  const MAX_DEPTH = 2;
@@ -19,7 +19,7 @@ export class TaskRunQueue {
19
19
  this.changed = changed;
20
20
  }
21
21
  enqueue(definition, input, source, parentRunId, provenance) {
22
- const id = randomUUID();
22
+ const id = newId();
23
23
  const parent = parentRunId ? this.getRun(parentRunId) : null;
24
24
  const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
25
25
  const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
@@ -130,6 +130,14 @@ export class TaskService {
130
130
  activeRunCount() {
131
131
  return this.store.countActiveRuns();
132
132
  }
133
+ /** The run this task has in flight, if any. The store already answers this
134
+ * for the overlap guard (runs.ts); a caller that has just been refused as
135
+ * an overlap needs the same answer to know what to wait for, and scanning
136
+ * run history for it finds nothing once the skipped rows outnumber the
137
+ * window. */
138
+ activeRun(taskId) {
139
+ return this.store.findActiveRun(taskId);
140
+ }
133
141
  list() {
134
142
  return this.definitions.list();
135
143
  }
@@ -139,14 +147,16 @@ export class TaskService {
139
147
  create(raw, creator = "http") {
140
148
  return this.definitions.create(raw, creator);
141
149
  }
142
- update(id, raw) {
143
- return this.definitions.update(id, raw);
150
+ /** `by` is how the code that owns a definition says so; the HTTP routes and
151
+ * the task tool have none, which is what closes both (definitions.ts). */
152
+ update(id, raw, by) {
153
+ return this.definitions.update(id, raw, by);
144
154
  }
145
- setEnabled(id, enabled) {
146
- return this.definitions.setEnabled(id, enabled);
155
+ setEnabled(id, enabled, by) {
156
+ return this.definitions.setEnabled(id, enabled, by);
147
157
  }
148
- archive(id) {
149
- return this.definitions.archive(id);
158
+ archive(id, by) {
159
+ return this.definitions.archive(id, by);
150
160
  }
151
161
  sessionExists(sessionId) {
152
162
  return this.definitions.sessionExists(sessionId);