@timqi/pier 0.1.2 → 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 (113) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +63 -59
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/listing.js +38 -15
  5. package/dist/agent/packages.js +347 -0
  6. package/dist/agent/pi.js +33 -59
  7. package/dist/channels/config.js +30 -20
  8. package/dist/channels/routes.js +0 -3
  9. package/dist/channels/slack-api.js +19 -10
  10. package/dist/channels/slack-cli.js +503 -0
  11. package/dist/channels/slack-directory.js +2 -2
  12. package/dist/channels/slack-outbound.js +1 -1
  13. package/dist/channels/slack-thread.js +41 -0
  14. package/dist/channels/slack-transcript.js +107 -0
  15. package/dist/channels/slack.js +9 -17
  16. package/dist/channels/types.js +0 -1
  17. package/dist/cli.js +158 -7
  18. package/dist/core/identity.js +20 -11
  19. package/dist/core/reply.js +16 -15
  20. package/dist/core/router.js +4 -1
  21. package/dist/core/types.js +7 -0
  22. package/dist/db.js +28 -0
  23. package/dist/extensions/index.js +1 -20
  24. package/dist/main.js +34 -42
  25. package/dist/paths.js +3 -0
  26. package/dist/secrets.js +2 -1
  27. package/dist/settings.js +5 -0
  28. package/dist/socket.js +95 -0
  29. package/dist/tasks/agent.js +5 -6
  30. package/dist/tasks/callbacks.js +2 -2
  31. package/dist/tasks/cli.js +215 -0
  32. package/dist/tasks/definitions.js +3 -3
  33. package/dist/tasks/execution.js +1 -3
  34. package/dist/tasks/groups.js +2 -5
  35. package/dist/tasks/messages.js +18 -158
  36. package/dist/tasks/operations.js +323 -0
  37. package/dist/tasks/routes.js +1 -11
  38. package/dist/tasks/runs.js +5 -20
  39. package/dist/tasks/service.js +18 -43
  40. package/dist/tasks/store.js +15 -15
  41. package/dist/tools.js +63 -85
  42. package/dist/vault.js +107 -0
  43. package/dist/web/auth.js +2 -1
  44. package/dist/web/config.js +25 -11
  45. package/dist/web/explorer.js +5 -3
  46. package/dist/web/instance.js +6 -18
  47. package/dist/web/packages.js +84 -0
  48. package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-NLS8W9yl.js} +2 -2
  49. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  50. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  51. package/dist/web/public/assets/boards-DleegoiC.js +1 -0
  52. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  53. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  54. package/dist/web/public/assets/explorer-vJvV1Sx0.js +4 -0
  55. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  56. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  57. package/dist/web/public/assets/index-Bs-gol9o.css +2 -0
  58. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  59. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  60. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  61. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  62. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  63. package/dist/web/public/assets/runs-C5FveZ75.js +1 -0
  64. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  65. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  66. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  67. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  68. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  69. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +3 -0
  70. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  71. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  72. package/dist/web/public/assets/tasks-R2Pv0Abg.js +4 -0
  73. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  74. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  75. package/dist/web/public/index.html +2 -2
  76. package/dist/web/public/index.html.br +0 -0
  77. package/dist/web/public/index.html.gz +0 -0
  78. package/dist/web/server.js +3 -1
  79. package/dist/web/vault.js +68 -0
  80. package/docs/deploy.md +42 -8
  81. package/package.json +1 -1
  82. package/skills/pier-help/SKILL.md +10 -6
  83. package/skills/pier-slack/SKILL.md +52 -71
  84. package/skills/pier-tasks/SKILL.md +48 -145
  85. package/skills/pier-vault/SKILL.md +36 -0
  86. package/dist/channels/slack-tool.js +0 -416
  87. package/dist/tasks/tool.js +0 -416
  88. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  89. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  90. package/dist/web/public/assets/boards-CneyR23E.js +0 -1
  91. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  92. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  93. package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
  94. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  95. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  96. package/dist/web/public/assets/index-DbFu15NN.js +0 -85
  97. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  98. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  99. package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
  100. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  101. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  102. package/dist/web/public/assets/runs-Cv0A-e08.js +0 -1
  103. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  104. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  105. package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
  106. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  107. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  108. package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
  109. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  110. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  111. package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
  112. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  113. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
package/dist/main.js CHANGED
@@ -9,6 +9,7 @@ import { ConfigSync } from "./config-sync.js";
9
9
  import { configSyncTask } from "./config-sync-task.js";
10
10
  import { CredentialStore } from "./agent/credentials.js";
11
11
  import { IndexedListing } from "./agent/listing.js";
12
+ import { PiPackageStore } from "./agent/packages.js";
12
13
  import { PiAgentFactory } from "./agent/pi.js";
13
14
  import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
14
15
  import { ChannelStore } from "./channels/config.js";
@@ -16,15 +17,11 @@ import { createControl } from "./channels/control.js";
16
17
  import { ConversationStore, resolveConversation } from "./channels/conversations.js";
17
18
  import { registerChannelRoutes } from "./channels/routes.js";
18
19
  import { ChannelRuntime } from "./channels/runtime.js";
19
- import { SlackApi } from "./channels/slack-api.js";
20
- import { SlackDirectory } from "./channels/slack-directory.js";
21
- import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
22
- import { parseConversation as parseSlackConversation } from "./channels/slack.js";
23
20
  import { EventHub } from "./core/hub.js";
24
21
  import { splitSpeaker } from "./core/identity.js";
25
22
  import { pierDb } from "./db.js";
26
23
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
27
- import { bundledInfo } from "./extensions/index.js";
24
+ import { BUNDLED } from "./extensions/index.js";
28
25
  import { surfacePrompt } from "./core/reply.js";
29
26
  import { Router } from "./core/router.js";
30
27
  import { acquireInstanceLock } from "./lock.js";
@@ -32,19 +29,21 @@ import { logger } from "./log.js";
32
29
  import { registerTaskRoutes } from "./tasks/routes.js";
33
30
  import { TaskService } from "./tasks/service.js";
34
31
  import { TaskStore } from "./tasks/store.js";
35
- import { taskToolSpec } from "./tasks/tool.js";
36
32
  import { PIER_HOME, pierPath, resolveAgentDir } from "./paths.js";
37
- import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath } from "./tools.js";
33
+ import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath, writePierShim } from "./tools.js";
38
34
  import { toolsTask } from "./tools-task.js";
39
35
  import { Secrets } from "./secrets.js";
40
36
  import { startUpdate, unitPath, updaterProblem } from "./service.js";
41
37
  import { SettingsStore } from "./settings.js";
42
- import { startAutoUpdate, UpdateCheck } from "./update.js";
38
+ import { currentVersion, startAutoUpdate, UpdateCheck } from "./update.js";
39
+ import { Vault } from "./vault.js";
40
+ import { servePier } from "./socket.js";
43
41
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
44
42
  import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
45
43
  import { PushStore, registerPushRoutes } from "./web/push.js";
46
44
  import { SessionStateStore } from "./web/session-state.js";
47
45
  import { createServer } from "./web/server.js";
46
+ import { registerVaultRoutes } from "./web/vault.js";
48
47
  const log = logger("pier");
49
48
  // Before any SDK call resolves a path, so everything Pi derives from its agent
50
49
  // dir lands under PIER_HOME. PIER_AGENT_DIR marks the value as ours: a second
@@ -53,6 +52,7 @@ const log = logger("pier");
53
52
  process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
54
53
  process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
55
54
  prependPath(process.env);
55
+ writePierShim();
56
56
  // Before the database and before task recovery, which would mark the running
57
57
  // instance's queued and active runs interrupted; the port is discovered far too
58
58
  // late, and a second Pier on another port would never notice at all.
@@ -76,44 +76,26 @@ const settings = new SettingsStore(db);
76
76
  // Unlocked below: vt mode waits on a human approval, and nothing that needs a
77
77
  // token may run before the key arrives.
78
78
  const secrets = new Secrets();
79
+ const vault = new Vault(secrets, db);
79
80
  let tasks;
80
81
  const conversations = new ConversationStore(db);
81
82
  let resolveIm;
82
83
  let channelStore;
83
- // Shared by the adapter and the tool: one display-name lookup per process.
84
- const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
85
84
  let readyForConfigReload = false;
86
85
  const piConfig = new PiConfigStore();
86
+ // Before anything reads settings.json: a first boot gets Pier's seed file.
87
+ await piConfig.seedSettings();
87
88
  const configSync = new ConfigSync({
88
89
  db, settings, config: piConfig, normalizeAgent: normalizeAgentSnapshot,
89
90
  reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
90
91
  });
91
- const factory = new PiAgentFactory([
92
- taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
93
- slackToolSpec((params, callerSessionId) => handleSlackTool({
94
- store: channelStore,
95
- directory: slackDirectory,
96
- // Per call: the Console can change the token underneath us.
97
- client: () => {
98
- const config = channelStore.get("slack");
99
- return config.token ? new SlackApi(config.token, config.appToken) : null;
100
- },
101
- // Per call: the mapping is durable, the session is not.
102
- here: (sessionId) => {
103
- const key = router.conversationOf(sessionId);
104
- if (key?.channelId !== "slack")
105
- return null;
106
- const { channel, threadTs } = parseSlackConversation(key.conversationId);
107
- return channel && threadTs ? { channel, threadTs } : null;
108
- },
109
- log: (m) => logger("slack.tool").warn(m),
110
- }, params, callerSessionId), () => slackToolAvailable(channelStore)),
111
- ],
92
+ const skillsDir = fileURLToPath(new URL("../skills", import.meta.url));
93
+ const factory = new PiAgentFactory(
112
94
  // Getters, read per session open: a Console change reaches the next session
113
95
  // without a restart.
114
96
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
115
97
  // Documents Pier's own tools, so it loads only inside a Pier session.
116
- [fileURLToPath(new URL("../skills", import.meta.url))], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get().extensions, () => settings.get().titleModel,
98
+ [skillsDir], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get(), () => settings.get().titleModel,
117
99
  // Transcripts carry the speaker header core wrote for the model.
118
100
  new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
119
101
  const hub = new EventHub();
@@ -137,7 +119,10 @@ const toolsUpdate = toolsTask(tasks);
137
119
  const reconciled = await toolsUpdate.reconcile();
138
120
  if ("problem" in reconciled)
139
121
  log.error(`tools cannot be managed: ${reconciled.problem}`);
140
- channelStore = new ChannelStore(db, secrets);
122
+ const packages = new PiPackageStore(piConfig, { version: currentVersion(), settings }, [skillsDir]);
123
+ // At boot, not lazily: the answer waits for the next Console open (update.ts).
124
+ packages.watchUpdates();
125
+ channelStore = new ChannelStore(db, vault);
141
126
  const control = createControl({ router, factory, conversations, store: channelStore });
142
127
  const channels = new ChannelRuntime(channelStore, router, control);
143
128
  resolveIm = resolveConversation(conversations, factory, control.launchFor, (message) => logger("channels").warn(message));
@@ -264,6 +249,7 @@ registerConfigSyncRoutes(app, {
264
249
  });
265
250
  registerTaskRoutes(app, tasks, { factory, router });
266
251
  registerChannelRoutes(app, channelStore, channels);
252
+ registerVaultRoutes(app, { vault, doctor: () => secrets.doctor() });
267
253
  registerBoardRoutes(app);
268
254
  const sessionState = new SessionStateStore(db);
269
255
  registerPushRoutes(app, {
@@ -280,23 +266,20 @@ app.route("/", createServer({
280
266
  hub,
281
267
  sessions: sessionState,
282
268
  config: piConfig,
269
+ packages,
283
270
  providers: factory,
284
271
  settings,
285
- // Assembled here so web/ gets names and summaries, not a module that
286
- // imports the Pi SDK; extensions and binaries are one kind of switch.
272
+ // Assembled here: the catalog spawns ubix, which web/ may not.
287
273
  catalog: async () => {
288
- const { extensions, tools, customTools } = settings.get();
289
- return {
290
- entries: [...bundledInfo(extensions), ...await managedTools.status(tools, customTools)],
291
- toolsTaskId: toolsUpdate.id(),
292
- };
274
+ const { tools, customTools } = settings.get();
275
+ return { entries: await managedTools.status(tools, customTools), toolsTaskId: toolsUpdate.id() };
293
276
  },
294
277
  // A switch is validated against what this Pier *can* switch, never against
295
278
  // a catalog whose custom half the request may be rewriting.
296
- names: { extensions: bundledInfo([]).map((entry) => entry.name), tools: MANAGED.map((tool) => tool.name) },
279
+ names: MANAGED.map((tool) => tool.name),
297
280
  onToolsChanged: toolsUpdate.changed,
298
281
  validateCustomTools: (raw) => {
299
- const validated = normalizeCustomTools(raw, bundledInfo([]).map((entry) => entry.name));
282
+ const validated = normalizeCustomTools(raw, BUNDLED.map((ext) => ext.name));
300
283
  return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
301
284
  },
302
285
  secrets,
@@ -311,6 +294,15 @@ app.route("/", createServer({
311
294
  }));
312
295
  const port = Number(process.env.PORT ?? 3141);
313
296
  const hostname = process.env.HOST ?? "127.0.0.1";
297
+ servePier({
298
+ vault,
299
+ // The deep link an agent's "no secret named X" error carries; loopback when
300
+ // no public URL is set, since nothing in the process can discover one.
301
+ fileUrl: (name) => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/#/settings/vault?name=${name}`,
302
+ task: (params, callerSessionId) => tasks.handle(params, callerSessionId),
303
+ // Live in the router, or on disk: the same two places a callback target is looked for.
304
+ knows: async (id) => router.stateOf(id) !== undefined || (await factory.find(id)) !== undefined,
305
+ });
314
306
  // Every command a turn runs inherits this env: `NODE_ENV=production` makes an
315
307
  // agent's `npm install` skip devDependencies, and PORT/HOST would aim its dev
316
308
  // server at Pier's socket.
package/dist/paths.js CHANGED
@@ -12,6 +12,9 @@ export const PIER_DB = pierPath("db", "pier.db");
12
12
  /** At the root of PIER_HOME: the claim is the whole instance directory, not the
13
13
  * database (lock.ts). */
14
14
  export const PIER_LOCK = pierPath("pier.lock");
15
+ /** Where the `pier` CLI reaches the running Pier; the CLI needs the name
16
+ * without opening the database (socket.ts). */
17
+ export const PIER_SOCK = pierPath("pier.sock");
15
18
  /** `PI_CODING_AGENT_DIR` is an operator override — unless it equals
16
19
  * `PIER_AGENT_DIR`, the value Pier itself set: then a Pier spawned from inside
17
20
  * another inherited it, and a leak is not an instruction. */
package/dist/secrets.js CHANGED
@@ -146,7 +146,8 @@ function open(key, sealed, aad) {
146
146
  decipher.setAuthTag(tag);
147
147
  return Buffer.concat([decipher.update(ct), decipher.final()]);
148
148
  }
149
- const vtCli = {
149
+ /** The real vt; vault.ts creates its `approve` records through the same client. */
150
+ export const vtCli = {
150
151
  read: (record) => run("vt", ["read", record]),
151
152
  create: async (plaintext) => {
152
153
  const out = await run("vt", ["create"], plaintext);
package/dist/settings.js CHANGED
@@ -99,6 +99,7 @@ export class SettingsStore {
99
99
  ...(titleModel ? { titleModel } : {}),
100
100
  autoUpdate: this.#value("autoUpdate") === "1",
101
101
  extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
102
+ skillsOff: this.#json("skillsOff", normalizeNames, "a list of names") ?? [],
102
103
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
103
104
  // `"drop"`: a row the bundled catalog has since taken is redundant, not malformed.
104
105
  customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, toml}") ?? [],
@@ -144,6 +145,10 @@ export class SettingsStore {
144
145
  this.#set("extensions", JSON.stringify(names));
145
146
  return this.get();
146
147
  }
148
+ setSkillsOff(names) {
149
+ this.#set("skillsOff", JSON.stringify(names));
150
+ return this.get();
151
+ }
147
152
  setTools(names) {
148
153
  this.#set("tools", JSON.stringify(names));
149
154
  return this.get();
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");