@timqi/pier 0.0.29 → 0.1.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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,21 +1,14 @@
1
1
  import { isAbsolute, resolve } from "node:path";
2
2
  import { Type } from "typebox";
3
- import { TaskDefinitions, record, requiredString } from "./definitions.js";
4
- import { TaskMessenger } from "./messages.js";
5
- import { TaskStore } from "./store.js";
3
+ import { logger } from "../log.js";
4
+ import { record, requiredString } from "./definitions.js";
5
+ import { isTerminal } from "./types.js";
6
+ const log = logger("tasks");
6
7
  // JSON-Schema enum emits ~1/3 the tokens of typebox's anyOf-of-consts.
7
8
  const strEnum = (...values) => Type.Unsafe({ type: "string", enum: [...values] });
8
- /**
9
- * Drop the fields with nothing in them instead of sending `null`.
10
- *
11
- * A run summary has eighteen fields and most are empty for most of a run's
12
- * life; a model reads "absent" and "null" the same way. On a `get` that lists
13
- * several runs this is a third of the payload.
14
- *
15
- * The input names every field — a summary that forgot one would otherwise pass
16
- * as "that field was empty" — and the result is the type with the empty ones
17
- * gone, which is why those are declared optional above.
18
- */
9
+ /** Absent instead of `null`: a model reads both the same way, and on a group
10
+ * summary the nulls are a third of the payload. The input names every field
11
+ * so a summary that forgot one cannot pass as "empty". */
19
12
  const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined));
20
13
  const summarize = (run, pendingDecisionId) => defined({
21
14
  runId: run.id,
@@ -28,6 +21,7 @@ const summarize = (run, pendingDecisionId) => defined({
28
21
  targetSessionId: run.targetSessionId,
29
22
  callbackSessionId: run.callbackSessionId,
30
23
  callbackState: run.callbackState,
24
+ callbackMode: run.callbackMode ?? null,
31
25
  pendingDecisionId,
32
26
  depth: run.depth,
33
27
  queuedAt: run.queuedAt,
@@ -36,9 +30,35 @@ const summarize = (run, pendingDecisionId) => defined({
36
30
  result: run.result,
37
31
  error: run.error,
38
32
  skipReason: run.skipReason,
33
+ next: null,
39
34
  });
40
- /** A list echoes many results at once, so each is capped; a single-run `get`
41
- * stays whole — it is the escape hatch every truncation note points at. */
35
+ /** The receipt says the callback is the whole answer, or a model that just
36
+ * launched work reaches for a status call. */
37
+ const receipt = (summary, callbackSessionId, mode, callerSessionId) => ({
38
+ ...summary,
39
+ next: callbackSessionId === null
40
+ ? "callback none: the result is not delivered to anyone"
41
+ : callbackSessionId !== callerSessionId
42
+ ? `the result is delivered to session ${callbackSessionId}; this session will not receive a callback`
43
+ : mode === "steer"
44
+ ? "the result interrupts your running turn as a steer message; nothing to query"
45
+ : "the result arrives as a callback message once your turn ends; nothing to query",
46
+ });
47
+ const SUBAGENT_REDIRECT = "subagents cannot redirect callbacks (callback_session_id)";
48
+ /** Who a new run's result goes to — the caller, nobody, or a named session that
49
+ * must exist. Shared by `run` and `resume`: a resumed run is a new run. */
50
+ const callbackTarget = async (input, definitions, callerSessionId) => {
51
+ if (input.callback_session_id === undefined)
52
+ return input.callback === "none" ? null : callerSessionId;
53
+ if (input.callback === "none")
54
+ throw new Error("callback none and callback_session_id conflict: pick one delivery target");
55
+ const target = requiredString(input.callback_session_id, "callback_session_id");
56
+ if (!(await definitions.sessionExists(target)))
57
+ throw new Error(`unknown session: ${target}`);
58
+ return target;
59
+ };
60
+ /** A group echoes many results at once, so each is capped; a single-run
61
+ * `recover` stays whole — it is the escape hatch every truncation note points at. */
42
62
  const trimResult = (summary) => {
43
63
  if (summary.result?.type !== "agent" || summary.result.text.length <= 2000)
44
64
  return summary;
@@ -46,24 +66,36 @@ const trimResult = (summary) => {
46
66
  ...summary,
47
67
  result: {
48
68
  ...summary.result,
49
- text: `${summary.result.text.slice(0, 2000)}\n[truncated — get run_id ${summary.runId} for the full text]`,
69
+ text: `${summary.result.text.slice(0, 2000)}\n[truncated — recover run_id ${summary.runId} with a reason for the full text]`,
50
70
  },
51
71
  };
52
72
  };
73
+ /** Delivered, given up on and reported, or never owed (`callback:"none"`).
74
+ * Anything else is still on its way, and reading it here would be reading it twice. */
75
+ const settled = (callback) => callback.callbackState === null || callback.callbackState === "delivered" || callback.callbackState === "abandoned";
76
+ /** The same words for queued, running, pending and retrying: a refusal that
77
+ * named the state would be the status query this operation replaced. */
78
+ const notRecoverable = (what, callback) => {
79
+ throw new Error(callback.callbackSessionId === null
80
+ ? `${what} is not recoverable yet: it was launched with callback none, so nothing will be delivered; recover reads finished results only and cannot wait for work`
81
+ : `${what} is not recoverable yet: wait for automatic delivery to session ${callback.callbackSessionId}; recover cannot wait for work`);
82
+ };
53
83
  const summarizeGroup = (group, members, messages) => defined({
54
84
  groupId: group.id,
55
85
  join: group.join,
56
86
  state: group.finishedAt ? "finished" : "running",
57
87
  callbackState: group.callbackState,
88
+ callbackMode: group.callbackMode ?? null,
58
89
  winnerRunId: group.winnerRunId,
59
90
  members: members.map((run) => trimResult(summarize(run, messages.openDecisionId(run.id)))),
91
+ next: null,
60
92
  });
61
93
  const LaunchSchema = Type.Object({
62
94
  model: Type.Optional(Type.Object({ provider: Type.String(), id: Type.String() })),
63
95
  thinking: Type.Optional(Type.String()),
64
96
  });
65
- // Model-facing draft shape. Guidance only: runtime truth stays in parseDraft,
66
- // so schema drift can never loosen boundary validation.
97
+ // Guidance only: runtime truth stays in parseDraft, so schema drift cannot
98
+ // loosen boundary validation.
67
99
  const DraftSchema = Type.Object({
68
100
  name: Type.Optional(Type.String({ description: "Defaults to the prompt's first line." })),
69
101
  description: Type.Optional(Type.String()),
@@ -103,34 +135,32 @@ export function taskToolSpec(execute) {
103
135
  return {
104
136
  name: "task",
105
137
  label: "Pier Task",
106
- description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. create files a definition the operator sees in the Console — only for schedules or roles you will run again; a one-off is run with a prompt. Run executes a stored task by task_id, a one-shot subagent from a prompt (shorthand: prompt + optional cwd/launch/name — cwd defaults to your own directory, relative paths resolve against it, name comes from the prompt) or from a full inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions. models lists the deployment's model menu (operator pins with intent notes, else the live catalog).",
138
+ description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. create files a definition the operator sees in the Console — only for schedules or roles you will run again; a one-off is run with a prompt. Run executes a stored task by task_id, a one-shot subagent from a prompt (shorthand: prompt + optional cwd/launch/name/timeoutSeconds — cwd defaults to your own directory, relative paths resolve against it, name comes from the prompt) or from a full inline task draft, or a core-joined fan-out via tasks[] with join all|first. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages once your turn ends — there is no status query; pass callback 'steer' to have a result interrupt your running turn instead, or 'none' for no callback at all. recover (run_id or group_id, plus a reason) re-reads a finished result after its callback has settled — for truncated text or lost context, never to check progress. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions. models lists the deployment's model menu (operator pins with intent notes, else the live catalog).",
107
139
  parameters: Type.Object({
108
- operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
140
+ operation: strEnum("list", "create", "update", "run", "recover", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
109
141
  task_id: Type.Optional(Type.String()),
110
142
  run_id: Type.Optional(Type.String()),
111
143
  group_id: Type.Optional(Type.String()),
112
144
  message_id: Type.Optional(Type.String()),
113
145
  message: Type.Optional(Type.String()),
114
- reason: Type.Optional(strEnum("progress", "decision")),
146
+ reason: Type.Optional(Type.String({ description: "contact: progress | decision. recover: why the delivered callback is not enough (required)." })),
115
147
  session_mode: Type.Optional(strEnum("fresh")),
116
- // The one-shot shorthand: a prompt is the whole delegation, and the
117
- // fresh session in the caller's own directory is what it means.
118
148
  prompt: Type.Optional(Type.String()),
119
149
  cwd: Type.Optional(Type.String()),
120
150
  launch: Type.Optional(LaunchSchema),
121
151
  name: Type.Optional(Type.String()),
152
+ timeoutSeconds: Type.Optional(Type.Number({ description: "1–86400; defaults to 3600." })),
122
153
  task: Type.Optional(DraftSchema),
123
- // The same draft again, spelled out, cost more tokens in every session
124
- // than the whole rest of this contract. One copy is the guidance; this
125
- // one points at it, and `parseDraft` is what actually validates either.
154
+ // Spelled out, the draft schema costs more tokens per session than the
155
+ // rest of this contract; `parseDraft` validates either shape.
126
156
  tasks: Type.Optional(Type.Unsafe({
127
157
  type: "array",
128
- description: "2+ entries, each a prompt string, {prompt, cwd?, launch?, name?}, a task draft shaped exactly like `task`, or {task_id}.",
158
+ description: "2+ entries, each a prompt string, {prompt, cwd?, launch?, name?, timeoutSeconds?}, a task draft shaped exactly like `task`, or {task_id}.",
129
159
  items: { type: "object" },
130
160
  })),
131
161
  join: Type.Optional(strEnum("all", "first")),
132
162
  input: Type.Optional(Type.Unknown()),
133
- callback: Type.Optional(strEnum("origin", "none")),
163
+ callback: Type.Optional(strEnum("origin", "none", "steer")),
134
164
  callback_session_id: Type.Optional(Type.String()),
135
165
  }),
136
166
  execute,
@@ -156,12 +186,19 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
156
186
  return definitions.update(requiredString(input.task_id, "task_id"), await expandDraft(definitions, input.task, callerSessionId));
157
187
  }
158
188
  if (input.operation === "run") {
189
+ const callbackMode = input.callback === "steer" ? "steer" : "followUp";
190
+ // A run's own callback is its parent's link back; a child that could point
191
+ // it elsewhere would strand the supervisor waiting for a result.
192
+ if (active && input.callback_session_id !== undefined)
193
+ throw new Error(SUBAGENT_REDIRECT);
159
194
  if (Array.isArray(input.tasks)) {
160
195
  // Core-joined fan-out: members run detached, one aggregated callback.
161
196
  if (input.task !== undefined || input.task_id !== undefined)
162
197
  throw new Error("use either task/task_id or tasks[]");
163
198
  if (input.session_mode !== undefined)
164
199
  throw new Error("session_mode applies to a single run only");
200
+ if (input.callback_session_id !== undefined)
201
+ throw new Error("callback_session_id applies to a single run only");
165
202
  if (input.tasks.length < 2)
166
203
  throw new Error("tasks[] needs at least 2 entries; use task for a single run");
167
204
  const resolved = [];
@@ -173,8 +210,9 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
173
210
  ? await resolveDraft(definitions, entry, active, callerSessionId)
174
211
  : resolveStored(definitions, entry.task_id, active));
175
212
  }
176
- const { group, runs } = host.runGroup(resolved, input.join === "first" ? "first" : "all", callerSessionId, active?.id ?? null, input.callback === "none" ? null : callerSessionId);
177
- return summarizeGroup(group, runs, messages);
213
+ const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
214
+ const { group, runs } = host.runGroup(resolved, input.join === "first" ? "first" : "all", callerSessionId, active?.id ?? null, groupCallbackSessionId, callbackMode);
215
+ return receipt(summarizeGroup(group, runs, messages), groupCallbackSessionId, callbackMode, callerSessionId);
178
216
  }
179
217
  const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
180
218
  const task = draft
@@ -186,33 +224,56 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
186
224
  throw new Error(`unsupported session_mode: ${String(input.session_mode)}`);
187
225
  }
188
226
  const sessionMode = input.session_mode;
189
- let callbackSessionId = input.callback === "none" ? null : callerSessionId;
190
- if (!active && callbackSessionId && typeof input.callback_session_id === "string") {
191
- callbackSessionId = requiredString(input.callback_session_id, "callback_session_id");
192
- if (!(await definitions.sessionExists(callbackSessionId)))
193
- throw new Error(`unknown session: ${callbackSessionId}`);
194
- }
227
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
195
228
  const run = host.run(task.id, input.input, "agent", active?.id ?? null, {
196
229
  invokedBySessionId: callerSessionId,
197
230
  sourceSessionId: callerSessionId,
198
231
  callbackSessionId,
232
+ callbackMode,
199
233
  background: true,
200
234
  sessionMode,
201
235
  });
202
- return summarize(run, null);
236
+ return receipt(summarize(run, null), callbackSessionId, callbackMode, callerSessionId);
203
237
  }
204
- if (input.operation === "get") {
238
+ if (input.operation === "recover") {
239
+ // History only, never status: readable once the callback has said its last
240
+ // word. The required reason is the friction, and the operator sees it.
241
+ const reason = requiredString(input.reason, "reason");
242
+ // The open question is the notification; the reply's continuation reports.
243
+ const decisionOpen = (run) => {
244
+ throw new Error(`run ${run.id} finished awaiting your decision ${messages.openDecisionId(run.id) ?? ""}; reply to it — the continuation's callback brings the result`);
245
+ };
246
+ // A race winner need not wait for losing members to finish cancelling.
247
+ const groupReady = (group) => {
248
+ if (!group.finishedAt || !settled(group))
249
+ notRecoverable(`group ${group.id}`, group);
250
+ };
205
251
  if (typeof input.group_id === "string") {
206
252
  const { group, members } = host.getGroup(input.group_id);
253
+ groupReady(group);
254
+ if (!members.every((run) => isTerminal(run.state))) {
255
+ throw new Error(`recover cannot inspect active members; the race has settled — recover its winning result with run_id ${group.winnerRunId ?? "from the callback"} and a reason`);
256
+ }
257
+ const asking = members.find((run) => messages.openDecisionId(run.id));
258
+ if (asking)
259
+ decisionOpen(asking);
260
+ log.info(`recover group ${group.id} by ${callerSessionId}: ${reason}`);
207
261
  return summarizeGroup(group, members, messages);
208
262
  }
209
- // Run history by task: without it, checking what a task did (or whether a
210
- // cascade landed) means leaving the tool for the database.
211
- if (input.run_id === undefined && typeof input.task_id === "string") {
212
- return host.listRuns(input.task_id, 10).map((run) => trimResult(summarize(run, messages.openDecisionId(run.id))));
213
- }
214
263
  const run = host.getRun(requiredString(input.run_id, "run_id"));
215
- return summarize(run, messages.openDecisionId(run.id));
264
+ if (run.groupId) {
265
+ const { group } = host.getGroup(run.groupId);
266
+ groupReady(group);
267
+ if (!isTerminal(run.state))
268
+ throw new Error("the group has reported its outcome; recover reads finished results only and cannot wait for this member");
269
+ }
270
+ else if (!isTerminal(run.state) || !settled(run)) {
271
+ notRecoverable(`run ${run.id}`, run);
272
+ }
273
+ if (messages.openDecisionId(run.id))
274
+ decisionOpen(run);
275
+ log.info(`recover run ${run.id} by ${callerSessionId}: ${reason}`);
276
+ return summarize(run, null);
216
277
  }
217
278
  if (input.operation === "cancel") {
218
279
  if (typeof input.group_id === "string") {
@@ -235,17 +296,28 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
235
296
  if (input.operation === "resume") {
236
297
  const prior = host.getRun(requiredString(input.run_id, "run_id"));
237
298
  assertOwns(store, callerSessionId, active, prior);
299
+ // The resumed run is a new run, so it carries its own callback options,
300
+ // under the same rule as `run`: a subagent may not redirect them.
301
+ if (active && input.callback_session_id !== undefined)
302
+ throw new Error(SUBAGENT_REDIRECT);
303
+ const callbackMode = input.callback === "steer" ? "steer" : "followUp";
304
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
238
305
  const run = host.resume(prior.id, requiredString(input.message, "message"), {
239
306
  invokedBySessionId: callerSessionId,
240
- callbackSessionId: input.callback === "none" ? null : callerSessionId,
307
+ callbackSessionId,
308
+ callbackMode,
241
309
  background: true,
242
310
  });
243
- return summarize(run, null);
311
+ return receipt(summarize(run, null), callbackSessionId, callbackMode, callerSessionId);
244
312
  }
245
313
  if (input.operation === "contact") {
246
314
  if (!active)
247
315
  throw new Error("contact is only available inside an active Agent run");
248
- const reason = input.reason === "decision" ? "decision" : "progress";
316
+ // The schema no longer narrows `reason` (recover shares the field), so the
317
+ // two names contact accepts are checked here.
318
+ const reason = input.reason === undefined ? "progress" : input.reason;
319
+ if (reason !== "progress" && reason !== "decision")
320
+ throw new Error(`contact reason must be progress or decision, got ${String(reason)}`);
249
321
  return messages.contact(active, callerSessionId, reason, requiredString(input.message, "message"));
250
322
  }
251
323
  if (input.operation === "reply") {
@@ -255,28 +327,22 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
255
327
  }
256
328
  /** A single run's draft: the top-level shorthand (`prompt` …) or `task`, never both. */
257
329
  function inlineDraft(input) {
258
- const { prompt, cwd, launch, name } = input;
330
+ const { prompt, cwd, launch, name, timeoutSeconds } = input;
259
331
  if (prompt === undefined)
260
332
  return record(input.task) ?? undefined;
261
333
  if (input.task !== undefined)
262
334
  throw new Error("use either prompt or task");
263
- return { prompt, cwd, launch, name };
335
+ return { prompt, cwd, launch, name, timeoutSeconds };
264
336
  }
265
- /** The prompt's first line, unmarked and cut short: a label for the Console,
266
- * not an identifier — the run's id is what anything addresses. */
337
+ /** A label for the Console, not an identifier. */
267
338
  function nameFromPrompt(prompt) {
268
339
  const line = prompt.split("\n")
269
340
  .map((l) => l.replace(/^[\s#>*-]+/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim())
270
341
  .find(Boolean) ?? "subagent";
271
342
  return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
272
343
  }
273
- /**
274
- * The shape a draft is validated in, from the shapes a caller may write it in.
275
- * A `prompt` shorthand becomes a fresh Agent action; a fresh session's cwd
276
- * resolves against the caller's own directory (and is that directory when
277
- * omitted); a missing name is the prompt's first line. Everything the caller
278
- * did spell out passes through untouched — parseDraft still judges it.
279
- */
344
+ /** A `prompt` shorthand becomes a fresh Agent action in the caller's own
345
+ * directory; everything the caller did spell out passes through to parseDraft. */
280
346
  async function expandDraft(definitions, raw, callerSessionId) {
281
347
  let draft = record(raw);
282
348
  if (!draft)
@@ -299,8 +365,8 @@ async function expandDraft(definitions, raw, callerSessionId) {
299
365
  draft = { ...draft, name: nameFromPrompt(action.prompt) };
300
366
  return draft;
301
367
  }
302
- /** Inline one-shot subagent: persisted like any task (kind "subagent",
303
- * filtered from default lists) so runs stay auditable and resumable. */
368
+ /** Persisted like any task (kind "subagent", filtered from default lists) so
369
+ * runs stay auditable and resumable. */
304
370
  async function resolveDraft(definitions, raw, active, callerSessionId) {
305
371
  const draft = record(await expandDraft(definitions, raw, callerSessionId));
306
372
  if (!draft)
@@ -308,6 +374,11 @@ async function resolveDraft(definitions, raw, active, callerSessionId) {
308
374
  if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
309
375
  throw new Error("inline subagent tasks must use a manual trigger");
310
376
  }
377
+ // Delivery of a one-off run is the top-level fields' business; a nested
378
+ // callback only means anything on a stored definition's schedule.
379
+ if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
380
+ throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
381
+ }
311
382
  if (active) {
312
383
  const action = record(draft.action);
313
384
  if (action?.type !== "agent")
@@ -1,36 +1,21 @@
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).
1
+ // A tools switch becomes exactly one run of the one task Pier owns. Beside
2
+ // tools.ts because tools.ts may not import tasks/; the task's run history is
3
+ // the tools status surface (§5).
11
4
  import { existsSync } from "node:fs";
12
5
  import { fileURLToPath } from "node:url";
13
6
  import { logger } from "./log.js";
14
7
  import { PIER_HOME } from "./paths.js";
15
8
  import { isTerminal } from "./tasks/types.js";
16
9
  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). */
10
+ /** The owner guard in tasks/definitions.ts: only this creator may write it back. */
20
11
  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.
12
+ // "pier", not "tools": what an operator greps the journal for.
23
13
  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. */
14
+ /** POSIX single quotes: this string is run by bash months from now. */
26
15
  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
- */
16
+ /** From a source checkout there is no `cli.js` and node cannot strip types
17
+ * through `.js` imports, so it is the same command under tsx. Neither
18
+ * available is a refusal with a reason, never a task whose script cannot run. */
34
19
  const toolsSyncScript = () => {
35
20
  const built = fileURLToPath(new URL("./cli.js", import.meta.url));
36
21
  if (existsSync(built))
@@ -49,22 +34,10 @@ const toolsSyncScript = () => {
49
34
  }
50
35
  };
51
36
  export function toolsTask(tasks) {
52
- /** Which task is Pier's, and the id every managed run goes through. */
53
37
  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
- */
38
+ /** Created once and never retired: a task that comes and goes is a state
39
+ * class of its own, and a run with nothing on already says so. Repairs a
40
+ * definition an older Pier could still edit. */
68
41
  const ensureToolsTask = async () => {
69
42
  const command = toolsSyncScript();
70
43
  if ("problem" in command)
@@ -83,40 +56,31 @@ export function toolsTask(tasks) {
83
56
  callback: { type: "none" },
84
57
  timeoutSeconds: 1800,
85
58
  };
86
- // Archived is not "owned but edited": nothing can un-archive a task, so the
87
- // replacement is a new one and the old one keeps its history.
59
+ // Nothing can un-archive a task, so an archived one is replaced, history kept.
88
60
  const owned = tasks.list().filter((task) => task.creator === TOOLS_TASK_CREATOR && !task.archived);
89
- // One per creator. Two would fight over ubix's state lock every night, each
90
- // reporting the other's run as an overlap.
61
+ // Two would fight over ubix's state lock every night.
91
62
  for (const extra of owned.slice(1)) {
92
63
  log.warn(`archiving a second tools update task (${extra.id})`);
93
64
  tasks.archive(extra.id, TOOLS_TASK_CREATOR);
94
65
  }
95
66
  const task = owned[0];
96
- // Every field Pier owns, not just the command: a paused task, a renamed one
97
- // or one pointed at a callback still claims to be keeping the tools current
98
- // while the daily run never happens.
67
+ // Every field, not just the command: a paused or renamed task still claims
68
+ // to keep the tools current while the daily run never happens.
99
69
  if (task && Object.entries(draft).some(([key, value]) => JSON.stringify(task[key]) !== JSON.stringify(value))) {
100
70
  log.warn("the tools update task was edited — restoring the definition Pier owns");
101
- // Named as the owner: this is the one path allowed to write it back
102
- // (tasks/definitions.ts).
103
71
  await tasks.update(task.id, draft, TOOLS_TASK_CREATOR);
104
72
  }
105
73
  const id = task ? task.id : (await tasks.create(draft, TOOLS_TASK_CREATOR)).id;
106
74
  toolsTaskId = id;
107
75
  return { id };
108
76
  };
109
- /** The half of `coalescedSync` (tools.ts, which has the rule and why) that
110
- * knows what a task is: start a run, and hand back what to wait for — our own
111
- * run, or the one already in flight that made ours a `skipped` row. */
77
+ /** The half of `coalescedSync` (tools.ts) that knows what a task is. */
112
78
  const requestSync = coalescedSync(() => {
113
79
  if (!toolsTaskId)
114
80
  throw new Error("no tools update task to run");
115
81
  const settled = (id) => tasks.waitForRun(id).then(() => undefined);
116
- // Bounded, because the only way round this loop is a run that finished
117
- // between being in flight and being asked about: real, rare, and not
118
- // something to spin on. Three refusals in a row with nothing running is a
119
- // bug, and it is reported as one rather than retried forever.
82
+ // A run can finish between being in flight and being asked about; three
83
+ // refusals with nothing running is a bug, reported rather than retried.
120
84
  for (let attempt = 0; attempt < 3; attempt++) {
121
85
  const mine = tasks.run(toolsTaskId, null, "manual");
122
86
  if (!isTerminal(mine.state))
@@ -127,8 +91,6 @@ export function toolsTask(tasks) {
127
91
  }
128
92
  throw new Error("the tools sync was refused as an overlap three times with nothing running");
129
93
  }, (err) => log.error("the tools sync could not be run", err));
130
- /** A switch was flipped: make sure the task is the one Pier means, then ask
131
- * for a sync. Answers with what that switch should say about it. */
132
94
  const toolsChanged = async () => {
133
95
  try {
134
96
  const task = await ensureToolsTask();
@@ -144,10 +106,8 @@ export function toolsTask(tasks) {
144
106
  }
145
107
  };
146
108
  return {
147
- /** Reconcile now. At boot, before any route exists: two first flips could
148
- * otherwise both find no task and create one each. */
109
+ /** At boot, before any route exists: two first flips could otherwise both create one. */
149
110
  reconcile: ensureToolsTask,
150
- /** The task whose runs are the status surface, null until there is one. */
151
111
  id: () => toolsTaskId,
152
112
  changed: toolsChanged,
153
113
  };