@timqi/pier 0.2.0 → 0.3.1

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 (77) hide show
  1. package/dist/agent/listing.js +14 -10
  2. package/dist/agent/pi.js +2 -2
  3. package/dist/agent/roles.js +5 -4
  4. package/dist/channels/conversations.js +4 -4
  5. package/dist/channels/lark-outbound.js +5 -0
  6. package/dist/channels/lark.js +25 -5
  7. package/dist/channels/runtime.js +47 -2
  8. package/dist/channels/slack-outbound.js +7 -0
  9. package/dist/channels/slack-thread.js +65 -0
  10. package/dist/channels/slack.js +27 -72
  11. package/dist/cli.js +7 -2
  12. package/dist/core/chain.js +27 -5
  13. package/dist/core/identity.js +2 -2
  14. package/dist/core/inbound-file.js +9 -3
  15. package/dist/core/reply.js +17 -3
  16. package/dist/core/search.js +65 -0
  17. package/dist/core/types.js +2 -0
  18. package/dist/db.js +8 -0
  19. package/dist/drain.js +1 -1
  20. package/dist/main.js +21 -5
  21. package/dist/settings.js +1 -1
  22. package/dist/socket.js +1 -0
  23. package/dist/tasks/agent.js +1 -1
  24. package/dist/tasks/callbacks.js +1 -1
  25. package/dist/tasks/execution.js +9 -3
  26. package/dist/tasks/open-items.js +53 -35
  27. package/dist/tasks/service.js +22 -1
  28. package/dist/tasks/store.js +5 -2
  29. package/dist/tasks/types.js +0 -2
  30. package/dist/tools.js +10 -3
  31. package/dist/web/public/assets/{code-BZwvPani.js → code-CWT27KH1.js} +1 -1
  32. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  33. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  34. package/dist/web/public/assets/{explorer-DDoVzMLQ.js → explorer-BoU77faI.js} +4 -4
  35. package/dist/web/public/assets/explorer-BoU77faI.js.br +0 -0
  36. package/dist/web/public/assets/explorer-BoU77faI.js.gz +0 -0
  37. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  38. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  39. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  40. package/dist/web/public/assets/index-DqLJpDE0.js +14 -0
  41. package/dist/web/public/assets/index-DqLJpDE0.js.br +0 -0
  42. package/dist/web/public/assets/index-DqLJpDE0.js.gz +0 -0
  43. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  44. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  45. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  46. package/dist/web/public/assets/settings-BprIId_R.js +9 -0
  47. package/dist/web/public/assets/settings-BprIId_R.js.br +0 -0
  48. package/dist/web/public/assets/settings-BprIId_R.js.gz +0 -0
  49. package/dist/web/public/index.html +3 -4
  50. package/dist/web/public/index.html.br +0 -0
  51. package/dist/web/public/index.html.gz +0 -0
  52. package/dist/web/push.js +2 -2
  53. package/dist/web/server.js +2 -4
  54. package/dist/web/session-state.js +1 -2
  55. package/package.json +1 -1
  56. package/skills/pier-help/SKILL.md +6 -1
  57. package/skills/pier-search/SKILL.md +27 -0
  58. package/skills/pier-tasks/SKILL.md +17 -3
  59. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  60. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  61. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  62. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  63. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +0 -72
  64. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  65. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  66. package/dist/web/public/assets/index-BpFzCwlo.js +0 -7
  67. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  68. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  69. package/dist/web/public/assets/index-wAGxFFPj.css +0 -2
  70. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  71. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  72. package/dist/web/public/assets/report-zjE8Bt8S.js +0 -8
  73. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  74. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  75. package/dist/web/public/assets/settings-Cv7rbeqf.js +0 -9
  76. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  77. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
@@ -72,14 +72,20 @@ export function replaceOutsideCode(text, pattern, replace) {
72
72
  const skip = codeRanges(text);
73
73
  let out = "";
74
74
  let last = 0;
75
- for (const match of text.matchAll(pattern)) {
75
+ const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
76
+ for (let match = re.exec(text); match; match = re.exec(text)) {
76
77
  const start = match.index;
77
78
  // A match is code when it begins in code; one that merely holds a span
78
- // (`<open>run \`x\`</open>`) is prose around it.
79
- if (skip.some(([from, to]) => start >= from && start < to))
79
+ // (`<open>run \`x\`</open>`) is prose around it. A skipped match resumes the
80
+ // scan one char on, so a quoted `<done>` cannot swallow the real marker after it.
81
+ if (skip.some(([from, to]) => start >= from && start < to)) {
82
+ re.lastIndex = start + 1;
80
83
  continue;
84
+ }
81
85
  out += text.slice(last, start) + replace(match);
82
86
  last = start + match[0].length;
87
+ if (!match[0])
88
+ re.lastIndex++;
83
89
  }
84
90
  return out + text.slice(last);
85
91
  }
@@ -3,7 +3,7 @@
3
3
  // emphasis repair. Web chat and every IM adapter render these, so the wording
4
4
  // and units live here.
5
5
  import { replaceOutsideCode } from "./inbound-file.js";
6
- import { TASK_RUN_STATES } from "./types.js";
6
+ import { NOT_IN_LEDGER, TASK_RUN_STATES } from "./types.js";
7
7
  // The syntax is told in agent/roles.ts; this file parses it back.
8
8
  /** Where a system input came from; wording every surface must spell the same. */
9
9
  export function originLabel(origin) {
@@ -30,7 +30,7 @@ const LIFTABLE = /[\u201c\u201d"\u2018\u2019'()\uff08\uff09\u300c\u300d\u300e\u3
30
30
  * `**“怎么做”**:` never closes (a spec hole, not a platform bug, and constant
31
31
  * in model output). Moving the punctuation outside — `“**怎么做**”:` — lands
32
32
  * the `**` against a letter. Fences and code spans are content, never touched. */
33
- export function cjkFriendly(markdown) {
33
+ function cjkFriendly(markdown) {
34
34
  return replaceOutsideCode(markdown, /\*\*(\S|\S[\s\S]*?\S)\*\*/g, ([whole, inner = ""]) => {
35
35
  let lead = "";
36
36
  let trail = "";
@@ -75,6 +75,11 @@ export const agoLabel = (ts, now) => {
75
75
  export const runStatus = (r, now) => r.finishedAt === null ? `${r.state} ${relTime(r.queuedAt, now)}` : `${r.state} ${agoLabel(r.finishedAt, now)}`;
76
76
  /** A lead's launches by state, in the states' own order: "2 running, 1 failed". */
77
77
  export const workerCounts = (workers) => TASK_RUN_STATES.filter((s) => workers[s] > 0).map((s) => `${String(workers[s])} ${s}`).join(", ") || "none";
78
+ /** A run behind an open item, `run <id8>… <state> <age>`, a lead's with its workers;
79
+ * web/ui/turn-activity.ts `linkRuns` reads the token back. */
80
+ export const openRunText = (r, now) => r.state === NOT_IN_LEDGER
81
+ ? `run ${r.runId} — ${NOT_IN_LEDGER}`
82
+ : `run ${r.runId.length > 8 ? `${r.runId.slice(0, 8)}…` : r.runId} ${runStatus(r, now)}${r.workers ? ` · workers: ${workerCounts(r.workers)}` : ""}`;
78
83
  /** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
79
84
  export const compact = (n) => {
80
85
  if (n < 1000)
@@ -170,7 +175,16 @@ export function splitReply(rawMarkdown, meta) {
170
175
  }
171
176
  /** For rendering mid-turn: everything `splitReply` repairs, minus the
172
177
  * next-step block, which is only one at the very end of a turn. */
173
- export const streamBody = (markdown) => cjkFriendly(replaceOutsideCode(markdown.replace(SILENT, ""), MARKER, () => "").trim());
178
+ export const streamBody = (markdown) => cjkFriendly(unmarked(markdown));
179
+ const unmarked = (markdown) => replaceOutsideCode(markdown.replace(SILENT, ""), MARKER, () => "").trim();
180
+ /** What a message says with Pier's markup off — `<silent>`, the open-item
181
+ * markers, the next-step block — and otherwise untouched: search cuts its
182
+ * snippets from this. */
183
+ export function saidText(markdown) {
184
+ const body = unmarked(markdown);
185
+ const m = BLOCK.exec(body);
186
+ return m?.[1] ? body.slice(0, m.index).trimEnd() : body;
187
+ }
174
188
  /** Lines a blank line does not necessarily separate (a loose list is still one
175
189
  * list). Over-matching is fine: one boundary too few costs only a repaint. */
176
190
  const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
@@ -0,0 +1,65 @@
1
+ // `pier search`: the `/search` route's host and the verb's argv, on the search
2
+ // seam core declares (`AgentFactory.search`); root keeps only the dispatch, as
3
+ // for `pier task` and `pier web`. Argv shape is checked here, the host validates
4
+ // the params, and its text reaches the shell verbatim (08 §Failure lines).
5
+ import { parseArgs } from "node:util";
6
+ import { readableTitle } from "./identity.js";
7
+ const USAGE = "usage: pier search <q...> [--limit N] [--json]";
8
+ // The match marks are the palette's to paint; a line is one line.
9
+ const flat = (text) => text.replaceAll("\u0001", "").replaceAll("\u0002", "").replace(/\s+/g, " ").trim();
10
+ // A title may be a whole first message; the snippet is what the line is for.
11
+ const TITLE_CHARS = 30;
12
+ const clip = (text) => {
13
+ const chars = [...text];
14
+ return chars.length > TITLE_CHARS ? `${chars.slice(0, TITLE_CHARS - 1).join("").trimEnd()}…` : text;
15
+ };
16
+ // sv-SE spells local time as YYYY-MM-DD HH:MM.
17
+ const when = (at) => new Date(at).toLocaleString("sv-SE", { dateStyle: "short", timeStyle: "short" });
18
+ const processIo = {
19
+ stdout: (line) => process.stdout.write(`${line}\n`),
20
+ stderr: (line) => process.stderr.write(`${line}\n`),
21
+ };
22
+ /** `/search`'s params to hits, each titled with its session's name as it is
23
+ * now; a session gone from disk is named by its id. */
24
+ export const searchSessions = (factory) => async (params) => {
25
+ const { q, limit = 20 } = (params ?? {});
26
+ const query = typeof q === "string" ? q.trim() : "";
27
+ if (!query)
28
+ throw new Error("q must be non-empty words to search for");
29
+ if (!Number.isInteger(limit))
30
+ throw new Error("limit must be an integer");
31
+ const hits = await factory.search(query, Math.min(50, Math.max(1, limit)));
32
+ return {
33
+ hits: await Promise.all(hits.map(async (hit) => ({ ...hit, title: readableTitle((await factory.find(hit.sessionId))?.title) ?? hit.sessionId }))),
34
+ };
35
+ };
36
+ /** One line per hit, or the answer's object with `--json`. */
37
+ export async function runSearchCli(argv, post, io = processIo) {
38
+ let v = {};
39
+ let words = [];
40
+ try {
41
+ ({ values: v, positionals: words } = parseArgs({ args: argv, allowPositionals: true, strict: true, options: { limit: { type: "string" }, json: { type: "boolean" } } }));
42
+ }
43
+ catch (err) {
44
+ io.stderr(`search: ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
45
+ return 2;
46
+ }
47
+ if (!words.length) {
48
+ io.stderr(`search: say what to search for\n${USAGE}`);
49
+ return 2;
50
+ }
51
+ const { status, body } = await post({ q: words.join(" "), ...(v.limit === undefined ? {} : { limit: Number(v.limit) }) });
52
+ if (status !== 200 || !body.result) {
53
+ io.stderr(`search: ${body.error ?? `socket answered ${String(status)}`}`);
54
+ return 1;
55
+ }
56
+ const { hits } = body.result;
57
+ if (v.json)
58
+ io.stdout(JSON.stringify(body.result));
59
+ else if (!hits.length)
60
+ io.stdout("no hits");
61
+ else
62
+ for (const hit of hits)
63
+ io.stdout(`${hit.sessionId} · ${clip(flat(hit.title))} · ${hit.role} · ${when(hit.at)}: ${flat(hit.snippet)}`);
64
+ return 0;
65
+ }
@@ -26,6 +26,8 @@ export const CHAIN_FULL_TOKENS = 60_000;
26
26
  /** Declared here, not in tasks/, because `BackgroundRun`, the `task-status` event core carries, names it.
27
27
  * In order: the Console lists them so, and a worker count reads so. */
28
28
  export const TASK_RUN_STATES = ["queued", "running", "succeeded", "failed", "cancelled", "interrupted", "skipped"];
29
+ /** The `state` of a run an open item names that the ledger no longer holds; its `name` is the id. */
30
+ export const NOT_IN_LEDGER = "not in the ledger";
29
31
  /** How much of a tool result any surface ever shows. A transcript replay
30
32
  * carries no more than that: a session's tool output is most of its history
31
33
  * payload, and the bytes past this point were downloaded to be sliced off. */
package/dist/db.js CHANGED
@@ -348,6 +348,14 @@ const MIGRATIONS = [
348
348
  `
349
349
  ALTER TABLE session_state DROP COLUMN sort;
350
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;
351
359
  `,
352
360
  ];
353
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
@@ -20,13 +20,16 @@ import { ChannelRuntime } from "./channels/runtime.js";
20
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
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";
30
33
  import { openItemsStatus } from "./tasks/open-items.js";
31
34
  import { registerTaskRoutes } from "./tasks/routes.js";
32
35
  import { TaskService } from "./tasks/service.js";
@@ -100,8 +103,9 @@ const factory = new PiAgentFactory(
100
103
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
101
104
  // Documents Pier's own tools, so it loads only inside a Pier session.
102
105
  [skillsDir], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get(), () => settings.get().titleModel,
103
- // Transcripts carry the speaker header core wrote for the model.
104
- new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text), (id) => taskStore.roleOf(id));
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));
105
109
  const hub = new EventHub();
106
110
  const router = new Router(hub, (key) => {
107
111
  // Web conversation ids are session ids; an IM id is a chat, resolved through
@@ -132,6 +136,17 @@ tasks = new TaskService(taskStore, factory, router, hub, {
132
136
  },
133
137
  },
134
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
+ },
135
150
  });
136
151
  const configurationSync = configSyncTask(tasks, configSync);
137
152
  const managedTools = new ManagedTools();
@@ -149,7 +164,7 @@ const control = createControl({
149
164
  router, factory, conversations, store: channelStore,
150
165
  modelMenu: () => settings.get().modelMenu,
151
166
  });
152
- const channels = new ChannelRuntime(channelStore, router, chain, control);
167
+ const channels = new ChannelRuntime(channelStore, router, chain, control, conversations);
153
168
  /** The head answers in the home chat while its adapter runs (docs/design/11), else on
154
169
  * the web with Web Push; an older member gets no home key, or an adapter's start
155
170
  * would re-key it over the head. Any other session: its conversations row. */
@@ -348,6 +363,7 @@ servePier({
348
363
  note(`done ${JSON.stringify(result.details)}`);
349
364
  return result;
350
365
  },
366
+ search: searchSessions(factory),
351
367
  // Live in the router, or on disk: the same two places a callback target is looked for.
352
368
  knows: async (id) => router.stateOf(id) !== undefined || (await factory.find(id)) !== undefined,
353
369
  login: () => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/login/${auth.mintLink()}`,
@@ -383,8 +399,8 @@ const shutdown = (stopTasks = true) => {
383
399
  // 90-second wait for SIGKILL.
384
400
  setTimeout(() => process.exit(0), 3000).unref();
385
401
  stopEviction();
386
- // The drain path leaves task runs alone: aborting would record them cancelled,
387
- // 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.
388
404
  if (stopTasks)
389
405
  tasks.stop();
390
406
  void channels.stop().finally(() => {
package/dist/settings.js CHANGED
@@ -92,7 +92,7 @@ export function normalizeModelRef(raw) {
92
92
  }
93
93
  /** Shape only: tools.ts owns the catalog, and an unknown name is ignored
94
94
  * there, so a downgrade cannot lose a setting it cannot explain. */
95
- export function normalizeTools(raw) {
95
+ function normalizeTools(raw) {
96
96
  return normalizeNames(raw);
97
97
  }
98
98
  function normalizeNames(raw) {
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");
@@ -20,7 +20,7 @@ const preamble = (run, supervised, role) => {
20
20
  : "read by the operator";
21
21
  return `[Pier task run ${run.id} — "${run.context.definition.name}"] ` +
22
22
  `Your final reply is recorded verbatim as the run result, ${audience}; ` +
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.` +
23
+ `next-step buttons and file:// attachments do not render there. Two parts: the conclusion — the paths it rests on, risks and unverified points one line each — then, only when something does, \`Needs your decision\`; no process, no log of attempts; a deliverable longer than a screen goes to a file the result names. A reversible choice on the way (how to push, a rebase strategy) is yours: take the recommended option and name it in the result. A destructive or irreversible step — force push, deleting what you did not create, a migration, a deploy, a restart — or a question only that reader can answer stops you: state it as your result and end your turn; the answer resumes this session.` +
24
24
  (role === "lead"
25
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
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." : "") +
@@ -22,7 +22,7 @@ const oneRun = (run) => {
22
22
  return { source: runSource(run), state: run.state, ...(cwd ? { cwd } : {}) };
23
23
  };
24
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.]";
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, in two parts: the conclusion (done, next, risks one line each), then `Needs your decision` only when something does.]";
26
26
  /** The line a design lead ends on once the user confirms: its milestone, whether a run's or a turn outside any. */
27
27
  export const DESIGN_FINAL = /^Design final:/m;
28
28
  /** On the record of a lead run that owed its supervisor nothing, so the run says why. */
@@ -3,7 +3,9 @@
3
3
  import { logger } from "../log.js";
4
4
  import { settleCallback } from "./callbacks.js";
5
5
  import { runBash } from "./command.js";
6
+ import { INTERRUPTED } from "./store.js";
6
7
  const log = logger("tasks");
8
+ const SHUTDOWN = Symbol("shutdown");
7
9
  export class TaskExecution {
8
10
  store;
9
11
  definitions;
@@ -21,9 +23,11 @@ export class TaskExecution {
21
23
  start(run) {
22
24
  void this.execute(run);
23
25
  }
26
+ /** Shutdown: the abort still kills each detached bash group, which boot
27
+ * recovery could not, but the run ends as that recovery would record it. */
24
28
  stop() {
25
29
  for (const controller of this.controllers.values())
26
- controller.abort();
30
+ controller.abort(SHUTDOWN);
27
31
  }
28
32
  cancel(id) {
29
33
  log.info(`run ${id} cancel requested`);
@@ -75,9 +79,11 @@ export class TaskExecution {
75
79
  }
76
80
  catch (error) {
77
81
  // A killed child reports `exited null`; report why we aborted instead.
82
+ // The first abort's reason wins: a shutdown after a user cancel stays cancelled.
78
83
  const aborted = controller.signal.aborted;
79
- run.state = aborted ? (timedOut ? "failed" : "cancelled") : "failed";
80
- run.error = timedOut ? "task timed out" : aborted ? "cancelled" : String(error);
84
+ const interrupted = aborted && !timedOut && controller.signal.reason === SHUTDOWN;
85
+ run.state = aborted ? (timedOut ? "failed" : interrupted ? "interrupted" : "cancelled") : "failed";
86
+ run.error = timedOut ? "task timed out" : interrupted ? INTERRUPTED : aborted ? "cancelled" : String(error);
81
87
  cause = timedOut ? run.error : error;
82
88
  }
83
89
  finally {
@@ -1,26 +1,21 @@
1
1
  // The continuous conversation's open items in task-run words: main's markers
2
2
  // joined to the run ledger by session, a lead's workers counted by state, and
3
3
  // the one text every surface shows (docs/design/10-continuous-session.md#open-items).
4
- import { openItemMarkers, runStatus, workerCounts } from "../core/reply.js";
5
- import { LEDGER_WINDOW_MS, TASK_RUN_STATES } from "../core/types.js";
4
+ import { openItemMarkers, openRunText } from "../core/reply.js";
5
+ import { LEDGER_WINDOW_MS, NOT_IN_LEDGER, TASK_RUN_STATES } from "../core/types.js";
6
6
  import { logger } from "../log.js";
7
- import { NOT_IN_LEDGER } from "./types.js";
8
7
  const log = logger("tasks");
9
8
  const IN_FLIGHT = new Set(["queued", "running"]);
10
9
  /** `members`: the chain, newest first; `designs`: the open designs of sessions not closed. */
11
10
  export function openItems(tasks, router, members, designs, now) {
12
11
  const since = now - LEDGER_WINDOW_MS;
13
12
  const runs = members.length ? tasks.ledger(members, since) : [];
14
- const withWorkers = (r) => {
15
- if (!r.targetSessionId || tasks.store.roleOf(r.targetSessionId) !== "lead")
16
- return r;
17
- const workers = Object.fromEntries(TASK_RUN_STATES.map((s) => [s, 0]));
18
- for (const w of tasks.ledger([r.targetSessionId], since)) {
19
- if (w.state in workers)
20
- workers[w.state] += 1;
21
- }
22
- return { ...r, workers };
23
- };
13
+ const awaiting = new Set(designs.flatMap((d) => (d.targetSessionId ? [d.targetSessionId] : [])));
14
+ const statusOf = (named) => named.some((r) => IN_FLIGHT.has(r.state) || (r.targetSessionId && router.stateOf(r.targetSessionId) === "streaming"))
15
+ ? "running"
16
+ : named.some((r) => r.targetSessionId && awaiting.has(r.targetSessionId))
17
+ ? "waiting on you"
18
+ : named.length && named.every((r) => r.state === "succeeded") ? "pending release" : "waiting on you";
24
19
  // An item names a session through any of its runs: the session's newest run stands for it.
25
20
  const tracked = new Set();
26
21
  const items = tasks.store.openItems().map((row) => {
@@ -28,15 +23,36 @@ export function openItems(tasks, router, members, designs, now) {
28
23
  const session = runs.find((r) => r.runId === id)?.targetSessionId ?? tasks.store.getRun(id)?.targetSessionId ?? null;
29
24
  tracked.add(session ?? id);
30
25
  const run = runs.find((r) => (session ? r.targetSessionId === session : r.runId === id));
31
- return run
32
- ? withWorkers(run)
33
- : { runId: id, name: id, state: NOT_IN_LEDGER, targetSessionId: session, cwd: null, queuedAt: 0, finishedAt: null };
26
+ return run ?? { runId: id, name: id, state: NOT_IN_LEDGER, targetSessionId: session, cwd: null, queuedAt: 0, finishedAt: null };
34
27
  }).filter((r, i, all) => all.findIndex((o) => o.runId === r.runId) === i);
35
- const busy = named.some((r) => IN_FLIGHT.has(r.state) || (r.targetSessionId && router.stateOf(r.targetSessionId) === "streaming"));
36
- return { problem: row.problem, stage: row.stage, runs: named, ...(named.length ? { live: busy ? "running" : "idle" } : {}) };
28
+ return { problem: row.problem, stage: row.stage, runs: named, status: statusOf(named) };
29
+ });
30
+ const unlisted = runs.filter((r) => IN_FLIGHT.has(r.state) && !tracked.has(r.targetSessionId ?? r.runId));
31
+ for (const r of unlisted)
32
+ tracked.add(r.targetSessionId ?? r.runId);
33
+ const unheld = designs.filter((d) => !tracked.has(d.targetSessionId ?? d.runId)).map((d) => {
34
+ const named = [runs.find((r) => d.targetSessionId && r.targetSessionId === d.targetSessionId) ?? d];
35
+ return { problem: d.name, stage: "", runs: named, status: statusOf(named) };
37
36
  });
38
- const unlisted = runs.filter((r) => IN_FLIGHT.has(r.state) && !tracked.has(r.targetSessionId ?? r.runId)).map(withWorkers);
39
- return { items, unlisted, designs };
37
+ const all = [...items, ...unheld];
38
+ // Every shown lead's workers in one read: a ledger call per lead grows with the list.
39
+ const leads = tasks.store.leads();
40
+ const leadOf = (r) => (r.state !== NOT_IN_LEDGER && r.targetSessionId && leads.has(r.targetSessionId) ? r.targetSessionId : null);
41
+ const shown = new Set([...all.flatMap((i) => i.runs), ...unlisted].flatMap((r) => leadOf(r) ?? []));
42
+ const counts = new Map([...shown].map((id) => [id, Object.fromEntries(TASK_RUN_STATES.map((s) => [s, 0]))]));
43
+ for (const w of shown.size ? tasks.store.ledgerRuns([...shown], since) : []) {
44
+ const c = w.invokedBySessionId ? counts.get(w.invokedBySessionId) : undefined;
45
+ if (c)
46
+ c[w.state] += 1;
47
+ }
48
+ const withWorkers = (r) => {
49
+ const workers = counts.get(leadOf(r) ?? "");
50
+ return workers ? { ...r, workers: { ...workers } } : r;
51
+ };
52
+ return {
53
+ items: all.map((i) => ({ ...i, runs: i.runs.map(withWorkers) })),
54
+ unlisted: unlisted.map(withWorkers),
55
+ };
40
56
  }
41
57
  /** The head's reply's markers, written; answers whether a row changed. */
42
58
  export function recordOpenItems(store, text, now) {
@@ -45,26 +61,28 @@ export function recordOpenItems(store, text, now) {
45
61
  log.warn(`open items: dropped a marker with no problem text: ${marker}`);
46
62
  return markers.length > 0 && store.markOpenItems(markers, now) > 0;
47
63
  }
48
- const workersText = (workers) => workers ? ` · workers: ${workerCounts(workers)}` : "";
49
- const runText = (r, now) => r.state === NOT_IN_LEDGER
50
- ? `run ${r.runId} — ${NOT_IN_LEDGER}`
51
- : `run ${r.runId.length > 8 ? `${r.runId.slice(0, 8)}…` : r.runId} ${runStatus(r, now)}${workersText(r.workers)}`;
52
- /** The one string every surface shows for the open items: `/status` and the seed. */
53
- export function renderOpenItems({ items, unlisted, designs }, now) {
54
- if (!items.length && !unlisted.length && !designs.length)
64
+ /** An `unlisted` run's stage: main named it in no item. */
65
+ const UNLISTED = "not on the list";
66
+ /** `status` is an item's, or `queued` for an unlisted run not started, as the panel tags it. */
67
+ const itemLine = (i, now) => `- ${i.problem}${i.stage ? ` — ${i.stage}` : ""} (${i.status})${i.runs.map((r) => ` · ${openRunText(r, now)}`).join("")}`;
68
+ /** The one string every surface shows for the open items: `/status` and the seed.
69
+ * Grouped as the status panel groups them (web/ui/drawer.ts): what waits on the user first. */
70
+ function renderOpenItems({ items, unlisted }, now) {
71
+ if (!items.length && !unlisted.length)
55
72
  return "Nothing open.";
56
- const open = items.map((i) => `- ${i.problem}${i.stage ? ` — ${i.stage}` : ""}${i.live ? ` (${i.live})` : ""}${i.runs.map((r) => ` · ${runText(r, now)}`).join("")}`);
57
- const rest = unlisted.map((r) => `- ${r.name} — ${runStatus(r, now)}${workersText(r.workers)}`);
58
- const decide = designs.map((r) => `- ${r.name} · ${runText(r, now)}`);
73
+ const waiting = items.filter((i) => i.status !== "running").map((i) => itemLine(i, now));
74
+ const running = [
75
+ ...items.filter((i) => i.status === "running").map((i) => itemLine(i, now)),
76
+ ...unlisted.map((r) => itemLine({ problem: r.name, stage: UNLISTED, runs: [r], status: r.state === "queued" ? "queued" : "running" }, now)),
77
+ ];
59
78
  return [
60
- ...(open.length ? ["Open", ...open] : []),
61
- ...(rest.length ? ["Not on the list", ...rest] : []),
62
- ...(decide.length ? ["Designs for you to finalize", ...decide] : []),
79
+ ...(waiting.length ? ["Waiting on you", ...waiting] : []),
80
+ ...(running.length ? ["In progress", ...running] : []),
63
81
  ].join("\n");
64
82
  }
65
- /** `/status`'s card: the text, and run id → session id for every named run and listed design that has one. */
83
+ /** `/status`'s card: the text, and run id → session id for every run it names that has one. */
66
84
  export function openItemsStatus(open, now) {
67
- const sessions = Object.fromEntries([...open.items.flatMap((i) => i.runs), ...open.designs]
85
+ const sessions = Object.fromEntries([...open.items.flatMap((i) => i.runs), ...open.unlisted]
68
86
  .flatMap((r) => (r.targetSessionId ? [[r.runId, r.targetSessionId]] : [])));
69
87
  return { text: renderOpenItems(open, now), sessions };
70
88
  }
@@ -3,7 +3,7 @@
3
3
  // drain needs. Decisions belong to the files beside it.
4
4
  import { logger } from "../log.js";
5
5
  import { AgentTaskRunner } from "./agent.js";
6
- import { DESIGN_FINAL, MILESTONE, runCwd, settleCallback, TaskCallbacks } from "./callbacks.js";
6
+ import { DESIGN_FINAL, LEAD_TURN, MILESTONE, runCwd, settleCallback, TaskCallbacks } from "./callbacks.js";
7
7
  import { TaskDefinitions, requiredString } from "./definitions.js";
8
8
  import { TaskExecution } from "./execution.js";
9
9
  import { TaskGroups } from "./groups.js";
@@ -138,6 +138,26 @@ export class TaskService {
138
138
  else
139
139
  log.info(`lead ${sessionId}: Design final: outside any run, recorded as run ${run.id} for ${run.callbackSessionId}`);
140
140
  this.runs.start(run);
141
+ this.designLead(run);
142
+ }
143
+ /** What a settled run of a design lead means to whoever shows the lead to
144
+ * the user (channels/runtime.ts); a reporter that throws must not unwind the settle. */
145
+ designLead(run) {
146
+ const lead = run.targetSessionId;
147
+ if (!this.instance.designLead || lead === null || this.store.leadPhaseOf(lead) !== "design")
148
+ return;
149
+ const state = run.callbackError === LEAD_TURN ? "waiting"
150
+ : run.state !== "succeeded" ? "failed"
151
+ : run.result?.type === "agent" && DESIGN_FINAL.test(run.result.text) ? "final"
152
+ : undefined;
153
+ if (!state)
154
+ return;
155
+ try {
156
+ this.instance.designLead(run, state);
157
+ }
158
+ catch (err) {
159
+ log.error(`lead ${lead}: its ${state} state could not be reported`, err);
160
+ }
141
161
  }
142
162
  start(tickMs = 1000) {
143
163
  if (this.timer)
@@ -445,6 +465,7 @@ export class TaskService {
445
465
  resolve(run);
446
466
  this.waiters.delete(run.id);
447
467
  this.groups.onSettled(run);
468
+ this.designLead(run);
448
469
  }
449
470
  backgroundRun(run) {
450
471
  return {
@@ -6,6 +6,8 @@ const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
6
6
  /** Kept unmatched probes per watch: one page of history, which is all any
7
7
  * surface lists of a probe that found nothing. */
8
8
  const KEPT_PROBES = 50;
9
+ /** The error of a run a restart or shutdown stopped: not a result, not a cancel. */
10
+ export const INTERRUPTED = "Pier restarted while the run was active";
9
11
  export class TaskStore {
10
12
  db;
11
13
  /** Every query below is a fixed string, so each is compiled once. */
@@ -194,8 +196,9 @@ export class TaskStore {
194
196
  }
195
197
  /** What a milestone resumes, and whose supervisor it reports to. */
196
198
  latestRunForTarget(sessionId) {
199
+ // Two runs queued in one millisecond are common (a turn and the run it records); ids are random, rowid is insertion order.
197
200
  return this.#one(`
198
- SELECT json FROM task_runs WHERE json_extract(json, '$.targetSessionId') = ? ORDER BY queued_at DESC, id DESC LIMIT 1
201
+ SELECT json FROM task_runs WHERE json_extract(json, '$.targetSessionId') = ? ORDER BY queued_at DESC, rowid DESC LIMIT 1
199
202
  `, sessionId);
200
203
  }
201
204
  /** Runs in flight whose result is owed to the session, their own or their
@@ -316,7 +319,7 @@ export class TaskStore {
316
319
  interruptRunning(now = Date.now()) {
317
320
  return this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running')").map((run) => {
318
321
  run.state = "interrupted";
319
- run.error = "Pier restarted while the run was active";
322
+ run.error = INTERRUPTED;
320
323
  run.finishedAt = now;
321
324
  if (run.callbackSessionId)
322
325
  run.callbackState = "pending";
@@ -17,5 +17,3 @@ export const isTerminal = (state) => state === "succeeded" ||
17
17
  /** The role a fresh run's session is created with and keeps: a lead's, or a
18
18
  * worker's when a session launched it; a cron or watch run's has none. */
19
19
  export const createdRole = ({ context: { definition: { action } }, invokedBySessionId }) => action.type === "agent" && action.launch?.role === "lead" ? "lead" : invokedBySessionId !== null ? "worker" : undefined;
20
- /** The `state` of a run an item names that the ledger no longer holds; its `name` is the id. */
21
- export const NOT_IN_LEDGER = "not in the ledger";
package/dist/tools.js CHANGED
@@ -392,6 +392,8 @@ export class ManagedTools {
392
392
  #fetch;
393
393
  #root;
394
394
  #lock;
395
+ /** Why a tool is absent lives only here: `ubix list` never names one that failed to install. */
396
+ #lastSync;
395
397
  #db;
396
398
  constructor(options = {}) {
397
399
  this.#exec = options.exec ?? spawnExec;
@@ -459,7 +461,8 @@ export class ManagedTools {
459
461
  // The only thing that changes what `list` answers; drop the memo both sides.
460
462
  this.#listed = undefined;
461
463
  try {
462
- return await this.#converge(tools, customTools, fence);
464
+ this.#lastSync = await this.#converge(tools, customTools, fence);
465
+ return this.#lastSync;
463
466
  }
464
467
  finally {
465
468
  this.#listed = undefined;
@@ -527,8 +530,12 @@ export class ManagedTools {
527
530
  }
528
531
  return base.map((entry) => {
529
532
  const state = states.find((s) => s.name === entry.name);
530
- if (!state)
531
- return entry;
533
+ if (!state) {
534
+ if (!entry.enabled)
535
+ return entry;
536
+ const synced = this.#lastSync?.entries.find((e) => e.name === entry.name);
537
+ return withBinary(entry, { error: synced?.error ?? "not installed — the last sync did not reach it" });
538
+ }
532
539
  const gone = state.installed === true && state.exists === false;
533
540
  // `npm:` lands in fnm's node prefix and `pixi:` in its own: installed,
534
541
  // but not where Pier's PATH points, and the row has to say so.
@@ -1,2 +1,2 @@
1
- import{J as e,r as t}from"./highlight-5Bl8zZ8q.js";var n=2e4;function r(e){let t=e.split(`
1
+ import{J as e,r as t}from"./highlight-CDSk2hRJ.js";var n=2e4;function r(e){let t=e.split(`
2
2
  `);return t.at(-1)===``&&t.pop(),t.map((e,t)=>({nums:[t+1],text:e,tone:``}))}function i(t,r){let i=t.length<=n?r:null,o=Math.max(2,...t.map(e=>e.nums.reduce((e,t)=>Math.max(e,String(t).length),0))),s=e(`div`,`py-2 font-mono text-[12px] leading-[1.5]`);for(let n of t){let t=e(`div`,`flex pl-2 pr-4 ${n.tone===`add`?`bg-emerald-50`:n.tone===`del`?`bg-red-50`:``}`);for(let r of n.nums){let n=e(`span`,`flex-none select-none pr-1.5 text-right text-neutral-300`,r===``?``:String(r));n.style.width=`calc(${o}ch + 0.375rem)`,t.append(n)}let r=n.nums.at(-1);typeof r==`number`&&(t.dataset.line=String(r)),t.append(a(n,i)),s.append(t)}return s}function a(n,r){let i=`min-w-0 flex-1 whitespace-pre-wrap [overflow-wrap:anywhere]`;if(!n.mark||n.mark[0]>=n.mark[1]){let e=t(n.text,r);return e.className=i,e}let[a,o]=n.mark,s=t(n.text.slice(a,o),r);return s.className=`rounded-xs ${n.tone===`add`?`bg-emerald-200/80`:`bg-red-200/70`}`,e(`span`,i,t(n.text.slice(0,a),r),s,t(n.text.slice(o),r))}export{r as n,i as t};