@timqi/pier 0.1.3 → 0.1.5

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 (136) hide show
  1. package/README.md +12 -7
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +13 -5
  4. package/dist/agent/listing.js +10 -2
  5. package/dist/agent/packages.js +4 -20
  6. package/dist/agent/pi.js +18 -86
  7. package/dist/channels/attach.js +2 -2
  8. package/dist/channels/commands.js +10 -8
  9. package/dist/channels/config.js +32 -24
  10. package/dist/channels/control.js +60 -14
  11. package/dist/channels/conversations.js +60 -13
  12. package/dist/channels/handoff.js +90 -0
  13. package/dist/channels/lark-api.js +7 -0
  14. package/dist/channels/lark-outbound.js +19 -0
  15. package/dist/channels/lark-panel.js +41 -26
  16. package/dist/channels/lark-render.js +2 -1
  17. package/dist/channels/lark.js +35 -26
  18. package/dist/channels/lines.js +1 -1
  19. package/dist/channels/panel.js +324 -115
  20. package/dist/channels/receipts.js +25 -12
  21. package/dist/channels/routes.js +21 -7
  22. package/dist/channels/runtime.js +21 -7
  23. package/dist/channels/slack-api.js +19 -10
  24. package/dist/channels/slack-cli.js +503 -0
  25. package/dist/channels/slack-directory.js +2 -2
  26. package/dist/channels/slack-outbound.js +11 -1
  27. package/dist/channels/slack-panel.js +81 -40
  28. package/dist/channels/slack-render.js +3 -1
  29. package/dist/channels/slack-thread.js +41 -0
  30. package/dist/channels/slack-transcript.js +107 -0
  31. package/dist/channels/slack.js +38 -31
  32. package/dist/channels/types.js +1 -3
  33. package/dist/cli.js +166 -7
  34. package/dist/core/identity.js +61 -17
  35. package/dist/core/reply.js +19 -15
  36. package/dist/core/router.js +23 -3
  37. package/dist/db.js +33 -0
  38. package/dist/main.js +61 -41
  39. package/dist/paths.js +3 -0
  40. package/dist/secrets.js +2 -1
  41. package/dist/settings.js +3 -12
  42. package/dist/socket.js +99 -0
  43. package/dist/tasks/agent.js +5 -6
  44. package/dist/tasks/callbacks.js +2 -2
  45. package/dist/tasks/cli.js +225 -0
  46. package/dist/tasks/definitions.js +3 -3
  47. package/dist/tasks/execution.js +1 -3
  48. package/dist/tasks/groups.js +2 -5
  49. package/dist/tasks/messages.js +18 -158
  50. package/dist/tasks/operations.js +335 -0
  51. package/dist/tasks/routes.js +1 -11
  52. package/dist/tasks/runs.js +5 -20
  53. package/dist/tasks/service.js +18 -43
  54. package/dist/tasks/store.js +15 -15
  55. package/dist/tools.js +18 -3
  56. package/dist/vault.js +107 -0
  57. package/dist/web/auth.js +2 -1
  58. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
  59. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  60. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  61. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
  62. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  63. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  64. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
  65. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  67. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  68. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  69. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  70. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  71. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  72. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  73. package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
  74. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  75. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  76. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  77. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  78. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  79. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
  80. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  81. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  82. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
  83. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  84. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  85. package/dist/web/public/index.html +2 -2
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/push.js +4 -10
  89. package/dist/web/vault.js +68 -0
  90. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  91. package/dist/websearch/cli.js +73 -0
  92. package/dist/websearch/run.js +273 -0
  93. package/docs/deploy.md +34 -11
  94. package/package.json +2 -3
  95. package/skills/pier-help/SKILL.md +32 -16
  96. package/skills/pier-slack/SKILL.md +52 -71
  97. package/skills/pier-tasks/SKILL.md +60 -148
  98. package/skills/pier-vault/SKILL.md +36 -0
  99. package/skills/pier-web/SKILL.md +50 -0
  100. package/dist/channels/slack-tool.js +0 -416
  101. package/dist/channels/telegram-api.js +0 -86
  102. package/dist/channels/telegram-panel.js +0 -97
  103. package/dist/channels/telegram-render.js +0 -69
  104. package/dist/channels/telegram.js +0 -421
  105. package/dist/extensions/index.js +0 -10
  106. package/dist/extensions/web/index.js +0 -9
  107. package/dist/extensions/web/tools.js +0 -265
  108. package/dist/tasks/tool.js +0 -416
  109. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  110. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  111. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  112. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  113. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  114. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  115. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  116. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  117. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  118. package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
  119. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  120. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  121. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  122. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  123. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  124. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  125. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  126. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  129. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  130. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  131. /package/dist/{extensions/web → websearch}/content.js +0 -0
  132. /package/dist/{extensions/web → websearch}/http.js +0 -0
  133. /package/dist/{extensions/web → websearch}/json.js +0 -0
  134. /package/dist/{extensions/web → websearch}/language.js +0 -0
  135. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  136. /package/dist/{extensions/web → websearch}/provider.js +0 -0
package/dist/main.js CHANGED
@@ -15,37 +15,36 @@ import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
15
15
  import { ChannelStore } from "./channels/config.js";
16
16
  import { createControl } from "./channels/control.js";
17
17
  import { ConversationStore, resolveConversation } from "./channels/conversations.js";
18
+ import { createHandoff } from "./channels/handoff.js";
18
19
  import { registerChannelRoutes } from "./channels/routes.js";
19
20
  import { ChannelRuntime } from "./channels/runtime.js";
20
- import { SlackApi } from "./channels/slack-api.js";
21
- import { SlackDirectory } from "./channels/slack-directory.js";
22
- import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
23
- import { parseConversation as parseSlackConversation } from "./channels/slack.js";
24
21
  import { EventHub } from "./core/hub.js";
25
22
  import { splitSpeaker } from "./core/identity.js";
26
23
  import { pierDb } from "./db.js";
27
24
  import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
28
- import { BUNDLED } from "./extensions/index.js";
29
25
  import { surfacePrompt } from "./core/reply.js";
30
26
  import { Router } from "./core/router.js";
31
27
  import { acquireInstanceLock } from "./lock.js";
28
+ import { parseWebParams, runWeb } from "./websearch/run.js";
32
29
  import { logger } from "./log.js";
33
30
  import { registerTaskRoutes } from "./tasks/routes.js";
34
31
  import { TaskService } from "./tasks/service.js";
35
32
  import { TaskStore } from "./tasks/store.js";
36
- import { taskToolSpec } from "./tasks/tool.js";
37
33
  import { PIER_HOME, pierPath, resolveAgentDir } from "./paths.js";
38
- import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath } from "./tools.js";
34
+ import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath, writePierShim } from "./tools.js";
39
35
  import { toolsTask } from "./tools-task.js";
40
36
  import { Secrets } from "./secrets.js";
41
37
  import { startUpdate, unitPath, updaterProblem } from "./service.js";
42
38
  import { SettingsStore } from "./settings.js";
43
39
  import { currentVersion, startAutoUpdate, UpdateCheck } from "./update.js";
40
+ import { Vault } from "./vault.js";
41
+ import { servePier } from "./socket.js";
44
42
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
45
43
  import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
46
44
  import { PushStore, registerPushRoutes } from "./web/push.js";
47
45
  import { SessionStateStore } from "./web/session-state.js";
48
46
  import { createServer } from "./web/server.js";
47
+ import { registerVaultRoutes } from "./web/vault.js";
49
48
  const log = logger("pier");
50
49
  // Before any SDK call resolves a path, so everything Pi derives from its agent
51
50
  // dir lands under PIER_HOME. PIER_AGENT_DIR marks the value as ours: a second
@@ -54,6 +53,7 @@ const log = logger("pier");
54
53
  process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
55
54
  process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
56
55
  prependPath(process.env);
56
+ writePierShim();
57
57
  // Before the database and before task recovery, which would mark the running
58
58
  // instance's queued and active runs interrupted; the port is discovered far too
59
59
  // late, and a second Pier on another port would never notice at all.
@@ -77,12 +77,11 @@ const settings = new SettingsStore(db);
77
77
  // Unlocked below: vt mode waits on a human approval, and nothing that needs a
78
78
  // token may run before the key arrives.
79
79
  const secrets = new Secrets();
80
+ const vault = new Vault(secrets, db);
80
81
  let tasks;
81
82
  const conversations = new ConversationStore(db);
82
83
  let resolveIm;
83
84
  let channelStore;
84
- // Shared by the adapter and the tool: one display-name lookup per process.
85
- const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
86
85
  let readyForConfigReload = false;
87
86
  const piConfig = new PiConfigStore();
88
87
  // Before anything reads settings.json: a first boot gets Pier's seed file.
@@ -92,28 +91,7 @@ const configSync = new ConfigSync({
92
91
  reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
93
92
  });
94
93
  const skillsDir = fileURLToPath(new URL("../skills", import.meta.url));
95
- const agentTools = [
96
- taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
97
- slackToolSpec((params, callerSessionId) => handleSlackTool({
98
- store: channelStore,
99
- directory: slackDirectory,
100
- // Per call: the Console can change the token underneath us.
101
- client: () => {
102
- const config = channelStore.get("slack");
103
- return config.token ? new SlackApi(config.token, config.appToken) : null;
104
- },
105
- // Per call: the mapping is durable, the session is not.
106
- here: (sessionId) => {
107
- const key = router.conversationOf(sessionId);
108
- if (key?.channelId !== "slack")
109
- return null;
110
- const { channel, threadTs } = parseSlackConversation(key.conversationId);
111
- return channel && threadTs ? { channel, threadTs } : null;
112
- },
113
- log: (m) => logger("slack.tool").warn(m),
114
- }, params, callerSessionId), () => slackToolAvailable(channelStore)),
115
- ];
116
- const factory = new PiAgentFactory(agentTools,
94
+ const factory = new PiAgentFactory(
117
95
  // Getters, read per session open: a Console change reaches the next session
118
96
  // without a restart.
119
97
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
@@ -129,7 +107,7 @@ const router = new Router(hub, (key) => {
129
107
  return factory.resume(key.conversationId);
130
108
  }
131
109
  return resolveIm(key);
132
- }, (key) => conversations.get(key));
110
+ }, (key) => conversations.get(key), (id) => conversations.keyOf(id));
133
111
  const stopEviction = router.startIdleEviction();
134
112
  tasks = new TaskService(new TaskStore(db), factory, router, hub, {
135
113
  modelMenu: () => settings.get().modelMenu,
@@ -142,13 +120,38 @@ const toolsUpdate = toolsTask(tasks);
142
120
  const reconciled = await toolsUpdate.reconcile();
143
121
  if ("problem" in reconciled)
144
122
  log.error(`tools cannot be managed: ${reconciled.problem}`);
145
- const packages = new PiPackageStore(piConfig, { version: currentVersion(), settings, tools: agentTools }, [skillsDir]);
123
+ const packages = new PiPackageStore(piConfig, { version: currentVersion(), settings }, [skillsDir]);
146
124
  // At boot, not lazily: the answer waits for the next Console open (update.ts).
147
125
  packages.watchUpdates();
148
- channelStore = new ChannelStore(db, secrets);
149
- const control = createControl({ router, factory, conversations, store: channelStore });
150
- const channels = new ChannelRuntime(channelStore, router, control);
151
- resolveIm = resolveConversation(conversations, factory, control.launchFor, (message) => logger("channels").warn(message));
126
+ channelStore = new ChannelStore(db, vault);
127
+ const sessionState = new SessionStateStore(db);
128
+ const control = createControl({
129
+ router, factory, conversations, store: channelStore,
130
+ modelMenu: () => settings.get().modelMenu,
131
+ });
132
+ // The panel pulls through the handoff and the handoff posts through the
133
+ // runtime: the runtime's half is reached lazily so both can be built.
134
+ const handoff = createHandoff({
135
+ store: channelStore,
136
+ runtime: {
137
+ running: () => channels.running(),
138
+ openThread: (platform, chatId, note) => channels.openThread(platform, chatId, note),
139
+ },
140
+ conversations,
141
+ factory,
142
+ router,
143
+ hub,
144
+ publicUrl: () => settings.get().publicUrl,
145
+ taskSessions: () => tasks.taskSessions(),
146
+ workingSet: () => sessionState.flags(),
147
+ log: (m) => logger("channels").info(m),
148
+ });
149
+ const channels = new ChannelRuntime(channelStore, router, control, handoff);
150
+ resolveIm = resolveConversation(conversations, factory, control.launchFor, (key, message) => {
151
+ logger("channels").warn(`${key.channelId}:${key.conversationId} ${message}`);
152
+ void channels.notify(key.channelId, key.conversationId, message)
153
+ .catch((err) => log.error(`could not tell ${key.channelId} about its re-routed session`, err));
154
+ });
152
155
  // Channels connect once tokens are readable; a refused unlock must not take
153
156
  // down the web surface, which is where the operator repairs it. The chats a
154
157
  // previous restart cut off are told as soon as channels are up (drain.ts).
@@ -271,9 +274,9 @@ registerConfigSyncRoutes(app, {
271
274
  run: configurationSync.run,
272
275
  });
273
276
  registerTaskRoutes(app, tasks, { factory, router });
274
- registerChannelRoutes(app, channelStore, channels);
277
+ registerChannelRoutes(app, channelStore, channels, handoff);
278
+ registerVaultRoutes(app, { vault, doctor: () => secrets.doctor() });
275
279
  registerBoardRoutes(app);
276
- const sessionState = new SessionStateStore(db);
277
280
  registerPushRoutes(app, {
278
281
  store: new PushStore(db, secrets),
279
282
  hub,
@@ -301,7 +304,7 @@ app.route("/", createServer({
301
304
  names: MANAGED.map((tool) => tool.name),
302
305
  onToolsChanged: toolsUpdate.changed,
303
306
  validateCustomTools: (raw) => {
304
- const validated = normalizeCustomTools(raw, BUNDLED.map((ext) => ext.name));
307
+ const validated = normalizeCustomTools(raw);
305
308
  return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
306
309
  },
307
310
  secrets,
@@ -312,10 +315,27 @@ app.route("/", createServer({
312
315
  backgroundRuns: (id) => tasks.backgroundRuns(id),
313
316
  activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
314
317
  taskSessions: () => tasks.taskSessions(),
315
- channelOf: (id) => conversations.channelOf(id),
318
+ channelOf: (id) => conversations.keyOf(id)?.channelId,
316
319
  }));
317
320
  const port = Number(process.env.PORT ?? 3141);
318
321
  const hostname = process.env.HOST ?? "127.0.0.1";
322
+ servePier({
323
+ vault,
324
+ // The deep link an agent's "no secret named X" error carries; loopback when
325
+ // no public URL is set, since nothing in the process can discover one.
326
+ fileUrl: (name) => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/#/settings/vault?name=${name}`,
327
+ task: (params, callerSessionId) => tasks.handle(params, callerSessionId),
328
+ // Progress and cost go to the log: the CLI's answer is the text alone.
329
+ web: async (params, callerSessionId) => {
330
+ const parsed = parseWebParams(params);
331
+ const note = (text) => log.info(`web ${callerSessionId}: ${text}`);
332
+ const result = await runWeb(parsed, await factory.webContext(router.modelOf(callerSessionId)), note);
333
+ note(`done ${JSON.stringify(result.details)}`);
334
+ return result;
335
+ },
336
+ // Live in the router, or on disk: the same two places a callback target is looked for.
337
+ knows: async (id) => router.stateOf(id) !== undefined || (await factory.find(id)) !== undefined,
338
+ });
319
339
  // Every command a turn runs inherits this env: `NODE_ENV=production` makes an
320
340
  // agent's `npm install` skip devDependencies, and PORT/HOST would aim its dev
321
341
  // 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
@@ -63,12 +63,8 @@ export function normalizeModelRef(raw) {
63
63
  return null;
64
64
  return { provider: provider.trim(), id: id.trim() };
65
65
  }
66
- /** Shape only: the catalog is code behind the Pi SDK, and an unknown name is
67
- * ignored there, so a downgrade cannot lose a setting it cannot explain. */
68
- export function normalizeExtensions(raw) {
69
- return normalizeNames(raw);
70
- }
71
- /** Shape only, for the same reason: tools.ts owns the catalog. */
66
+ /** Shape only: tools.ts owns the catalog, and an unknown name is ignored
67
+ * there, so a downgrade cannot lose a setting it cannot explain. */
72
68
  export function normalizeTools(raw) {
73
69
  return normalizeNames(raw);
74
70
  }
@@ -98,11 +94,10 @@ export class SettingsStore {
98
94
  modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
99
95
  ...(titleModel ? { titleModel } : {}),
100
96
  autoUpdate: this.#value("autoUpdate") === "1",
101
- extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
102
97
  skillsOff: this.#json("skillsOff", normalizeNames, "a list of names") ?? [],
103
98
  tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
104
99
  // `"drop"`: a row the bundled catalog has since taken is redundant, not malformed.
105
- customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, toml}") ?? [],
100
+ customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, "drop"), "a list of {name, toml}") ?? [],
106
101
  };
107
102
  }
108
103
  /** A malformed row is named, not silently served as the empty value (§5). */
@@ -141,10 +136,6 @@ export class SettingsStore {
141
136
  this.#set("autoUpdate", on ? "1" : "0");
142
137
  return this.get();
143
138
  }
144
- setExtensions(names) {
145
- this.#set("extensions", JSON.stringify(names));
146
- return this.get();
147
- }
148
139
  setSkillsOff(names) {
149
140
  this.#set("skillsOff", JSON.stringify(names));
150
141
  return this.get();
package/dist/socket.js ADDED
@@ -0,0 +1,99 @@
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
+ "/task": operation("task"),
32
+ "/web": operation("web"),
33
+ };
34
+ /** A CLI verb's params under the caller's session. 422, not 400: the request
35
+ * was well-formed; what the operation refused is the caller's to read. */
36
+ function operation(name) {
37
+ return async (hosts, { params }, sessionId, answer) => {
38
+ try {
39
+ answer(200, { result: await hosts[name](params, sessionId) });
40
+ }
41
+ catch (err) {
42
+ answer(422, { error: err instanceof Error ? err.message : String(err) });
43
+ }
44
+ };
45
+ }
46
+ export function servePier(hosts, path = PIER_SOCK) {
47
+ const server = createServer((req, res) => void handle(hosts, req, res).catch((err) => {
48
+ log.error(`${req.method ?? "?"} ${req.url ?? "?"} failed`, err);
49
+ if (!res.headersSent)
50
+ res.writeHead(500).end(JSON.stringify({ error: String(err) }));
51
+ }));
52
+ // A crash leaves the old socket file behind, and listen() on it is EADDRINUSE.
53
+ rmSync(path, { force: true });
54
+ // bind() runs inside listen(): the umask covers the instant the file exists
55
+ // with default bits; the chmod sets the exact mode once it is bound. A bind
56
+ // that failed reports through the error event and left no file to chmod.
57
+ const umask = process.umask(0o077);
58
+ try {
59
+ server.listen(path, () => log.info(`cli socket at ${path}`));
60
+ }
61
+ finally {
62
+ process.umask(umask);
63
+ }
64
+ if (server.listening)
65
+ chmodSync(path, 0o600);
66
+ server.on("error", (err) => log.error(`cli socket ${path} failed`, err));
67
+ process.once("exit", () => rmSync(path, { force: true }));
68
+ return server;
69
+ }
70
+ async function handle(hosts, req, res) {
71
+ const answer = (status, body) => {
72
+ res.writeHead(status, { "content-type": "application/json" });
73
+ res.end(JSON.stringify(body));
74
+ };
75
+ const route = req.method === "POST" ? ROUTES[req.url ?? ""] : undefined;
76
+ if (!route)
77
+ return answer(404, { error: `POST ${Object.keys(ROUTES).join(" | ")} only` });
78
+ let raw = "";
79
+ for await (const chunk of req) {
80
+ raw += chunk.toString();
81
+ // Answered before the body is drained: leaving the loop destroys the
82
+ // request stream, and the response already queued still reaches the client.
83
+ if (raw.length > MAX_BODY)
84
+ return answer(413, { error: `body exceeds ${String(MAX_BODY / 1024)} KiB` });
85
+ }
86
+ let body;
87
+ try {
88
+ body = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return answer(400, { error: "body must be a JSON object" });
92
+ }
93
+ const { sessionId, ...fields } = typeof body === "object" && body !== null ? body : {};
94
+ if (typeof sessionId !== "string" || !sessionId)
95
+ return answer(400, { error: "PIER_SESSION_ID is required" });
96
+ if (!(await hosts.knows(sessionId)))
97
+ return answer(403, { error: `${sessionId} is not a session of this Pier` });
98
+ await route(hosts, fields, sessionId, answer);
99
+ }
@@ -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,225 @@
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|-> | --bash <script>] [--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 new run (--prompt | --bash | --task-id | --session … --prompt), a batch (--member), or a prompt on 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 (`--model ?` one pin per line), 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; an answer
107
+ // the server already wrote in lines (`--model ?`) is printed as it came.
108
+ io.stdout(typeof body.result === "string" ? body.result : JSON.stringify(body.result));
109
+ return 0;
110
+ }
111
+ io.stderr(`task: ${body.error ?? `socket answered ${String(status)}`}`);
112
+ return 1;
113
+ }
114
+ /** Argv → the exact params object, or an ArgvError before the socket is touched. */
115
+ function build(name, parsed, io) {
116
+ let stdinReads = 0;
117
+ const text = (raw) => {
118
+ if (raw !== "-")
119
+ return raw === undefined ? undefined : String(raw);
120
+ if (stdinReads++)
121
+ refuse("only one --prompt may read stdin (-)");
122
+ // An agent's shell has no TTY: a `-` with nothing piped is an empty prompt, named here rather than by the server.
123
+ return io.stdin() || refuse("--prompt - read nothing from stdin");
124
+ };
125
+ if (parsed.some((v) => v.model === "?"))
126
+ return { operation: "run", launch: { model: "?" } };
127
+ const [values, ...members] = parsed;
128
+ if (name === "list")
129
+ return { operation: "list" };
130
+ if (name === "cancel" || name === "recover") {
131
+ if ((values.run === undefined) === (values.group === undefined))
132
+ refuse(`${name} takes exactly one of --run or --group`);
133
+ if (name === "recover" && values.reason === undefined)
134
+ refuse("recover needs --reason");
135
+ return compact({ operation: name, run_id: values.run, group_id: values.group, reason: values.reason });
136
+ }
137
+ if (name === "save")
138
+ return saveParams(values, text);
139
+ /** One new run, in the three shapes `tasks[]` accepts. A saved
140
+ * definition runs as is: batch defaults pass it by, its own flags are refused. */
141
+ const entry = (v, own = v) => {
142
+ const launch = launchOf(v);
143
+ const timeoutSeconds = seconds("timeout", v.timeout);
144
+ if (v["task-id"] !== undefined) {
145
+ const extra = flagsOf(own).find((flag) => ["prompt", "bash", "session", "cwd", "model", "thinking", "name", "timeout"].includes(flag));
146
+ if (extra)
147
+ refuse(`--${extra} does not apply to a saved definition (--task-id)`);
148
+ return { task_id: v["task-id"] };
149
+ }
150
+ if (v.bash !== undefined) {
151
+ if (v.prompt !== undefined)
152
+ refuse("a run takes exactly one of --prompt or --bash");
153
+ if (launch)
154
+ refuse("--model/--thinking apply to a prompt, not --bash");
155
+ if (v.session !== undefined)
156
+ refuse("--session continues an agent session; --bash is a command, not a turn");
157
+ return compact({ name: v.name, timeoutSeconds, action: compact({ type: "bash", script: v.bash, cwd: v.cwd }) });
158
+ }
159
+ const prompt = text(v.prompt) ?? refuse("a new run needs --prompt, --bash or --task-id");
160
+ if (v.session === undefined)
161
+ return compact({ prompt, cwd: v.cwd, launch, name: v.name, timeoutSeconds });
162
+ if (v.cwd !== undefined)
163
+ refuse("--cwd applies to a fresh session, not --session");
164
+ return compact({ name: v.name, timeoutSeconds, action: compact({ type: "agent", session: { mode: "reuse", sessionId: v.session }, prompt, launch }) });
165
+ };
166
+ const delivery = compact({ callback: values.callback, callback_session_id: values["callback-session"] });
167
+ if (values.run !== undefined) {
168
+ if (members.length)
169
+ refuse("--run addresses one existing run; --member starts new ones");
170
+ const extra = flagsOf(values).find((flag) => !["run", "after", "prompt", "callback", "callback-session"].includes(flag));
171
+ if (extra)
172
+ refuse(`--${extra} does not apply to an existing run (--run)`);
173
+ const message = text(values.prompt) ?? refuse("--run needs --prompt");
174
+ return compact({ operation: "message", run_id: values.run, message, after: values.after || undefined, ...delivery });
175
+ }
176
+ if (values.after)
177
+ refuse("--after applies to --run only");
178
+ if (members.length) {
179
+ if (members.length < 2)
180
+ refuse("a batch needs at least two --member");
181
+ if (values["task-id"] !== undefined)
182
+ refuse("--task-id names one member's definition; put it after a --member");
183
+ const grouped = members.find((m) => flagsOf(m).some((flag) => GROUP_FLAGS.includes(flag)));
184
+ if (grouped)
185
+ refuse(`--${flagsOf(grouped).find((flag) => GROUP_FLAGS.includes(flag))} belongs before the first --member`);
186
+ const defaults = Object.fromEntries(Object.entries(values).filter(([flag]) => !GROUP_FLAGS.includes(flag)));
187
+ return compact({ operation: "run", tasks: members.map((m) => entry({ ...defaults, ...m }, m)), join: values.join, ...delivery });
188
+ }
189
+ if (values.join !== undefined)
190
+ refuse("--join applies to a batch (--member)");
191
+ const single = entry(values);
192
+ if (single.task_id !== undefined || single.action === undefined)
193
+ return { operation: "run", ...single, ...delivery };
194
+ return { operation: "run", task: single, ...delivery };
195
+ }
196
+ function saveParams(v, text) {
197
+ if ((v.prompt === undefined) === (v.bash === undefined))
198
+ refuse("save takes exactly one of --prompt or --bash");
199
+ const cron = v.cron !== undefined || v.tz !== undefined;
200
+ const watch = v.watch !== undefined || v.every !== undefined || v.repeat !== undefined;
201
+ if (cron && watch)
202
+ refuse("a task has one trigger: --cron/--tz or --watch/--every/--repeat");
203
+ if (cron && (v.cron === undefined || v.tz === undefined))
204
+ refuse("--cron and --tz go together");
205
+ if (watch && (v.watch === undefined || v.every === undefined))
206
+ refuse("--watch and --every go together");
207
+ const launch = launchOf(v);
208
+ if (launch && v.bash !== undefined)
209
+ refuse("--model/--thinking apply to a prompt, not --bash");
210
+ const trigger = cron
211
+ ? { type: "cron", expression: v.cron, timezone: v.tz }
212
+ : watch
213
+ ? compact({ type: "watch", script: v.watch, cwd: v.cwd, intervalSeconds: seconds("every", v.every), mode: v.repeat ? "repeat" : "once" })
214
+ : { type: "manual" };
215
+ const task = compact({
216
+ name: v.name,
217
+ timeoutSeconds: seconds("timeout", v.timeout),
218
+ trigger,
219
+ callback: v["callback-session"] === undefined ? undefined : { type: "session", sessionId: v["callback-session"] },
220
+ ...(v.bash === undefined
221
+ ? { prompt: text(v.prompt), cwd: v.cwd, launch }
222
+ : { action: compact({ type: "bash", script: v.bash, cwd: v.cwd }) }),
223
+ });
224
+ return compact({ operation: "save", task_id: v["task-id"], task });
225
+ }