@timqi/pier 0.1.3 → 0.1.4

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 (97) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/packages.js +1 -7
  5. package/dist/agent/pi.js +6 -47
  6. package/dist/channels/config.js +30 -20
  7. package/dist/channels/routes.js +0 -3
  8. package/dist/channels/slack-api.js +19 -10
  9. package/dist/channels/slack-cli.js +503 -0
  10. package/dist/channels/slack-directory.js +2 -2
  11. package/dist/channels/slack-outbound.js +1 -1
  12. package/dist/channels/slack-thread.js +41 -0
  13. package/dist/channels/slack-transcript.js +107 -0
  14. package/dist/channels/slack.js +9 -17
  15. package/dist/channels/types.js +0 -1
  16. package/dist/cli.js +158 -7
  17. package/dist/core/identity.js +20 -11
  18. package/dist/core/reply.js +16 -15
  19. package/dist/core/router.js +4 -1
  20. package/dist/db.js +28 -0
  21. package/dist/main.js +19 -32
  22. package/dist/paths.js +3 -0
  23. package/dist/secrets.js +2 -1
  24. package/dist/socket.js +95 -0
  25. package/dist/tasks/agent.js +5 -6
  26. package/dist/tasks/callbacks.js +2 -2
  27. package/dist/tasks/cli.js +215 -0
  28. package/dist/tasks/definitions.js +3 -3
  29. package/dist/tasks/execution.js +1 -3
  30. package/dist/tasks/groups.js +2 -5
  31. package/dist/tasks/messages.js +18 -158
  32. package/dist/tasks/operations.js +323 -0
  33. package/dist/tasks/routes.js +1 -11
  34. package/dist/tasks/runs.js +5 -20
  35. package/dist/tasks/service.js +18 -43
  36. package/dist/tasks/store.js +15 -15
  37. package/dist/tools.js +17 -0
  38. package/dist/vault.js +107 -0
  39. package/dist/web/auth.js +2 -1
  40. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-NLS8W9yl.js} +2 -2
  41. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  42. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  43. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-DleegoiC.js} +1 -1
  44. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  45. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  46. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-vJvV1Sx0.js} +1 -1
  47. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  48. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  49. package/dist/web/public/assets/{index-DVIt5Gio.css → index-Bs-gol9o.css} +1 -1
  50. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  51. package/dist/web/public/assets/{index-DVIt5Gio.css.gz → index-Bs-gol9o.css.gz} +0 -0
  52. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  53. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  54. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  55. package/dist/web/public/assets/{runs-DdERzeac.js → runs-C5FveZ75.js} +1 -1
  56. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  57. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  58. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  59. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  60. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  61. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-CJJ2j5Ks.js} +1 -1
  62. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  63. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  64. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-R2Pv0Abg.js} +1 -1
  65. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  66. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  67. package/dist/web/public/index.html +2 -2
  68. package/dist/web/public/index.html.br +0 -0
  69. package/dist/web/public/index.html.gz +0 -0
  70. package/dist/web/vault.js +68 -0
  71. package/docs/deploy.md +33 -10
  72. package/package.json +1 -1
  73. package/skills/pier-help/SKILL.md +10 -6
  74. package/skills/pier-slack/SKILL.md +52 -71
  75. package/skills/pier-tasks/SKILL.md +48 -145
  76. package/skills/pier-vault/SKILL.md +36 -0
  77. package/dist/channels/slack-tool.js +0 -416
  78. package/dist/tasks/tool.js +0 -416
  79. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  80. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  81. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  82. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  83. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  84. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  85. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  86. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  87. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  88. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  89. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  90. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  91. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  92. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  93. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  94. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  95. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  96. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  97. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
package/dist/socket.js ADDED
@@ -0,0 +1,95 @@
1
+ // The local socket the `pier` CLI reaches the running instance through: one
2
+ // route per verb the CLI has, the socket's permission bits as the whole auth.
3
+ // Not a workbench route — that server sits behind a proxy at a public
4
+ // hostname — so plaintext crosses this socket into a local process of Pier's
5
+ // user and nowhere else.
6
+ import { chmodSync, rmSync } from "node:fs";
7
+ import { createServer } from "node:http";
8
+ import { logger } from "./log.js";
9
+ import { PIER_SOCK } from "./paths.js";
10
+ import { isVaultName, UnknownSecret, VaultLocked } from "./vault.js";
11
+ const log = logger("socket");
12
+ /** Generous for a task draft; a `--prompt -` past it is told so, not hung up on. */
13
+ const MAX_BODY = 64 * 1024;
14
+ const ROUTES = {
15
+ async "/resolve"({ vault, fileUrl }, { names }, sessionId, answer) {
16
+ if (!Array.isArray(names) || !names.length || !names.every((n) => typeof n === "string" && isVaultName(n))) {
17
+ return answer(400, { error: "names must be a non-empty list of vault names" });
18
+ }
19
+ try {
20
+ answer(200, { values: vault.resolve(names, `session ${sessionId}`) });
21
+ }
22
+ catch (err) {
23
+ if (err instanceof UnknownSecret)
24
+ return answer(404, { error: err.message, file: fileUrl(err.secret) });
25
+ if (err instanceof VaultLocked)
26
+ return answer(423, { error: err.message });
27
+ log.error(`resolve ${names.join(",")} failed`, err);
28
+ answer(500, { error: String(err) });
29
+ }
30
+ },
31
+ // 422, not 400: the request was well-formed; what the operation refused is
32
+ // the caller's to read.
33
+ async "/task"({ task }, { params }, sessionId, answer) {
34
+ try {
35
+ answer(200, { result: await task(params, sessionId) });
36
+ }
37
+ catch (err) {
38
+ answer(422, { error: err instanceof Error ? err.message : String(err) });
39
+ }
40
+ },
41
+ };
42
+ export function servePier(hosts, path = PIER_SOCK) {
43
+ const server = createServer((req, res) => void handle(hosts, req, res).catch((err) => {
44
+ log.error(`${req.method ?? "?"} ${req.url ?? "?"} failed`, err);
45
+ if (!res.headersSent)
46
+ res.writeHead(500).end(JSON.stringify({ error: String(err) }));
47
+ }));
48
+ // A crash leaves the old socket file behind, and listen() on it is EADDRINUSE.
49
+ rmSync(path, { force: true });
50
+ // bind() runs inside listen(): the umask covers the instant the file exists
51
+ // with default bits; the chmod sets the exact mode once it is bound. A bind
52
+ // that failed reports through the error event and left no file to chmod.
53
+ const umask = process.umask(0o077);
54
+ try {
55
+ server.listen(path, () => log.info(`cli socket at ${path}`));
56
+ }
57
+ finally {
58
+ process.umask(umask);
59
+ }
60
+ if (server.listening)
61
+ chmodSync(path, 0o600);
62
+ server.on("error", (err) => log.error(`cli socket ${path} failed`, err));
63
+ process.once("exit", () => rmSync(path, { force: true }));
64
+ return server;
65
+ }
66
+ async function handle(hosts, req, res) {
67
+ const answer = (status, body) => {
68
+ res.writeHead(status, { "content-type": "application/json" });
69
+ res.end(JSON.stringify(body));
70
+ };
71
+ const route = req.method === "POST" ? ROUTES[req.url ?? ""] : undefined;
72
+ if (!route)
73
+ return answer(404, { error: `POST ${Object.keys(ROUTES).join(" | ")} only` });
74
+ let raw = "";
75
+ for await (const chunk of req) {
76
+ raw += chunk.toString();
77
+ // Answered before the body is drained: leaving the loop destroys the
78
+ // request stream, and the response already queued still reaches the client.
79
+ if (raw.length > MAX_BODY)
80
+ return answer(413, { error: `body exceeds ${String(MAX_BODY / 1024)} KiB` });
81
+ }
82
+ let body;
83
+ try {
84
+ body = JSON.parse(raw);
85
+ }
86
+ catch {
87
+ return answer(400, { error: "body must be a JSON object" });
88
+ }
89
+ const { sessionId, ...fields } = typeof body === "object" && body !== null ? body : {};
90
+ if (typeof sessionId !== "string" || !sessionId)
91
+ return answer(400, { error: "PIER_SESSION_ID is required" });
92
+ if (!(await hosts.knows(sessionId)))
93
+ return answer(403, { error: `${sessionId} is not a session of this Pier` });
94
+ await route(hosts, fields, sessionId, answer);
95
+ }
@@ -10,19 +10,18 @@ const MAX_ACTIVE_AGENTS = 6;
10
10
  const log = logger("tasks");
11
11
  /** Every session gets the chat-surface contract, task runs included, so the
12
12
  * delegation prompt says which of it does not apply. Skipped on resume. */
13
- const preamble = (run) => {
13
+ const preamble = (run, supervised) => {
14
14
  // A cron/watch task with a session callback is read by an agent too.
15
15
  const audience = run.invokedBySessionId
16
16
  ? "read by the agent that delegated this run"
17
17
  : run.callbackSessionId
18
18
  ? "read by the agent session it is delivered to"
19
19
  : "read by the operator";
20
- const contact = run.invokedBySessionId
21
- ? ' Mid-run, the task tool\'s contact operation reaches that agent: reason "progress" is fire-and-forget, "decision" waits for a reply — state what you await and end your turn.'
22
- : "";
23
20
  return `[Pier task run ${run.id} — "${run.context.definition.name}"] ` +
24
21
  `Your final reply is recorded verbatim as the run result, ${audience}; ` +
25
- `next-step buttons and file:// attachments do not render there.${contact}\n\n`;
22
+ `next-step buttons and file:// attachments do not render there. A question only that reader can answer is your result: state it and end your turn; the answer resumes this session.` +
23
+ (supervised ? " You cannot delegate from here — `pier task` is refused; if the work needs another agent, say so in your result and your supervisor will run it." : "") +
24
+ "\n\n";
26
25
  };
27
26
  export class AgentTaskRunner {
28
27
  factory;
@@ -62,7 +61,7 @@ export class AgentTaskRunner {
62
61
  const input = run.input === undefined || run.input === null
63
62
  ? ""
64
63
  : `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
65
- const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
64
+ const prompt = run.context.resumePrompt ?? `${preamble(run, this.store.supervised(run))}${action.prompt}${input}`;
66
65
  run.context.sessionId = session.id;
67
66
  run.context.model = session.model;
68
67
  // The level the session settled on: an unspecified effort inherits the caller's.
@@ -2,7 +2,7 @@
2
2
  // belongs to outbox.ts; this file owns the run vocabulary and the batching.
3
3
  import { Outbox } from "./outbox.js";
4
4
  /** The run id and the session that did the work: a relayer's next move is a
5
- * deep link to it, and without this that costs a second tool call. */
5
+ * deep link to it, and without this that costs a second call. */
6
6
  export const runRef = (run) => `Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`;
7
7
  /** What the card in the recipient's transcript says the input came from. */
8
8
  export const runSource = (run) => ({
@@ -21,7 +21,7 @@ export function runResultText(run) {
21
21
  if (run.result?.type === "watch")
22
22
  result = "Watch condition did not match";
23
23
  if (result.length > 8000)
24
- result = `${result.slice(0, 8000)}\n[truncated — task tool recover run_id ${run.id} with a reason returns the full text]`;
24
+ result = `${result.slice(0, 8000)}\n[truncated — pier task recover --run ${run.id} --reason … returns the full text]`;
25
25
  return result;
26
26
  }
27
27
  export class TaskCallbacks {
@@ -0,0 +1,215 @@
1
+ // `pier task <command> …`: argv → the params object `/task` takes. Argv shape
2
+ // is the only thing checked here; the server validates the operation, and its
3
+ // answer comes back verbatim.
4
+ import { readFileSync } from "node:fs";
5
+ import { parseArgs } from "node:util";
6
+ const OPTIONS = {
7
+ prompt: { type: "string" }, run: { type: "string" }, after: { type: "boolean" },
8
+ "task-id": { type: "string" }, session: { type: "string" },
9
+ model: { type: "string" }, thinking: { type: "string" }, cwd: { type: "string" }, name: { type: "string" },
10
+ timeout: { type: "string" }, callback: { type: "string" }, "callback-session": { type: "string" }, join: { type: "string" },
11
+ bash: { type: "string" }, cron: { type: "string" }, tz: { type: "string" },
12
+ watch: { type: "string" }, every: { type: "string" }, repeat: { type: "boolean" },
13
+ group: { type: "string" }, reason: { type: "string" },
14
+ help: { type: "boolean", short: "h" },
15
+ };
16
+ /** The usage line is the contract (docs/design/09-tasks-cli.md); the flags a
17
+ * command accepts are read off it, so the two cannot drift. */
18
+ const COMMANDS = {
19
+ run: {
20
+ usage: "run [--prompt <text|->] [--run <id> [--after]] [--task-id <id>] [--session <id>]\n" +
21
+ " [--model <name|?>] [--thinking <level>] [--cwd <dir>] [--name <text>] [--timeout <seconds>]\n" +
22
+ " [--callback origin|none|steer] [--callback-session <id>] [--join all|first] [--member <flags…>]…",
23
+ help: "a prompt on a run: a new one (--prompt | --task-id | --session … --prompt), a batch (--member), or an existing one (--run)",
24
+ },
25
+ save: {
26
+ usage: "save [--task-id <id>] --name <text> (--prompt <text|-> | --bash <script>)\n" +
27
+ " [--cron \"<expr>\" --tz <zone> | --watch <script> --every <seconds> [--repeat]]\n" +
28
+ " [--cwd <dir>] [--timeout <seconds>] [--model <name>] [--thinking <level>] [--callback-session <id>]",
29
+ help: "file a definition the operator sees, or update one by --task-id; no trigger means manual",
30
+ },
31
+ list: { usage: "list", help: "stored definitions, as JSON" },
32
+ cancel: { usage: "cancel (--run <id> | --group <id>)", help: "a run or a group, descendants included" },
33
+ recover: { usage: "recover (--run <id> | --group <id>) --reason <text>", help: "a finished result after its callback settled; never a progress check" },
34
+ };
35
+ const USAGE = [
36
+ "usage: pier task <command> … — subagents and scheduled tasks (skills/pier-tasks)",
37
+ ...Object.values(COMMANDS).map(({ usage, help }) => ` ${usage}\n ${help}`),
38
+ "`--prompt -` reads stdin. The receipt is one line of JSON, exit 0; a refusal is one `task:` line, exit 1.",
39
+ ].join("\n");
40
+ /** Flags that belong to the batch, never to one member. */
41
+ const GROUP_FLAGS = ["join", "callback", "callback-session"];
42
+ const processIo = {
43
+ stdout: (line) => process.stdout.write(`${line}\n`),
44
+ stderr: (line) => process.stderr.write(`${line}\n`),
45
+ stdin: () => readFileSync(0, "utf8"),
46
+ };
47
+ class ArgvError extends Error {
48
+ }
49
+ const refuse = (message) => {
50
+ throw new ArgvError(message);
51
+ };
52
+ const compact = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
53
+ const flagsOf = (values) => Object.keys(values).filter((flag) => flag !== "help");
54
+ const seconds = (flag, raw) => {
55
+ if (raw === undefined)
56
+ return undefined;
57
+ const n = Number(raw);
58
+ return Number.isInteger(n) ? n : refuse(`--${flag} must be a whole number of seconds`);
59
+ };
60
+ /** `launch` as the server takes it: `model` is a menu name it resolves. */
61
+ const launchOf = (v) => v.model === undefined && v.thinking === undefined ? undefined : compact({ model: v.model, thinking: v.thinking });
62
+ /** Argv is split at each bare `--member`; a value equal to it is unreachable,
63
+ * since parseArgs (strict) refuses option-like values anyway. */
64
+ function segments(argv) {
65
+ const out = [[]];
66
+ for (const arg of argv) {
67
+ if (arg === "--member")
68
+ out.push([]);
69
+ else
70
+ out.at(-1).push(arg);
71
+ }
72
+ return out;
73
+ }
74
+ export async function runTaskCli(argv, post, io = processIo) {
75
+ const [name, ...rest] = argv;
76
+ if (!name || name === "--help" || name === "-h") {
77
+ io.stdout(USAGE);
78
+ return name ? 0 : 2;
79
+ }
80
+ const cmd = COMMANDS[name];
81
+ const usage = (message) => {
82
+ io.stderr(`task: ${message}\n${cmd ? `pier task ${cmd.usage}` : USAGE}`);
83
+ return 2;
84
+ };
85
+ if (!cmd)
86
+ return usage(`unknown command "${name}"`);
87
+ const allowed = new Set((cmd.usage.match(/--[a-z-]+/g) ?? []).map((flag) => flag.slice(2)));
88
+ let params;
89
+ try {
90
+ const parsed = (name === "run" ? segments(rest) : [rest]).map((args) => {
91
+ const values = parseArgs({ args, options: OPTIONS, allowPositionals: false, strict: true }).values;
92
+ const stray = flagsOf(values).find((flag) => !allowed.has(flag));
93
+ return stray ? refuse(`--${stray} is not an option of ${name}`) : values;
94
+ });
95
+ if (parsed.some((values) => values.help)) {
96
+ io.stdout(`pier task ${cmd.usage}\n ${cmd.help}`);
97
+ return 0;
98
+ }
99
+ params = build(name, parsed, io);
100
+ }
101
+ catch (err) {
102
+ return usage(err instanceof Error ? err.message : String(err));
103
+ }
104
+ const { status, body } = await post(params);
105
+ if (status === 200) {
106
+ // Compact: the reader is a model, and the ids are what it keeps.
107
+ io.stdout(JSON.stringify(body.result));
108
+ return 0;
109
+ }
110
+ io.stderr(`task: ${body.error ?? `socket answered ${String(status)}`}`);
111
+ return 1;
112
+ }
113
+ /** Argv → the exact params object, or an ArgvError before the socket is touched. */
114
+ function build(name, parsed, io) {
115
+ let stdinReads = 0;
116
+ const text = (raw) => {
117
+ if (raw !== "-")
118
+ return raw === undefined ? undefined : String(raw);
119
+ if (stdinReads++)
120
+ refuse("only one --prompt may read stdin (-)");
121
+ // An agent's shell has no TTY: a `-` with nothing piped is an empty prompt, named here rather than by the server.
122
+ return io.stdin() || refuse("--prompt - read nothing from stdin");
123
+ };
124
+ if (parsed.some((v) => v.model === "?"))
125
+ return { operation: "run", launch: { model: "?" } };
126
+ const [values, ...members] = parsed;
127
+ if (name === "list")
128
+ return { operation: "list" };
129
+ if (name === "cancel" || name === "recover") {
130
+ if ((values.run === undefined) === (values.group === undefined))
131
+ refuse(`${name} takes exactly one of --run or --group`);
132
+ if (name === "recover" && values.reason === undefined)
133
+ refuse("recover needs --reason");
134
+ return compact({ operation: name, run_id: values.run, group_id: values.group, reason: values.reason });
135
+ }
136
+ if (name === "save")
137
+ return saveParams(values, text);
138
+ /** One new run, in the three shapes `tasks[]` accepts. A saved
139
+ * definition runs as is: batch defaults pass it by, its own flags are refused. */
140
+ const entry = (v, own = v) => {
141
+ const launch = launchOf(v);
142
+ const timeoutSeconds = seconds("timeout", v.timeout);
143
+ if (v["task-id"] !== undefined) {
144
+ const extra = flagsOf(own).find((flag) => ["prompt", "session", "cwd", "model", "thinking", "name", "timeout"].includes(flag));
145
+ if (extra)
146
+ refuse(`--${extra} does not apply to a saved definition (--task-id)`);
147
+ return { task_id: v["task-id"] };
148
+ }
149
+ const prompt = text(v.prompt) ?? refuse("a new run needs --prompt or --task-id");
150
+ if (v.session === undefined)
151
+ return compact({ prompt, cwd: v.cwd, launch, name: v.name, timeoutSeconds });
152
+ if (v.cwd !== undefined)
153
+ refuse("--cwd applies to a fresh session, not --session");
154
+ return compact({ name: v.name, timeoutSeconds, action: compact({ type: "agent", session: { mode: "reuse", sessionId: v.session }, prompt, launch }) });
155
+ };
156
+ const delivery = compact({ callback: values.callback, callback_session_id: values["callback-session"] });
157
+ if (values.run !== undefined) {
158
+ if (members.length)
159
+ refuse("--run addresses one existing run; --member starts new ones");
160
+ const extra = flagsOf(values).find((flag) => !["run", "after", "prompt", "callback", "callback-session"].includes(flag));
161
+ if (extra)
162
+ refuse(`--${extra} does not apply to an existing run (--run)`);
163
+ const message = text(values.prompt) ?? refuse("--run needs --prompt");
164
+ return compact({ operation: "message", run_id: values.run, message, after: values.after || undefined, ...delivery });
165
+ }
166
+ if (values.after)
167
+ refuse("--after applies to --run only");
168
+ if (members.length) {
169
+ if (members.length < 2)
170
+ refuse("a batch needs at least two --member");
171
+ if (values["task-id"] !== undefined)
172
+ refuse("--task-id names one member's definition; put it after a --member");
173
+ const grouped = members.find((m) => flagsOf(m).some((flag) => GROUP_FLAGS.includes(flag)));
174
+ if (grouped)
175
+ refuse(`--${flagsOf(grouped).find((flag) => GROUP_FLAGS.includes(flag))} belongs before the first --member`);
176
+ const defaults = Object.fromEntries(Object.entries(values).filter(([flag]) => !GROUP_FLAGS.includes(flag)));
177
+ return compact({ operation: "run", tasks: members.map((m) => entry({ ...defaults, ...m }, m)), join: values.join, ...delivery });
178
+ }
179
+ if (values.join !== undefined)
180
+ refuse("--join applies to a batch (--member)");
181
+ const single = entry(values);
182
+ if (single.task_id !== undefined || single.action === undefined)
183
+ return { operation: "run", ...single, ...delivery };
184
+ return { operation: "run", task: single, ...delivery };
185
+ }
186
+ function saveParams(v, text) {
187
+ if ((v.prompt === undefined) === (v.bash === undefined))
188
+ refuse("save takes exactly one of --prompt or --bash");
189
+ const cron = v.cron !== undefined || v.tz !== undefined;
190
+ const watch = v.watch !== undefined || v.every !== undefined || v.repeat !== undefined;
191
+ if (cron && watch)
192
+ refuse("a task has one trigger: --cron/--tz or --watch/--every/--repeat");
193
+ if (cron && (v.cron === undefined || v.tz === undefined))
194
+ refuse("--cron and --tz go together");
195
+ if (watch && (v.watch === undefined || v.every === undefined))
196
+ refuse("--watch and --every go together");
197
+ const launch = launchOf(v);
198
+ if (launch && v.bash !== undefined)
199
+ refuse("--model/--thinking apply to a prompt, not --bash");
200
+ const trigger = cron
201
+ ? { type: "cron", expression: v.cron, timezone: v.tz }
202
+ : watch
203
+ ? compact({ type: "watch", script: v.watch, cwd: v.cwd, intervalSeconds: seconds("every", v.every), mode: v.repeat ? "repeat" : "once" })
204
+ : { type: "manual" };
205
+ const task = compact({
206
+ name: v.name,
207
+ timeoutSeconds: seconds("timeout", v.timeout),
208
+ trigger,
209
+ callback: v["callback-session"] === undefined ? undefined : { type: "session", sessionId: v["callback-session"] },
210
+ ...(v.bash === undefined
211
+ ? { prompt: text(v.prompt), cwd: v.cwd, launch }
212
+ : { action: compact({ type: "bash", script: v.bash, cwd: v.cwd }) }),
213
+ });
214
+ return compact({ operation: "save", task_id: v["task-id"], task });
215
+ }
@@ -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, the task tool, Pier's own owned task —
3
+ // definition can be created — HTTP, `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";
@@ -24,7 +24,7 @@ export const idSymbol = (byte) => ID_ALPHABET.charAt(byte & 31);
24
24
  * not an error — `saveRun`'s ON CONFLICT DO UPDATE would quietly overwrite. */
25
25
  export const newId = () => Array.from(randomBytes(16), idSymbol).join("");
26
26
  /** A definition Pier's own code created is reconciled by that code and edited
27
- * by nobody: `creator` is `"http"` (Console) or `session:<id>` (task tool);
27
+ * by nobody: `creator` is `"http"` (Console) or `session:<id>` (`pier task`);
28
28
  * anything else is an instance-layer owner, which names itself in `by`.
29
29
  * Otherwise a public surface could repoint the tools task while its switch
30
30
  * went on claiming Pier keeps the tools current. */
@@ -258,7 +258,7 @@ export class TaskDefinitions {
258
258
  if (trigger.type === "watch")
259
259
  await this.assertDirectory(trigger.cwd);
260
260
  const actionRaw = record(value.action);
261
- // Self-documenting: tool callers (models) recover from this in one retry.
261
+ // Self-documenting: a model recovers from this in one retry.
262
262
  if (!actionRaw) {
263
263
  throw new Error('action required, e.g. {"type":"agent","session":{"mode":"fresh","cwd":"/abs/path"},"prompt":"..."}');
264
264
  }
@@ -92,9 +92,7 @@ export class TaskExecution {
92
92
  log.debug(`${settled} (watch did not match)`);
93
93
  else
94
94
  log.info(settled);
95
- // A run that ends awaiting a supervisor decision suppresses its
96
- // completion callback: the pending question is the notification.
97
- if (run.callbackSessionId && !this.host.openDecisionId(run.id))
95
+ if (run.callbackSessionId)
98
96
  run.callbackState = "pending";
99
97
  this.controllers.delete(run.id);
100
98
  try {
@@ -35,12 +35,12 @@ export class TaskGroups {
35
35
  }
36
36
  /** Enqueues every member or none: a partially started group is worse than
37
37
  * a rejected one. */
38
- runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
38
+ runAll(definitions, join, callerSessionId, callbackSessionId, callbackMode) {
39
39
  if (definitions.length < 2)
40
40
  throw new Error("a task group needs at least 2 members");
41
41
  const { group, runs } = this.store.transact(() => {
42
42
  const group = this.create(join, callerSessionId, callbackSessionId, callbackMode);
43
- const runs = definitions.map((definition) => this.host.prepareMember(definition.id, group.id, callerSessionId, parentRunId));
43
+ const runs = definitions.map((definition) => this.host.prepareMember(definition.id, group.id, callerSessionId));
44
44
  group.memberRunIds = runs.map((run) => run.id);
45
45
  this.store.saveGroup(group);
46
46
  return { group, runs };
@@ -150,9 +150,6 @@ export class TaskGroups {
150
150
  `- "${run.context.definition.name}" \u2014 state: ${run.state}`,
151
151
  ` ${runRef(run)}`,
152
152
  ];
153
- const decision = this.host.openDecisionId(run.id);
154
- if (decision)
155
- head.push(` Needs a decision: reply to message ${decision}`);
156
153
  if (group.join === "first" && run.id !== group.winnerRunId) {
157
154
  head.push(" Cancelled after the winning run; resume its session to recover partial work.");
158
155
  return head.join("\n");