@timqi/pier 0.1.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,6 +1,6 @@
1
1
  // What a task *is* before it ever runs: the id it is minted with, the draft
2
2
  // validated into a definition, and when its trigger is next due. Every way a
3
- // definition can be created — HTTP, `pier task`, Pier's own owned task —
3
+ // definition can be created — `pier task`, Pier's own owned task —
4
4
  // arrives here, so a field is checked in one place or nowhere.
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { stat } from "node:fs/promises";
@@ -69,13 +69,27 @@ function parseTrigger(raw) {
69
69
  }
70
70
  /** Always computed from `from` (boot recomputes from *now*): cron runs missed
71
71
  * while Pier was down are skipped, never caught up — no double fire. */
72
- export function nextRunAt(trigger, from) {
72
+ function nextRunAt(trigger, from) {
73
73
  if (trigger.type === "manual")
74
74
  return null;
75
75
  if (trigger.type === "watch")
76
76
  return from + trigger.intervalSeconds * 1000;
77
77
  return new Cron(trigger.expression, { timezone: trigger.timezone }).nextRun(new Date(from))?.getTime() ?? null;
78
78
  }
79
+ function parseModel(raw) {
80
+ const model = record(raw);
81
+ if (!model)
82
+ throw new Error("agent model must be an object");
83
+ return {
84
+ provider: requiredString(model.provider, "model provider"),
85
+ id: requiredString(model.id, "model id"),
86
+ };
87
+ }
88
+ function parseThinking(raw) {
89
+ if (!isThinkingLevel(raw))
90
+ throw new Error("invalid agent thinking level");
91
+ return raw;
92
+ }
79
93
  function parseLaunch(raw) {
80
94
  if (raw === undefined)
81
95
  return undefined;
@@ -83,20 +97,19 @@ function parseLaunch(raw) {
83
97
  if (!value)
84
98
  throw new Error("agent launch policy must be an object");
85
99
  const launch = {};
86
- if (value.model !== undefined) {
87
- const model = record(value.model);
88
- if (!model)
89
- throw new Error("agent model must be an object");
90
- launch.model = {
91
- provider: requiredString(model.provider, "model provider"),
92
- id: requiredString(model.id, "model id"),
93
- };
100
+ if (value.model !== undefined)
101
+ launch.model = parseModel(value.model);
102
+ if (value.thinking !== undefined)
103
+ launch.thinking = parseThinking(value.thinking);
104
+ if (value.role !== undefined) {
105
+ if (value.role !== "lead")
106
+ throw new Error("agent role must be lead");
107
+ launch.role = "lead";
94
108
  }
95
- if (value.thinking !== undefined) {
96
- if (!isThinkingLevel(value.thinking)) {
97
- throw new Error("invalid agent thinking level");
98
- }
99
- launch.thinking = value.thinking;
109
+ if (value.design !== undefined) {
110
+ if (value.design !== true || launch.role !== "lead")
111
+ throw new Error("agent design must be true, on a lead");
112
+ launch.design = true;
100
113
  }
101
114
  return Object.keys(launch).length ? launch : undefined;
102
115
  }
@@ -136,7 +149,7 @@ export class TaskDefinitions {
136
149
  revision: 1,
137
150
  trigger: draft.trigger,
138
151
  action: draft.action,
139
- callback: draft.callback ?? { type: "none" },
152
+ callback: draft.callback ?? { type: "conversation" },
140
153
  timeoutSeconds: draft.timeoutSeconds ?? DEFAULT_TIMEOUT,
141
154
  nextRunAt: null,
142
155
  creator,
@@ -321,7 +334,7 @@ export class TaskDefinitions {
321
334
  if (raw === undefined)
322
335
  return undefined;
323
336
  const value = record(raw);
324
- if (!value || (value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
337
+ if (!value || (value.type !== "conversation" && value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
325
338
  throw new Error("invalid callback");
326
339
  }
327
340
  if (value.type !== "session")
@@ -1,8 +1,11 @@
1
1
  // One queued run carried to a result: dispatched by action kind, abortable,
2
2
  // settled exactly once. The lifecycle command.ts and agent.ts share.
3
3
  import { logger } from "../log.js";
4
+ import { settleCallback } from "./callbacks.js";
4
5
  import { runBash } from "./command.js";
6
+ import { INTERRUPTED } from "./store.js";
5
7
  const log = logger("tasks");
8
+ const SHUTDOWN = Symbol("shutdown");
6
9
  export class TaskExecution {
7
10
  store;
8
11
  definitions;
@@ -20,9 +23,11 @@ export class TaskExecution {
20
23
  start(run) {
21
24
  void this.execute(run);
22
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. */
23
28
  stop() {
24
29
  for (const controller of this.controllers.values())
25
- controller.abort();
30
+ controller.abort(SHUTDOWN);
26
31
  }
27
32
  cancel(id) {
28
33
  log.info(`run ${id} cancel requested`);
@@ -74,9 +79,11 @@ export class TaskExecution {
74
79
  }
75
80
  catch (error) {
76
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.
77
83
  const aborted = controller.signal.aborted;
78
- run.state = aborted ? (timedOut ? "failed" : "cancelled") : "failed";
79
- 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);
80
87
  cause = timedOut ? run.error : error;
81
88
  }
82
89
  finally {
@@ -92,8 +99,7 @@ export class TaskExecution {
92
99
  log.debug(`${settled} (watch did not match)`);
93
100
  else
94
101
  log.info(settled);
95
- if (run.callbackSessionId)
96
- run.callbackState = "pending";
102
+ settleCallback(run, this.store);
97
103
  this.controllers.delete(run.id);
98
104
  try {
99
105
  this.store.saveRun(run);
@@ -13,12 +13,14 @@ export class TaskGroups {
13
13
  host;
14
14
  changed;
15
15
  unreachable;
16
+ headOf;
16
17
  outbox;
17
- constructor(store, router, host, changed, unreachable) {
18
+ constructor(store, router, host, changed, unreachable, headOf = (id) => id, milestone) {
18
19
  this.store = store;
19
20
  this.host = host;
20
21
  this.changed = changed;
21
22
  this.unreachable = unreachable;
23
+ this.headOf = headOf;
22
24
  this.outbox = new Outbox(router, {
23
25
  id: (group) => group.id,
24
26
  reload: (id) => this.store.getGroup(id),
@@ -31,6 +33,7 @@ export class TaskGroups {
31
33
  origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
32
34
  }),
33
35
  abandoned: (group, sessionId, why) => unreachable(sessionId, `the result of a ${String(group.memberRunIds.length)}-run group`, why),
36
+ milestone,
34
37
  });
35
38
  }
36
39
  /** Enqueues every member or none: a partially started group is worse than
@@ -141,7 +144,7 @@ export class TaskGroups {
141
144
  const group = this.store.getGroup(candidate.id);
142
145
  if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
143
146
  return;
144
- await this.outbox.deliver(group.callbackSessionId, [group]);
147
+ await this.outbox.deliver(this.headOf(group.callbackSessionId), [group]);
145
148
  }
146
149
  text(group) {
147
150
  const members = group.memberRunIds.map((id) => this.host.getRun(id));
@@ -23,13 +23,17 @@ export class TaskMessenger {
23
23
  store;
24
24
  hub;
25
25
  unreachable;
26
+ runChanged;
26
27
  outbox;
27
28
  constructor(store, router, hub,
28
29
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
29
- unreachable) {
30
+ unreachable,
31
+ /** A follow-up's run card counts it while it is pending (service.ts). */
32
+ runChanged) {
30
33
  this.store = store;
31
34
  this.hub = hub;
32
35
  this.unreachable = unreachable;
36
+ this.runChanged = runChanged;
33
37
  this.outbox = new Outbox(router, {
34
38
  id: ({ message }) => message.id,
35
39
  reload: (id) => {
@@ -68,12 +72,6 @@ export class TaskMessenger {
68
72
  for (const message of this.store.expirePendingMessages())
69
73
  this.changed(message);
70
74
  }
71
- list(runId) {
72
- return this.store.listMessages(runId);
73
- }
74
- recent(since) {
75
- return this.store.listRecentMessages(since);
76
- }
77
75
  async control(run, fromSessionId, kind, content) {
78
76
  const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content);
79
77
  this.changed(message);
@@ -178,5 +176,7 @@ export class TaskMessenger {
178
176
  /** Publish only after any transaction changing this message has committed. */
179
177
  changed(message) {
180
178
  this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
179
+ if (message.kind === "follow_up")
180
+ this.runChanged(message.runId);
181
181
  }
182
182
  }
@@ -0,0 +1,88 @@
1
+ // The continuous conversation's open items in task-run words: main's markers
2
+ // joined to the run ledger by session, a lead's workers counted by state, and
3
+ // the one text every surface shows (docs/design/10-continuous-session.md#open-items).
4
+ import { openItemMarkers, openRunText } from "../core/reply.js";
5
+ import { LEDGER_WINDOW_MS, NOT_IN_LEDGER, TASK_RUN_STATES } from "../core/types.js";
6
+ import { logger } from "../log.js";
7
+ const log = logger("tasks");
8
+ const IN_FLIGHT = new Set(["queued", "running"]);
9
+ /** `members`: the chain, newest first; `designs`: the open designs of sessions not closed. */
10
+ export function openItems(tasks, router, members, designs, now) {
11
+ const since = now - LEDGER_WINDOW_MS;
12
+ const runs = members.length ? tasks.ledger(members, since) : [];
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";
19
+ // An item names a session through any of its runs: the session's newest run stands for it.
20
+ const tracked = new Set();
21
+ const items = tasks.store.openItems().map((row) => {
22
+ const named = row.runIds.map((id) => {
23
+ const session = runs.find((r) => r.runId === id)?.targetSessionId ?? tasks.store.getRun(id)?.targetSessionId ?? null;
24
+ tracked.add(session ?? id);
25
+ const run = runs.find((r) => (session ? r.targetSessionId === session : r.runId === id));
26
+ return run ?? { runId: id, name: id, state: NOT_IN_LEDGER, targetSessionId: session, cwd: null, queuedAt: 0, finishedAt: null };
27
+ }).filter((r, i, all) => all.findIndex((o) => o.runId === r.runId) === i);
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) };
36
+ });
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
+ };
56
+ }
57
+ /** The head's reply's markers, written; answers whether a row changed. */
58
+ export function recordOpenItems(store, text, now) {
59
+ const { markers, dropped } = openItemMarkers(text);
60
+ for (const marker of dropped)
61
+ log.warn(`open items: dropped a marker with no problem text: ${marker}`);
62
+ return markers.length > 0 && store.markOpenItems(markers, now) > 0;
63
+ }
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)
72
+ return "Nothing open.";
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
+ ];
78
+ return [
79
+ ...(waiting.length ? ["Waiting on you", ...waiting] : []),
80
+ ...(running.length ? ["In progress", ...running] : []),
81
+ ].join("\n");
82
+ }
83
+ /** `/status`'s card: the text, and run id → session id for every run it names that has one. */
84
+ export function openItemsStatus(open, now) {
85
+ const sessions = Object.fromEntries([...open.items.flatMap((i) => i.runs), ...open.unlisted]
86
+ .flatMap((r) => (r.targetSessionId ? [[r.runId, r.targetSessionId]] : [])));
87
+ return { text: renderOpenItems(open, now), sessions };
88
+ }
@@ -1,8 +1,9 @@
1
1
  // The `/task` socket route: every `pier task` operation — run, message, save,
2
- // list, cancel, recover — validated here once, answered with the summaries a
3
- // model reads. Scheduling and delivery stay in the service; this file decides
4
- // who may ask for what.
2
+ // list, pause, resume, archive, cancel, recover — validated here once, answered
3
+ // with the summaries a model reads. Scheduling and delivery stay in the
4
+ // service; this file decides who may ask for what.
5
5
  import { isAbsolute, resolve } from "node:path";
6
+ import { isModelTier, LEDGER_WINDOW_MS } from "../core/types.js";
6
7
  import { logger } from "../log.js";
7
8
  import { record, requiredString } from "./definitions.js";
8
9
  import { isTerminal } from "./types.js";
@@ -104,35 +105,67 @@ function cancelledOrCurrent(host, id, ms) {
104
105
  new Promise((resolve) => setTimeout(() => resolve(host.getRun(id)), ms).unref()),
105
106
  ]);
106
107
  }
107
- export async function handleTask(host, definitions, store, raw, callerSessionId) {
108
+ export async function handleTask(host, definitions, store, raw, callerSessionId, chain) {
109
+ // The launching session controls a run, and so does the run's own; every
110
+ // member of the continuous conversation counts as the one that launched it.
111
+ const launchers = () => chain.chainOf(callerSessionId) ?? [callerSessionId];
112
+ const assertOwns = (target) => {
113
+ if (!launchers().includes(target.invokedBySessionId ?? "") && target.targetSessionId !== callerSessionId) {
114
+ throw new Error("session does not own this run");
115
+ }
116
+ };
108
117
  const input = record(raw);
109
118
  if (!input)
110
119
  throw new Error("task parameters required");
111
- // Delegation is one level (docs/design/09-tasks-cli.md §Two levels, no tree):
112
- // what a supervised run launched would report to a session no run owns. A
113
- // queued run has not taken the session's turn, so it gates nothing yet.
120
+ // Depth stays 2 (docs/design/09-tasks-cli.md §Two levels, no tree); a queued
121
+ // run has not taken the session's turn, so it gates nothing yet.
122
+ const role = store.roleOf(callerSessionId);
123
+ const lead = role === "lead";
114
124
  const active = store.findActiveRunForTarget(callerSessionId);
115
- if (active?.state === "running" && store.supervised(active))
125
+ if (active?.state === "running" && store.supervised(active) && !lead)
116
126
  throw new Error("a delegated run cannot delegate; ask in your result and let your supervisor run it");
127
+ if (role === "worker")
128
+ throw new Error("a worker's session never delegates, in a run or after it; say what needs another agent and let the session that launched it run it");
129
+ // Read off a draft before it is filed; a saved definition has the same shape under `action`.
130
+ const notLead = (draft) => {
131
+ const role = record(record(draft)?.launch)?.role ?? record(record(record(draft)?.action)?.launch)?.role;
132
+ if (lead && role === "lead")
133
+ throw new Error("a feature lead cannot launch a lead; a build lead is your supervisor's to launch, from your milestone");
134
+ return draft;
135
+ };
117
136
  const menu = () => host.models().then((listed) => listed.models);
118
- if (input.operation === "list")
119
- return definitions.list().filter((task) => task.kind !== "subagent");
137
+ if (input.operation === "list") {
138
+ // What the schedule answer needs: the next occurrence and the last run's state, not the run.
139
+ return definitions.list().filter((task) => task.kind !== "subagent").map(({ nextRunAt, ...task }) => {
140
+ const last = host.listRuns(task.id, 1)[0];
141
+ return { ...task, nextRun: nextRunAt, lastRun: last ? defined({ runId: last.id, state: last.state, startedAt: last.startedAt, finishedAt: last.finishedAt }) : null };
142
+ });
143
+ }
144
+ if (input.operation === "runs")
145
+ return host.ledger(launchers(), Date.now() - LEDGER_WINDOW_MS);
146
+ // A one-shot's hidden definition is a run's record, not a task anyone filed.
147
+ const filed = () => {
148
+ const id = requiredString(input.task_id, "task_id");
149
+ if (definitions.get(id).kind === "subagent")
150
+ throw new Error(`${id} is a one-shot run's own definition; save without --task-id to file a task`);
151
+ return id;
152
+ };
120
153
  if (input.operation === "save") {
121
154
  const draft = await expandDraft(definitions, menu, input.task, callerSessionId);
122
155
  if (input.task_id === undefined)
123
156
  return definitions.create(draft, `session:${callerSessionId}`);
124
- const id = requiredString(input.task_id, "task_id");
125
- // A one-shot's hidden definition is a run's record, not a task anyone filed.
126
- if (definitions.get(id).kind === "subagent")
127
- throw new Error(`${id} is a one-shot run's own definition; save without --task-id to file a task`);
128
- return definitions.update(id, draft);
157
+ return definitions.update(filed(), draft);
129
158
  }
159
+ if (input.operation === "pause" || input.operation === "resume")
160
+ return host.setEnabled(filed(), input.operation === "resume");
161
+ if (input.operation === "archive")
162
+ return host.archive(filed());
130
163
  if (input.operation === "run") {
131
164
  // `--model ?`: the menu instead of a run, the one lookup the common case never pays.
132
165
  if (record(input.launch)?.model === "?") {
133
166
  const { source, models } = await host.models();
134
167
  const head = source === "menu" ? "the operator's menu" : "the live catalog (no model pinned)";
135
- return `${head} — --model takes a provider/id or a unique substring of one:\n${menuLines(models)}`;
168
+ return `${head} — --model takes a tier, a provider/id or a unique substring of one:\n${menuLines(models)}`;
136
169
  }
137
170
  const callbackMode = callbackModeOf(input);
138
171
  if (Array.isArray(input.tasks)) {
@@ -149,8 +182,8 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
149
182
  if (!entry)
150
183
  throw new Error("invalid tasks[] entry");
151
184
  resolved.push(entry.task_id === undefined
152
- ? await resolveDraft(definitions, menu, entry, callerSessionId)
153
- : definitions.get(requiredString(entry.task_id, "task_id")));
185
+ ? await resolveDraft(definitions, menu, notLead(entry), callerSessionId)
186
+ : notLead(definitions.get(requiredString(entry.task_id, "task_id"))));
154
187
  }
155
188
  const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
156
189
  const { group, runs } = host.runGroup(resolved, oneOf(input, "join", ["all", "first"]) ?? "all", callerSessionId, groupCallbackSessionId, callbackMode);
@@ -158,8 +191,8 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
158
191
  }
159
192
  const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
160
193
  const task = draft
161
- ? await resolveDraft(definitions, menu, draft, callerSessionId)
162
- : definitions.get(requiredString(input.task_id, "task_id"));
194
+ ? await resolveDraft(definitions, menu, notLead(draft), callerSessionId)
195
+ : notLead(definitions.get(requiredString(input.task_id, "task_id")));
163
196
  const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
164
197
  const run = host.run(task.id, null, "agent", null, {
165
198
  invokedBySessionId: callerSessionId,
@@ -205,12 +238,12 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
205
238
  if (typeof input.group_id === "string") {
206
239
  const { group, members } = host.getGroup(input.group_id);
207
240
  for (const member of members)
208
- assertOwns(callerSessionId, member);
241
+ assertOwns(member);
209
242
  const cancelled = host.cancelGroup(group.id);
210
243
  return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)));
211
244
  }
212
245
  const run = host.getRun(requiredString(input.run_id, "run_id"));
213
- assertOwns(callerSessionId, run);
246
+ assertOwns(run);
214
247
  // cancel() only aborts; the row flips ~100 ms later. A receipt still saying
215
248
  // `running` reads as "cancel failed", so wait for the flip, briefly.
216
249
  host.cancel(run.id);
@@ -221,7 +254,7 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
221
254
  // caller, decides whether the text steers, queues or resumes, so a status
222
255
  // query never has to exist.
223
256
  const run = host.getRun(requiredString(input.run_id, "run_id"));
224
- assertOwns(callerSessionId, run);
257
+ assertOwns(run);
225
258
  const message = requiredString(input.message, "message");
226
259
  if (isTerminal(run.state)) {
227
260
  // A resumed run is a new run with its own callback.
@@ -254,23 +287,33 @@ function nameFrom(text) {
254
287
  .find(Boolean) ?? "subagent";
255
288
  return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
256
289
  }
257
- /** `launch.model` by name (docs/design/09-tasks-cli.md §Models). Anything but
258
- * one pin or an unpinned `provider/id` is refused with the lines to pick
259
- * from, so the agent never guesses an id. */
290
+ /** `launch.model` by name (docs/design/09-tasks-cli.md §Models): the one pin
291
+ * it names, a tier its first. Anything but a tier, one pin or an unpinned
292
+ * `provider/id` is refused with the lines to pick from, so the agent never
293
+ * guesses an id. A tier is never a substring: "cheap" matching an id would
294
+ * pick a pin the operator did not assign. */
260
295
  function resolveModel(name, menu) {
261
296
  const needle = name.trim().toLowerCase();
262
297
  const full = (pin) => `${pin.provider}/${pin.id}`;
263
- const exact = menu.find((pin) => full(pin).toLowerCase() === needle);
264
- const hits = exact ? [exact] : menu.filter((pin) => `${full(pin)} ${pin.note ?? ""}`.toLowerCase().includes(needle));
265
- if (hits.length === 1)
266
- return { model: { provider: hits[0].provider, id: hits[0].id }, thinking: hits[0].thinking };
298
+ const pick = (pin) => ({ model: { provider: pin.provider, id: pin.id }, thinking: pin.thinking });
299
+ if (isModelTier(needle)) {
300
+ const pin = menu.find((p) => p.tier === needle);
301
+ if (pin)
302
+ return pick(pin);
303
+ throw new Error(`model "${name}": tier ${needle} is unassigned — the operator's menu:\n${menuLines(menu)}`);
304
+ }
305
+ const exact = menu.filter((pin) => full(pin).toLowerCase() === needle);
306
+ const hits = exact.length ? exact : menu.filter((pin) => full(pin).toLowerCase().includes(needle));
307
+ // One model pinned at several levels is still one model: its first row, as with a tier.
308
+ if (hits.length && hits.every((pin) => full(pin) === full(hits[0])))
309
+ return pick(hits[0]);
267
310
  const slash = name.indexOf("/");
268
311
  if (!hits.length && slash > 0 && slash < name.length - 1)
269
312
  return { model: { provider: name.slice(0, slash), id: name.slice(slash + 1) } };
270
313
  throw new Error(`model "${name}" matches ${String(hits.length)} of the menu:\n${menuLines(hits.length ? hits : menu)}`);
271
314
  }
272
315
  /** One pin per line, the only shape a menu is ever printed in. */
273
- const menuLines = (menu) => menu.map((pin) => `${pin.provider}/${pin.id}${pin.thinking ? ` · ${pin.thinking}` : ""}${pin.note ? ` — ${pin.note}` : ""}`).join("\n")
316
+ const menuLines = (menu) => menu.map((pin) => `${pin.tier ? `${pin.tier} · ` : ""}${pin.provider}/${pin.id}${pin.thinking ? ` · ${pin.thinking}` : ""}`).join("\n")
274
317
  || "(no model is pinned or available)";
275
318
  /** A `prompt` shorthand becomes a fresh Agent action in the caller's own
276
319
  * directory; everything the caller did spell out passes through to parseDraft. */
@@ -305,6 +348,7 @@ async function expandDraft(definitions, menu, raw, callerSessionId) {
305
348
  const launch = record(action?.launch);
306
349
  if (typeof launch?.model === "string") {
307
350
  const { model, thinking } = resolveModel(launch.model, await menu());
351
+ // A pin's level only where the caller named none.
308
352
  const resolved = { ...launch, model, ...(launch.thinking === undefined && thinking ? { thinking } : {}) };
309
353
  draft = { ...draft, action: { ...record(draft.action), launch: resolved } };
310
354
  }
@@ -324,12 +368,5 @@ async function resolveDraft(definitions, menu, raw, callerSessionId) {
324
368
  if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
325
369
  throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
326
370
  }
327
- return definitions.create({ ...draft, trigger: { type: "manual" } }, `session:${callerSessionId}`, "subagent");
328
- }
329
- /** The session that launched a run controls it, and so does the run's own
330
- * session; a `task` action's child inherits its parent's launcher. */
331
- function assertOwns(callerSessionId, target) {
332
- if (target.invokedBySessionId !== callerSessionId && target.targetSessionId !== callerSessionId) {
333
- throw new Error("session does not own this run");
334
- }
371
+ return definitions.create({ ...draft, trigger: { type: "manual" }, callback: { type: "none" } }, `session:${callerSessionId}`, "subagent");
335
372
  }
@@ -3,6 +3,11 @@
3
3
  import { logger } from "../log.js";
4
4
  import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
5
5
  const log = logger("tasks");
6
+ /** A result waiting on a lead's own running turn is asked again this often, not
7
+ * every tick: a wait spends no attempt, so the attempt backoff does not apply. */
8
+ const MILESTONE_WAIT_MS = 10_000;
9
+ /** The marks a delivered record carries: nothing left to retry. */
10
+ const DELIVERED = { callbackState: "delivered", callbackError: null, callbackNextAttemptAt: null };
6
11
  /** The records a delivery names, from either side of the seam: a batch names
7
12
  * every one of them, a group names itself, a message names its id. */
8
13
  const recordIds = (origin) => {
@@ -24,6 +29,17 @@ export class Outbox {
24
29
  * written only against the input visible in the recipient's transcript: Pi's
25
30
  * queues are memory, so a resolved `systemInput` proves nothing. */
26
31
  async deliver(sessionId, batch) {
32
+ const marked = batch.map((record) => ({ ...record, ...DELIVERED }));
33
+ const taken = this.kind.milestone?.(sessionId, () => this.kind.input(batch).text, () => { for (const record of marked)
34
+ this.kind.save(record); });
35
+ if (taken === "resumed")
36
+ for (const record of marked)
37
+ this.kind.changed(record);
38
+ if (taken === "wait")
39
+ for (const record of batch)
40
+ this.defer(record, MILESTONE_WAIT_MS);
41
+ if (taken === "resumed" || taken === "wait")
42
+ return;
27
43
  const mine = batch.filter((record) => !this.delivering.has(this.kind.id(record)));
28
44
  if (mine.length === 0)
29
45
  return;
@@ -128,8 +144,8 @@ export class Outbox {
128
144
  return true;
129
145
  }
130
146
  /** Busy target: try again shortly, and do not count it. */
131
- defer(record) {
132
- record.callbackNextAttemptAt = Date.now() + 1000;
147
+ defer(record, ms = 1000) {
148
+ record.callbackNextAttemptAt = Date.now() + ms;
133
149
  this.kind.save(record);
134
150
  }
135
151
  /** Backs off like a failure, so an input never recorded is re-sent on a
@@ -142,10 +158,7 @@ export class Outbox {
142
158
  this.kind.save(record);
143
159
  }
144
160
  delivered(record) {
145
- record.callbackState = "delivered";
146
- record.callbackError = null;
147
- record.callbackNextAttemptAt = null;
148
- this.write(record);
161
+ this.write(Object.assign(record, DELIVERED));
149
162
  }
150
163
  /** A failed pass costs one attempt, whether it died before or after send. */
151
164
  failed(record, error, counted) {