@timqi/pier 0.0.9 → 0.0.16

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 (67) hide show
  1. package/README.md +7 -1
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +190 -31
  5. package/dist/boards/boards.js +65 -16
  6. package/dist/boards/pier.css +1 -1
  7. package/dist/channels/attach.js +87 -0
  8. package/dist/channels/control.js +2 -2
  9. package/dist/channels/lark-api.js +38 -0
  10. package/dist/channels/lark-outbound.js +11 -2
  11. package/dist/channels/slack-api.js +36 -0
  12. package/dist/channels/slack-outbound.js +12 -2
  13. package/dist/channels/slack-tool.js +49 -9
  14. package/dist/channels/telegram-api.js +21 -2
  15. package/dist/channels/telegram.js +23 -8
  16. package/dist/cli.js +34 -0
  17. package/dist/core/identity.js +18 -0
  18. package/dist/core/inbound-file.js +3 -1
  19. package/dist/core/reply.js +2 -1
  20. package/dist/core/router.js +72 -0
  21. package/dist/db.js +78 -0
  22. package/dist/extensions/index.js +5 -2
  23. package/dist/extensions/web/artifacts.js +7 -2
  24. package/dist/extensions/web/content.js +5 -0
  25. package/dist/extensions/web/language.js +5 -0
  26. package/dist/extensions/web/tools.js +33 -8
  27. package/dist/limits.js +14 -0
  28. package/dist/main.js +47 -6
  29. package/dist/paths.js +6 -1
  30. package/dist/settings.js +44 -0
  31. package/dist/tasks/agent.js +23 -4
  32. package/dist/tasks/callbacks.js +20 -1
  33. package/dist/tasks/command.js +15 -0
  34. package/dist/tasks/definitions.js +60 -12
  35. package/dist/tasks/execution.js +9 -1
  36. package/dist/tasks/groups.js +8 -4
  37. package/dist/tasks/messages.js +10 -2
  38. package/dist/tasks/routes.js +4 -0
  39. package/dist/tasks/runs.js +7 -2
  40. package/dist/tasks/service.js +22 -6
  41. package/dist/tasks/store.js +4 -0
  42. package/dist/tasks/tool.js +0 -12
  43. package/dist/tasks/types.js +4 -0
  44. package/dist/tools-task.js +155 -0
  45. package/dist/tools.js +875 -0
  46. package/dist/web/auth.js +5 -3
  47. package/dist/web/explorer.js +15 -2
  48. package/dist/web/files.js +1 -1
  49. package/dist/web/instance.js +165 -36
  50. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
  51. package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
  52. package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
  53. package/dist/web/public/index.html +5 -8
  54. package/dist/web/public/sw.js +4 -0
  55. package/dist/web/push.js +22 -7
  56. package/dist/web/repos.js +75 -0
  57. package/dist/web/server.js +145 -64
  58. package/dist/web/session-state.js +33 -51
  59. package/dist/web/types.js +5 -0
  60. package/docs/deploy.md +12 -3
  61. package/package.json +1 -1
  62. package/skills/pier-boards/SKILL.md +23 -13
  63. package/skills/pier-help/SKILL.md +1 -1
  64. package/skills/pier-slack/SKILL.md +21 -1
  65. package/skills/pier-tasks/SKILL.md +2 -2
  66. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  67. package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
@@ -1,7 +1,14 @@
1
- import { randomUUID } from "node:crypto";
1
+ // What a parent and a child say to each other while a run is going: steer,
2
+ // follow-up and resume in one direction, progress and decision questions in
3
+ // the other. Every message is a durable row before it is a delivery, because
4
+ // the two ends are different sessions and either may be mid-turn, gone, or
5
+ // finished — an undelivered message is retried, expired and *said*, never
6
+ // dropped (§5b).
2
7
  import { EventHub } from "../core/hub.js";
3
8
  import { Router } from "../core/router.js";
4
9
  import { logger } from "../log.js";
10
+ import { runSource } from "./callbacks.js";
11
+ import { newId } from "./definitions.js";
5
12
  import { TaskStore } from "./store.js";
6
13
  import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
7
14
  const log = logger("tasks");
@@ -152,7 +159,7 @@ export class TaskMessenger {
152
159
  }
153
160
  create(run, kind, fromSessionId, toSessionId, content, replyTo) {
154
161
  const message = {
155
- id: randomUUID(),
162
+ id: newId(),
156
163
  runId: run.id,
157
164
  kind,
158
165
  fromSessionId,
@@ -319,6 +326,7 @@ export class TaskMessenger {
319
326
  sourceSessionId: message.fromSessionId,
320
327
  messageId: message.id,
321
328
  messageKind: message.kind,
329
+ source: runSource(run),
322
330
  };
323
331
  }
324
332
  require(id) {
@@ -1,3 +1,7 @@
1
+ // The area's HTTP surface: tasks, runs, group and message routes for the
2
+ // Console, plus the Activity snapshot it draws its graph from. A route reads
3
+ // its body, names the caller and hands the decision to TaskService — policy
4
+ // that lives here would be policy the task tool does not get.
1
5
  import { record, requiredString } from "./definitions.js";
2
6
  const jsonBody = async (req) => req.json().catch(() => null);
3
7
  export function registerTaskRoutes(app, tasks, activity) {
@@ -1,6 +1,11 @@
1
- import { randomUUID } from "node:crypto";
1
+ // A definition plus an input becomes a queued run: where it came from, how
2
+ // deep in a subagent chain it sits, whether it overlaps a run already going,
3
+ // and which session hears about it. The limits that keep a chain from
4
+ // exploding (depth, children per root) are decided here, once, because every
5
+ // caller — scheduler, tool, HTTP — enqueues through this one door.
2
6
  import { logger } from "../log.js";
3
7
  import { TaskCallbacks } from "./callbacks.js";
8
+ import { newId } from "./definitions.js";
4
9
  import { TaskStore } from "./store.js";
5
10
  const log = logger("tasks");
6
11
  const MAX_DEPTH = 2;
@@ -19,7 +24,7 @@ export class TaskRunQueue {
19
24
  this.changed = changed;
20
25
  }
21
26
  enqueue(definition, input, source, parentRunId, provenance) {
22
- const id = randomUUID();
27
+ const id = newId();
23
28
  const parent = parentRunId ? this.getRun(parentRunId) : null;
24
29
  const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
25
30
  const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
@@ -1,3 +1,9 @@
1
+ // The one object the rest of Pier talks to about tasks, and the clock behind
2
+ // it: the tick that finds what is due, the boot recovery that writes off runs
3
+ // a restart interrupted, and the pause a drain needs. Every decision it looks
4
+ // like it makes belongs to a file beside it (definitions, runs, execution,
5
+ // groups, messages, callbacks) — what is genuinely here is scheduling and the
6
+ // facade, so the HTTP routes and the task tool cannot drift apart.
1
7
  import { EventHub } from "../core/hub.js";
2
8
  import { Router } from "../core/router.js";
3
9
  import { logger } from "../log.js";
@@ -130,6 +136,14 @@ export class TaskService {
130
136
  activeRunCount() {
131
137
  return this.store.countActiveRuns();
132
138
  }
139
+ /** The run this task has in flight, if any. The store already answers this
140
+ * for the overlap guard (runs.ts); a caller that has just been refused as
141
+ * an overlap needs the same answer to know what to wait for, and scanning
142
+ * run history for it finds nothing once the skipped rows outnumber the
143
+ * window. */
144
+ activeRun(taskId) {
145
+ return this.store.findActiveRun(taskId);
146
+ }
133
147
  list() {
134
148
  return this.definitions.list();
135
149
  }
@@ -139,14 +153,16 @@ export class TaskService {
139
153
  create(raw, creator = "http") {
140
154
  return this.definitions.create(raw, creator);
141
155
  }
142
- update(id, raw) {
143
- return this.definitions.update(id, raw);
156
+ /** `by` is how the code that owns a definition says so; the HTTP routes and
157
+ * the task tool have none, which is what closes both (definitions.ts). */
158
+ update(id, raw, by) {
159
+ return this.definitions.update(id, raw, by);
144
160
  }
145
- setEnabled(id, enabled) {
146
- return this.definitions.setEnabled(id, enabled);
161
+ setEnabled(id, enabled, by) {
162
+ return this.definitions.setEnabled(id, enabled, by);
147
163
  }
148
- archive(id) {
149
- return this.definitions.archive(id);
164
+ archive(id, by) {
165
+ return this.definitions.archive(id, by);
150
166
  }
151
167
  sessionExists(sessionId) {
152
168
  return this.definitions.sessionExists(sessionId);
@@ -1,3 +1,7 @@
1
+ // Every query this area makes against pier.db, and nothing else: definitions,
2
+ // runs, groups and messages are rows here, read and written through one
3
+ // connection db.ts opened. A store owns its queries, never its own tables or
4
+ // its own handle — the schema is db.ts's migration list.
1
5
  import { pierDb } from "../db.js";
2
6
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
3
7
  export class TaskStore {
@@ -82,9 +82,6 @@ const DraftSchema = Type.Object({
82
82
  Type.Object({ mode: Type.Literal("reuse"), sessionId: Type.String() }),
83
83
  ]),
84
84
  prompt: Type.String(),
85
- // No `capabilities` here on purpose: a child gets the same tools as any
86
- // Pier session, so the model never spends a decision on it. Read-only
87
- // children stay configurable through the Console and HTTP.
88
85
  launch: Type.Optional(Type.Object({
89
86
  model: Type.Optional(Type.Object({ provider: Type.String(), id: Type.String() })),
90
87
  thinking: Type.Optional(Type.String()),
@@ -152,9 +149,6 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
152
149
  return definitions.update(requiredString(input.task_id, "task_id"), input.task);
153
150
  }
154
151
  if (input.operation === "run") {
155
- if (active && active.context.definition.action.type === "agent" && active.context.definition.action.launch?.capabilities === "read") {
156
- throw new Error("read-only subagents cannot delegate nested work");
157
- }
158
152
  if (Array.isArray(input.tasks)) {
159
153
  // Core-joined fan-out: members run detached, one aggregated callback.
160
154
  if (input.task !== undefined || input.task_id !== undefined)
@@ -253,12 +247,6 @@ async function resolveDraft(definitions, draft, active, callerSessionId) {
253
247
  if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
254
248
  throw new Error("inline subagent tasks must use a manual trigger");
255
249
  }
256
- // The draft parser accepts `capabilities` for Console/HTTP definitions; from
257
- // the tool it is rejected rather than silently honoured, so a model working
258
- // from stale memory learns the field is gone.
259
- if (record(record(draft.action)?.launch)?.capabilities !== undefined) {
260
- throw new Error("launch.capabilities is configured in Console or HTTP, not by the task tool");
261
- }
262
250
  if (active) {
263
251
  const action = record(draft.action);
264
252
  if (action?.type !== "agent")
@@ -1,3 +1,7 @@
1
+ // The vocabulary every file in this area shares: what a task, a run, a group
2
+ // and a control message *are*, plus the delivery constants the outbox and the
3
+ // messenger must agree on. Owner-defined and browser-importable type-only
4
+ // (architecture.md), so nothing here may reach for a runtime or a node builtin.
1
5
  export const retryDelay = (attempts) => Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6));
2
6
  /** Attempts before a delivery is given up on and reported. With the backoff
3
7
  * above that is ~4 minutes: long enough to outlast a busy or restarting
@@ -0,0 +1,155 @@
1
+ // One reason: a tools switch has to become exactly one run of the one task
2
+ // Pier owns — which takes knowing what that task runs, keeping it the task
3
+ // Pier wrote, and turning a burst of switches into one run of it.
4
+ //
5
+ // It lives beside tools.ts rather than inside it because tools.ts may not
6
+ // import tasks/, and outside main.ts because main.ts is wiring: this is the
7
+ // only rule in the instance layer that is neither construction nor a callback.
8
+ // The task's run history *is* the tools status surface — the install, the daily
9
+ // update and every failure are runs with output, so there is no second place to
10
+ // look (§5b).
11
+ import { existsSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { logger } from "./log.js";
14
+ import { PIER_HOME } from "./paths.js";
15
+ import { isTerminal } from "./tasks/types.js";
16
+ import { coalescedSync } from "./tools.js";
17
+ /** Marks the daily update task as Pier's own — this file finds the one it owns
18
+ * rather than one a person wrote, and names itself with it when it writes the
19
+ * definition back (the owner guard in tasks/definitions.ts). */
20
+ const TOOLS_TASK_CREATOR = "tools";
21
+ // The area these lines have always logged under: the move must not rename
22
+ // anything an operator greps the journal for.
23
+ const log = logger("pier");
24
+ /** POSIX single quotes: `$`, a backtick and a backslash mean things inside
25
+ * double quotes, and this string is run by bash months from now. */
26
+ const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
27
+ /**
28
+ * How this Pier runs `pier tools sync`. Installed, that is the built CLI beside
29
+ * main.js. From a source checkout there is no `cli.js` and node cannot strip
30
+ * types through imports that still say `.js`, so it is the same command under
31
+ * tsx — which is what a source checkout has. Neither available is a refusal
32
+ * with a reason, never a task whose script cannot run.
33
+ */
34
+ const toolsSyncScript = () => {
35
+ const built = fileURLToPath(new URL("./cli.js", import.meta.url));
36
+ if (existsSync(built))
37
+ return { script: `${shellQuote(process.execPath)} ${shellQuote(built)} tools sync` };
38
+ const source = fileURLToPath(new URL("./cli.ts", import.meta.url));
39
+ if (!existsSync(source))
40
+ return { problem: `no CLI to run: neither ${built} nor ${source} exists` };
41
+ try {
42
+ return {
43
+ script: `${shellQuote(process.execPath)} --import ${shellQuote(import.meta.resolve("tsx"))}` +
44
+ ` ${shellQuote(source)} tools sync`,
45
+ };
46
+ }
47
+ catch {
48
+ return { problem: `running from source (${source}) and tsx is not installed — run npm install, or npm run build` };
49
+ }
50
+ };
51
+ export function toolsTask(tasks) {
52
+ /** Which task is Pier's, and the id every managed run goes through. */
53
+ let toolsTaskId = null;
54
+ /**
55
+ * The one task Pier owns, brought in line with what it should be.
56
+ *
57
+ * Created once and never retired: a task that comes and goes is a state class
58
+ * of its own (two boots racing to create it, a retirement racing a switch),
59
+ * and the run it would have been retired for already says "no tools switched
60
+ * on".
61
+ *
62
+ * It repairs rather than trusts because of what came before the owner guard
63
+ * (tasks/definitions.ts): a definition edited by an older Pier, or by a
64
+ * release where the routes could still write it, is brought back to the one
65
+ * Pier owns here. Nothing can edit it any more — so this is a boot-time
66
+ * repair of state that already exists, not a defence.
67
+ */
68
+ const ensureToolsTask = async () => {
69
+ const command = toolsSyncScript();
70
+ if ("problem" in command)
71
+ return { problem: command.problem };
72
+ const draft = {
73
+ name: "tools: daily update",
74
+ description: "Installs the CLI tools switched on in Settings → Agent and keeps them current.",
75
+ trigger: {
76
+ type: "cron",
77
+ expression: "17 4 * * *",
78
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
79
+ },
80
+ // PIER_HOME as cwd: the command belongs to the instance, not to a project.
81
+ action: { type: "bash", script: command.script, cwd: PIER_HOME },
82
+ timeoutSeconds: 1800,
83
+ };
84
+ const draftShape = [draft.name, draft.description, true, draft.trigger, draft.action, { type: "none" }, draft.timeoutSeconds];
85
+ // Archived is not "owned but edited": nothing can un-archive a task, so the
86
+ // replacement is a new one and the old one keeps its history.
87
+ const owned = tasks.list().filter((task) => task.creator === TOOLS_TASK_CREATOR && !task.archived);
88
+ // One per creator. Two would fight over ubix's state lock every night, each
89
+ // reporting the other's run as an overlap.
90
+ for (const extra of owned.slice(1)) {
91
+ log.warn(`archiving a second tools update task (${extra.id})`);
92
+ tasks.archive(extra.id, TOOLS_TASK_CREATOR);
93
+ }
94
+ const task = owned[0];
95
+ // Every field Pier owns, not just the command: a paused task, a renamed one
96
+ // or one pointed at a callback still claims to be keeping the tools current
97
+ // while the daily run never happens.
98
+ const current = task &&
99
+ JSON.stringify([task.name, task.description, task.enabled, task.trigger, task.action, task.callback, task.timeoutSeconds]);
100
+ if (task && current !== JSON.stringify(draftShape)) {
101
+ log.warn("the tools update task was edited — restoring the definition Pier owns");
102
+ // Named as the owner: this is the one path allowed to write it back
103
+ // (tasks/definitions.ts).
104
+ await tasks.update(task.id, { ...draft, enabled: true, callback: { type: "none" } }, TOOLS_TASK_CREATOR);
105
+ }
106
+ const id = task ? task.id : (await tasks.create(draft, TOOLS_TASK_CREATOR)).id;
107
+ toolsTaskId = id;
108
+ return { id };
109
+ };
110
+ /** The half of `coalescedSync` (tools.ts, which has the rule and why) that
111
+ * knows what a task is: start a run, and hand back what to wait for — our own
112
+ * run, or the one already in flight that made ours a `skipped` row. */
113
+ const requestSync = coalescedSync(() => {
114
+ if (!toolsTaskId)
115
+ throw new Error("no tools update task to run");
116
+ const settled = (id) => tasks.waitForRun(id).then(() => undefined);
117
+ // Bounded, because the only way round this loop is a run that finished
118
+ // between being in flight and being asked about: real, rare, and not
119
+ // something to spin on. Three refusals in a row with nothing running is a
120
+ // bug, and it is reported as one rather than retried forever.
121
+ for (let attempt = 0; attempt < 3; attempt++) {
122
+ const mine = tasks.run(toolsTaskId, null, "manual");
123
+ if (!isTerminal(mine.state))
124
+ return { ran: "started", settled: settled(mine.id) };
125
+ const active = tasks.activeRun(toolsTaskId);
126
+ if (active)
127
+ return { ran: "overlapped", settled: settled(active.id) };
128
+ }
129
+ throw new Error("the tools sync was refused as an overlap three times with nothing running");
130
+ }, (err) => log.error("the tools sync could not be run", err));
131
+ /** A switch was flipped: make sure the task is the one Pier means, then ask
132
+ * for a sync. Answers with what that switch should say about it. */
133
+ const toolsChanged = async () => {
134
+ try {
135
+ const task = await ensureToolsTask();
136
+ if ("problem" in task) {
137
+ log.error(`tools cannot be managed: ${task.problem}`);
138
+ return { state: "refused", reason: task.problem };
139
+ }
140
+ return { state: requestSync() };
141
+ }
142
+ catch (err) {
143
+ log.error("the tools update task could not be reconciled", err);
144
+ return { state: "refused", reason: err instanceof Error ? err.message : String(err) };
145
+ }
146
+ };
147
+ return {
148
+ /** Reconcile now. At boot, before any route exists: two first flips could
149
+ * otherwise both find no task and create one each. */
150
+ reconcile: ensureToolsTask,
151
+ /** The task whose runs are the status surface, null until there is one. */
152
+ id: () => toolsTaskId,
153
+ changed: toolsChanged,
154
+ };
155
+ }