@timqi/pier 0.0.1 → 0.0.2

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 (67) hide show
  1. package/README.md +76 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
package/dist/agent/pi.js CHANGED
@@ -1,19 +1,31 @@
1
1
  // The ONLY file allowed to import @earendil-works/pi-*. Implements the
2
2
  // AgentFactory/AgentSession seam from src/core/types.ts on the Pi SDK.
3
3
  // No Pi type may appear in an exported signature.
4
- import { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
4
+ import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
5
5
  import { logger } from "../log.js";
6
- import { imageAt, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
7
- import { defaultAgentDir } from "./config.js";
8
- import { curateModels } from "./models.js";
6
+ import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
7
+ import { defaultAgentDir, PiConfigStore } from "./config.js";
8
+ import { curateModels, pinFirst } from "./models.js";
9
9
  const log = logger("agent");
10
- const toImageContent = (images) => images?.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
11
10
  /** Pi's bash tool has no default timeout, so a hung command holds the turn
12
11
  * until someone aborts it — nobody is watching in a scheduled task. Kept below
13
- * the task-run timeout (tasks/definitions.ts) so a stuck command comes back as
12
+ * the default task-run timeout so a stuck command comes back as
14
13
  * a tool error the agent can retry with an explicit longer timeout, instead of
15
14
  * killing the whole run. */
16
15
  const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
16
+ /** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
17
+ const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work, verify results, and state what you could not check.
18
+
19
+ # Communication
20
+ These rules govern conversational replies. When the reply *is* the deliverable — a report that was asked for, a review, a task run whose result another agent reads — the work sets the length: complete beats brief, and nothing below caps it.
21
+ - Answer with the conclusion only. Reasons, process, trade-offs, alternatives: only when asked.
22
+ - Cap per reply: 100 words (or 100 Chinese chars), max 3 bullets; 300 when explicitly asked why or how. Code blocks, diffs and commands don't count.
23
+ - Never: preamble, restating the question, closing summaries, "I'm going to..." narration, listing changes already visible in the diff.
24
+ - After edits, say only: file(s) touched + one line on the result. Don't explain self-evident code.
25
+ - Show file paths as \`path:line\`.
26
+ - If the honest answer needs more than the cap, give the conclusion plus one short "want the details?" — don't dump it.
27
+ - Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.`;
28
+ export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
17
29
  /** Patching the call is cheaper than replacing the tool: the built-in keeps its
18
30
  * shell settings, and the agent spends no tokens deciding a timeout. */
19
31
  const bashTimeoutDefault = (pi) => {
@@ -23,10 +35,24 @@ const bashTimeoutDefault = (pi) => {
23
35
  }
24
36
  });
25
37
  };
26
- class PiSession {
38
+ export class PiSession {
27
39
  pi;
28
- constructor(pi) {
40
+ pinned;
41
+ constructor(pi,
42
+ /** Operator pins, read per call — the menu can change while we run. */
43
+ pinned = () => []) {
29
44
  this.pi = pi;
45
+ this.pinned = pinned;
46
+ }
47
+ /** Pi's dispose unhooks the one listener that persists and emits, so a turn
48
+ * started after it runs for real — model call, tools and all — and lands
49
+ * nowhere: no transcript entry, no event, and a promise that resolves as if
50
+ * it worked. Refusing is what turns that silence into the failure it is;
51
+ * callers already report or retry a rejection (§5b). */
52
+ disposed = false;
53
+ live() {
54
+ if (this.disposed)
55
+ throw new Error(`session ${this.pi.sessionId} is closed`);
30
56
  }
31
57
  get id() {
32
58
  return this.pi.sessionId;
@@ -57,7 +83,7 @@ class PiSession {
57
83
  }
58
84
  async availableModels() {
59
85
  const available = await this.pi.modelRuntime.getAvailable();
60
- const curated = curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning })));
86
+ const curated = pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
61
87
  // The session's active model must stay selectable even when curation
62
88
  // (or an older catalog) would hide it.
63
89
  const current = this.model;
@@ -82,11 +108,9 @@ class PiSession {
82
108
  return this.pi.clearQueue();
83
109
  }
84
110
  async history() {
111
+ this.live();
85
112
  return toChatTurns(this.pi.messages);
86
113
  }
87
- async image(ordinal) {
88
- return imageAt(this.pi.messages, ordinal);
89
- }
90
114
  async rewindToUserTurn(index) {
91
115
  const total = (await this.history()).filter((t) => t.role === "user").length;
92
116
  // Anchor at the tail: branch entries keep compacted-away history that
@@ -104,16 +128,22 @@ class PiSession {
104
128
  if (cancelled)
105
129
  throw new Error("rewind cancelled");
106
130
  }
107
- prompt(text, images) {
108
- return this.pi.prompt(text, { images: toImageContent(images) });
131
+ // Async, so a refusal is a rejected promise: the seam promises callers they
132
+ // may only `.catch()` (core/types.ts), and dispatch does exactly that.
133
+ async prompt(text) {
134
+ this.live();
135
+ return this.pi.prompt(text);
109
136
  }
110
- steer(text, images) {
111
- return this.pi.steer(text, toImageContent(images));
137
+ async steer(text) {
138
+ this.live();
139
+ return this.pi.steer(text);
112
140
  }
113
- followUp(text, images) {
114
- return this.pi.followUp(text, toImageContent(images));
141
+ async followUp(text) {
142
+ this.live();
143
+ return this.pi.followUp(text);
115
144
  }
116
- systemInput(text, origin, mode) {
145
+ async systemInput(text, origin, mode) {
146
+ this.live();
117
147
  return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
118
148
  }
119
149
  abort() {
@@ -136,6 +166,7 @@ class PiSession {
136
166
  return undefined;
137
167
  }
138
168
  async dispose() {
169
+ this.disposed = true;
139
170
  this.pi.dispose();
140
171
  }
141
172
  }
@@ -144,6 +175,8 @@ export class PiAgentFactory {
144
175
  instructions;
145
176
  skillPaths;
146
177
  credentials;
178
+ providerConfig;
179
+ pinned;
147
180
  constructor(extraTools = [],
148
181
  /** Appended as a virtual context file, so Pi's own prompt stays intact.
149
182
  * Read per session, not captured once: it carries settings a user can
@@ -157,23 +190,141 @@ export class PiAgentFactory {
157
190
  * Optional only for bare test factories; main.ts always passes one, and
158
191
  * every runtime built here reads and writes through it — an OAuth refresh
159
192
  * persists to the database, never to a plaintext file. */
160
- credentials) {
193
+ credentials, providerConfig = new PiConfigStore(),
194
+ /** Operator-pinned models (Console → Settings → Models), surfaced first in
195
+ * every picker. A getter for the same reason `instructions` is one. */
196
+ pinned = () => []) {
161
197
  this.extraTools = extraTools;
162
198
  this.instructions = instructions;
163
199
  this.skillPaths = skillPaths;
164
200
  this.credentials = credentials;
201
+ this.providerConfig = providerConfig;
202
+ this.pinned = pinned;
165
203
  }
166
204
  /** One runtime for the whole process; catalogs are global, not per session. */
167
205
  catalog;
206
+ refreshQueue = Promise.resolve();
207
+ builtinProviderIds;
168
208
  /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
169
209
  * name, so the SDK accepts it without this file exporting any SDK type. */
170
210
  createRuntime() {
171
211
  return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
172
212
  }
213
+ authRuntime() {
214
+ return (this.catalog ??= this.createRuntime());
215
+ }
216
+ refreshedRuntime() {
217
+ const result = this.refreshQueue.then(async () => {
218
+ const runtime = await this.authRuntime();
219
+ await runtime.refresh({ allowNetwork: false });
220
+ return runtime;
221
+ });
222
+ this.refreshQueue = result.then(() => undefined, () => undefined);
223
+ return result;
224
+ }
225
+ builtinIds() {
226
+ return (this.builtinProviderIds ??= ModelRuntime.create({
227
+ ...(this.credentials ? { credentials: this.credentials } : {}),
228
+ modelsPath: null,
229
+ refreshOnCreate: false,
230
+ }).then((runtime) => new Set(runtime.getProviders().map((provider) => provider.id))));
231
+ }
173
232
  async availableModels() {
174
- this.catalog ??= this.createRuntime();
175
- const available = await (await this.catalog).getAvailable();
176
- return curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning })));
233
+ const available = await (await this.refreshedRuntime()).getAvailable();
234
+ return pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
235
+ }
236
+ async providers() {
237
+ const [builtinIds, structures] = await Promise.all([
238
+ this.builtinIds(),
239
+ this.providerConfig.providerStructures(),
240
+ ]);
241
+ // Refresh after queued config writes settle, so runtime and structure never
242
+ // combine an older catalog with a newer models.json snapshot.
243
+ const runtime = await this.refreshedRuntime();
244
+ const stored = new Map((await runtime.listCredentials()).map((c) => [c.providerId, c.type]));
245
+ return runtime.getProviders().map((provider) => {
246
+ const status = runtime.getProviderAuthStatus(provider.id);
247
+ const methods = [];
248
+ if (provider.auth.apiKey?.login) {
249
+ methods.push({ type: "api_key", name: provider.auth.apiKey.name });
250
+ }
251
+ if (provider.auth.oauth) {
252
+ methods.push({
253
+ type: "oauth",
254
+ name: provider.auth.oauth.loginLabel ?? provider.auth.oauth.name,
255
+ ...(provider.auth.oauth.isSubscription ? { subscription: true } : {}),
256
+ });
257
+ }
258
+ const credential = stored.get(provider.id);
259
+ const structure = structures[provider.id];
260
+ return {
261
+ id: provider.id,
262
+ name: structure?.name ?? provider.name,
263
+ builtin: builtinIds.has(provider.id),
264
+ methods,
265
+ configured: status.configured,
266
+ ...(status.label ? { source: status.label } : status.source ? { source: status.source } : {}),
267
+ ...(credential ? { stored: credential } : {}),
268
+ ...(structure?.endpoint ? { endpoint: structure.endpoint } : {}),
269
+ ...(structure?.api ? { api: structure.api } : {}),
270
+ ...(structure?.models ? { models: structure.models } : {}),
271
+ };
272
+ });
273
+ }
274
+ async setup(input) {
275
+ const builtins = await this.builtinIds();
276
+ if (input.kind === "builtin" && !builtins.has(input.id)) {
277
+ throw new Error(`not a built-in provider: ${input.id}`);
278
+ }
279
+ if (input.kind === "custom" && builtins.has(input.id)) {
280
+ throw new Error(`built-in provider must use built-in setup: ${input.id}`);
281
+ }
282
+ try {
283
+ await this.providerConfig.setupProvider(input, async () => {
284
+ const runtime = await this.refreshedRuntime();
285
+ const error = runtime.getError();
286
+ if (error)
287
+ throw new Error(error);
288
+ if (!runtime.getProvider(input.id))
289
+ throw new Error(`provider did not load: ${input.id}`);
290
+ });
291
+ }
292
+ catch (err) {
293
+ try {
294
+ await this.refreshedRuntime();
295
+ }
296
+ catch (refreshErr) {
297
+ log.warn("provider runtime refresh failed after config rollback", refreshErr);
298
+ }
299
+ throw err;
300
+ }
301
+ }
302
+ async login(providerId, type, interaction) {
303
+ const store = this.credentials;
304
+ const previous = await store?.read(providerId);
305
+ const restore = async (committed) => {
306
+ if (store && await store.replaceIfCurrent(providerId, committed, previous)) {
307
+ await this.refreshedRuntime();
308
+ }
309
+ };
310
+ try {
311
+ const committed = await (await this.authRuntime()).login(providerId, type, interaction);
312
+ return () => restore(committed);
313
+ }
314
+ catch (err) {
315
+ if (err instanceof CredentialSynchronizationError && err.credential) {
316
+ try {
317
+ await restore(err.credential);
318
+ }
319
+ catch (rollback) {
320
+ throw new AggregateError([err, rollback], `failed to restore credential for ${providerId}`);
321
+ }
322
+ }
323
+ throw err;
324
+ }
325
+ }
326
+ async logout(providerId) {
327
+ await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
177
328
  }
178
329
  /**
179
330
  * Pi discovers AGENTS.md itself; we append one more, in memory, telling the
@@ -184,6 +335,9 @@ export class PiAgentFactory {
184
335
  const loader = new DefaultResourceLoader({
185
336
  cwd,
186
337
  agentDir: defaultAgentDir(), // same discovery Pi would have done itself
338
+ // SYSTEM.md remains user-owned: append it after Pier's replacement for
339
+ // Pi's generic default, preserving the user's later instruction layer.
340
+ systemPromptOverride: pierSystemPrompt,
187
341
  additionalSkillPaths: this.skillPaths,
188
342
  extensionFactories: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
189
343
  agentsFilesOverride: (current) => {
@@ -230,11 +384,12 @@ export class PiAgentFactory {
230
384
  }
231
385
  },
232
386
  }));
233
- if (opts.name)
234
- sessionManager.appendSessionInfo(opts.name);
235
387
  // Locked Secrets is a refusal with a reason, here — not "provider is not
236
- // configured" three calls later, and never a fall back to auth.json.
388
+ // configured" three calls later, and never a fall back to auth.json. Before
389
+ // appendSessionInfo, so the refused open writes nothing to disk.
237
390
  this.credentials?.assertUnlocked();
391
+ if (opts.name)
392
+ sessionManager.appendSessionInfo(opts.name);
238
393
  const tools = opts.capabilities === "read"
239
394
  ? ["read", "grep", "find", "ls", ...this.extraTools.map((tool) => tool.name)]
240
395
  : undefined;
@@ -247,7 +402,7 @@ export class PiAgentFactory {
247
402
  resourceLoader: await this.resourceLoader(cwd),
248
403
  });
249
404
  live = created.session;
250
- const session = new PiSession(live);
405
+ const session = new PiSession(live, this.pinned);
251
406
  if (opts.model)
252
407
  await session.setModel(opts.model);
253
408
  if (opts.thinking)
@@ -4,8 +4,8 @@
4
4
  //
5
5
  // Only <board>/site is reachable over HTTP: sources, README and the manifest
6
6
  // itself stay off the wire, so a public board leaks nothing about how it was
7
- // made. `public` is a data state, not yet a security boundary — nothing in
8
- // Pier authenticates today (docs/architecture.md, Open Questions).
7
+ // made. `/boards/*` is authenticated; `/p/*` additionally requires the
8
+ // manifest's `public` flag and runs as sandboxed active content.
9
9
  import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
10
10
  import { extname, join, resolve, sep } from "node:path";
11
11
  import { logger } from "../log.js";
@@ -33,11 +33,16 @@ const TYPES = {
33
33
  ".txt": "text/plain; charset=utf-8",
34
34
  ".csv": "text/plain; charset=utf-8",
35
35
  };
36
- // A published board must not be able to phone home: no third-party fetches, no
37
- // framing, no beacons. Inline style/script stay allowed a single-file board
38
- // is the normal shape.
36
+ // Board HTML is active content on the workbench origin. Scripts stay available
37
+ // for self-contained pages, but the response sandbox removes forms, frames,
38
+ // popups and network access. Public pages omit same-origin too, so their scripts
39
+ // receive an opaque origin and cannot inherit an operator's authority.
39
40
  const CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
40
- "script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'none'";
41
+ "script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; " +
42
+ "worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; " +
43
+ "frame-ancestors 'none'";
44
+ const PRIVATE_CSP = `sandbox allow-scripts allow-same-origin; ${CSP}`;
45
+ const PUBLIC_CSP = `sandbox allow-scripts; ${CSP}`;
41
46
  /** Malformed boards are reported once, not on every scan. */
42
47
  const warned = new Set();
43
48
  /** The one choke point where a slug becomes a path, so the slug is validated
@@ -148,12 +153,17 @@ async function serveFile(c, dir, slug, rest, publicOnly) {
148
153
  const headers = {
149
154
  "content-type": type,
150
155
  "x-content-type-options": "nosniff",
151
- // HTML is the page an agent keeps rewriting; assets are fingerprinted or
152
- // rare enough that five minutes is safe.
153
- "cache-control": type.startsWith("text/html") ? "no-store" : "max-age=300",
156
+ // Every public request rechecks the manifest, so unpublishing cannot leave
157
+ // a shared-cache copy reachable. Private assets may stay in one browser.
158
+ "cache-control": publicOnly || type.startsWith("text/html")
159
+ ? "no-store"
160
+ : "private, max-age=300",
161
+ "content-security-policy": publicOnly ? PUBLIC_CSP : PRIVATE_CSP,
154
162
  };
163
+ // Public sandboxed pages have an opaque origin. Fonts and module scripts need
164
+ // CORS even when their URLs are under the same published board.
155
165
  if (publicOnly)
156
- headers["content-security-policy"] = CSP;
166
+ headers["access-control-allow-origin"] = "*";
157
167
  return c.body(await readFile(file), 200, headers);
158
168
  }
159
169
  export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
@@ -1,4 +1,4 @@
1
- // Console → Channels HTTP surface. One document per platform: credentials,
1
+ // Settings → Channels HTTP surface. One document per platform: credentials,
2
2
  // global defaults, bound users and discovered chats travel together, so the
3
3
  // UI never has to stitch two half-configs.
4
4
  import { isThinkingLevel } from "../core/types.js";
@@ -12,22 +12,39 @@ const ADAPTERS = [
12
12
  // Lifecycle news, which is not what the injected sink below is for: that one
13
13
  // is a warning sink the adapters share, and "slack started" is not a warning.
14
14
  const log = logger("channels");
15
+ // The parameter below shadows `log` inside its own default expression.
16
+ const warn = (m) => log.warn(m);
15
17
  export class ChannelRuntime {
16
18
  store;
17
19
  router;
18
20
  control;
19
21
  log;
20
22
  live = new Map();
21
- constructor(store, router, control, log = (m) => logger("channels").warn(m)) {
23
+ reloading = Promise.resolve();
24
+ stopped = false;
25
+ constructor(store, router, control, log = warn) {
22
26
  this.store = store;
23
27
  this.router = router;
24
28
  this.control = control;
25
29
  this.log = log;
26
30
  }
27
- /** (Re)start every platform whose config says it should run. Idempotent. */
28
- async reload() {
29
- for (const adapter of ADAPTERS)
30
- await this.restart(adapter);
31
+ /** (Re)start every platform whose config says it should run. Idempotent.
32
+ * Serialized two concurrent Console saves raced into duplicate live
33
+ * adapters; platforms restart in parallel so a hung start on one cannot
34
+ * stall the other, and a failure is logged, never swallowed. */
35
+ reload() {
36
+ const run = this.reloading.catch(() => { }).then(async () => {
37
+ if (this.stopped)
38
+ return;
39
+ const results = await Promise.allSettled(ADAPTERS.map((a) => this.restart(a)));
40
+ results.forEach((result, i) => {
41
+ if (result.status === "rejected") {
42
+ this.log(`${ADAPTERS[i].platform} reload failed: ${String(result.reason)}`);
43
+ }
44
+ });
45
+ });
46
+ this.reloading = run;
47
+ return run;
31
48
  }
32
49
  async restart(adapter) {
33
50
  const { platform, needsAppToken, build } = adapter;
@@ -67,7 +84,21 @@ export class ChannelRuntime {
67
84
  this.live.set(platform, channel);
68
85
  log.info(`${platform} started`);
69
86
  }
87
+ /** Post a note into a conversation on a live adapter. Boot-time restart-note
88
+ * delivery (src/drain.ts) has no session to report through; false means the
89
+ * platform is not running, so the caller can say so instead of dropping it. */
90
+ async notify(platform, conversationId, text) {
91
+ const channel = this.live.get(platform);
92
+ if (!channel)
93
+ return false;
94
+ await channel.notify(conversationId, { text, origin: { kind: "error" } });
95
+ return true;
96
+ }
70
97
  async stop() {
98
+ // Join the reload queue first: an in-flight restart could otherwise
99
+ // register an adapter after `live` was cleared — running, unstoppable.
100
+ this.stopped = true;
101
+ await this.reloading.catch(() => { });
71
102
  for (const channel of this.live.values()) {
72
103
  await channel.stop().catch((err) => this.log(`${channel.id} did not stop cleanly: ${String(err)}`));
73
104
  }
@@ -288,9 +288,7 @@ export class SlackApi {
288
288
  });
289
289
  if (!res.ok)
290
290
  throw new Error(`slack file download: ${res.status}`);
291
- const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "image/png";
292
- if (!mimeType.startsWith("image/"))
293
- throw new Error(`slack file is ${mimeType}, not an image`);
294
- return { data: Buffer.from(await res.arrayBuffer()).toString("base64"), mimeType };
291
+ const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "application/octet-stream";
292
+ return { bytes: new Uint8Array(await res.arrayBuffer()), mimeType };
295
293
  }
296
294
  }
@@ -4,7 +4,7 @@
4
4
  // inbound traffic: which renderer to use, how to chunk against that renderer's
5
5
  // limit, and what an empty turn still has to say. The adapter keeps the 👀
6
6
  // receipts, because those are about the turn ending, not about what was said.
7
- import { formatTurnMeta, originLabel } from "../core/reply.js";
7
+ import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
8
8
  import { isBlockRejection } from "./slack-api.js";
9
9
  import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
10
10
  /**
@@ -38,13 +38,9 @@ export class SlackOutbound {
38
38
  // indistinguishable from a crash, a dropped connection or a bug, and the
39
39
  // person waiting has no way to tell. A muted one-liner is the cheapest
40
40
  // honest answer.
41
- // Options count as a reply: the buttons are the answer, so a turn that is
42
- // only its options is not "nothing".
43
- const quiet = text || row
44
- ? ""
45
- : reply.silence
46
- ? `_stayed silent — ${escapeMrkdwn(reply.silence)}_`
47
- : "_no reply_";
41
+ const quiet = isSilentReply(reply)
42
+ ? `_${quietLabel(reply.silence && escapeMrkdwn(reply.silence))}_`
43
+ : "";
48
44
  if (!(text || row || footer || quiet))
49
45
  return;
50
46
  const parts = text ? chunk(text, this.budget()) : [""];
@@ -20,9 +20,6 @@ export const MARKDOWN_MAX = 11_000;
20
20
  export const MRKDWN_MAX = 2800;
21
21
  // Slack truncates a button label past this, mid-word.
22
22
  const BUTTON_MAX = 75;
23
- // Slack's cap on one actions block. `MAX_SUGGESTIONS` in core/reply.ts already
24
- // holds the agent to 5, so this only guards a caller that bypasses it.
25
- const MAX_BUTTONS = 25;
26
23
  /** Shared: the adapter and the panel escape plain text with this too. */
27
24
  export const escapeMrkdwn = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28
25
  /**
@@ -187,7 +184,7 @@ const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON
187
184
  export function actions(labels) {
188
185
  if (!labels.length)
189
186
  return undefined;
190
- const elements = labels.slice(0, MAX_BUTTONS).map((label, index) => ({
187
+ const elements = labels.map((label, index) => ({
191
188
  type: "button",
192
189
  action_id: `${OFFER_PREFIX}${index}`,
193
190
  text: { type: "plain_text", text: truncate(label), emoji: true },
@@ -22,6 +22,8 @@
22
22
  //
23
23
  // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
24
24
  // config.ts, platform-blind and shared with Telegram.
25
+ import { saveInbound } from "../core/inbox.js";
26
+ import { fileMarker, lostMarker, MAX_INBOUND_BYTES } from "../core/inbound-file.js";
25
27
  import { logger } from "../log.js";
26
28
  import { Chains } from "./chains.js";
27
29
  import { parseCommand } from "./commands.js";
@@ -178,7 +180,7 @@ export class SlackChannel {
178
180
  }
179
181
  async stop() {
180
182
  this.running = false;
181
- await this.socket?.close().catch(() => { });
183
+ await this.socket?.close().catch((err) => this.log(`slack socket did not close cleanly: ${String(err)}`));
182
184
  this.socket = undefined;
183
185
  await this.chains.drain(DRAIN_TIMEOUT_MS);
184
186
  }
@@ -264,7 +266,7 @@ export class SlackChannel {
264
266
  if (!ts)
265
267
  return this.log("message event without a ts, dropped");
266
268
  const raw = (event.text ?? "").trim();
267
- const files = (event.files ?? []).filter((f) => f.mimetype?.startsWith("image/"));
269
+ const files = event.files ?? [];
268
270
  if (!raw && !files.length)
269
271
  return;
270
272
  const { kind } = await this.directory.channel(this.api, channel, event);
@@ -301,7 +303,7 @@ export class SlackChannel {
301
303
  }
302
304
  // Downloading only past the gate: an unauthorized sender must not be able
303
305
  // to make the bot pull bytes on their behalf.
304
- const images = await this.images(files);
306
+ const markers = await this.saveAttachments(files);
305
307
  this.receipts.mark(here.conversationId, channel, ts);
306
308
  // IM messages steer by default: a follow-up that waits for the turn to end
307
309
  // is the wrong default when the human is watching a 👀 in a thread.
@@ -311,8 +313,7 @@ export class SlackChannel {
311
313
  // A Slack thread is many people talking into one session, so the agent is
312
314
  // told who spoke — and the id, which is what a mention needs.
313
315
  sender: { id: event.user, name: await this.directory.user(this.api, event.user) },
314
- text,
315
- images,
316
+ text: [text, ...markers].filter(Boolean).join("\n"),
316
317
  mode: "steer",
317
318
  });
318
319
  }
@@ -458,17 +459,27 @@ export class SlackChannel {
458
459
  }
459
460
  return name ?? channel;
460
461
  }
461
- async images(files) {
462
- const out = [];
462
+ /** Save uploads to the inbox; each becomes a prompt line — a failed one
463
+ * becomes a lost-marker line, never silence (5b). */
464
+ async saveAttachments(files) {
465
+ const markers = [];
463
466
  for (const file of files) {
467
+ // Metadata check before the fetch: any type is welcome, but a movie
468
+ // buffered whole into memory is not.
469
+ if (file.size !== undefined && file.size > MAX_INBOUND_BYTES) {
470
+ markers.push(lostMarker(file.name ?? "attachment", "too large"));
471
+ continue;
472
+ }
464
473
  try {
465
- out.push(await this.api.downloadFile(file));
474
+ const { bytes, mimeType } = await this.api.downloadFile(file);
475
+ markers.push(fileMarker(await saveInbound(this.id, file.name, mimeType, bytes)));
466
476
  }
467
477
  catch (err) {
468
478
  this.log(`file download failed: ${String(err)}`);
479
+ markers.push(lostMarker(file.name ?? "attachment", "download failed"));
469
480
  }
470
481
  }
471
- return out;
482
+ return markers;
472
483
  }
473
484
  // --- outbound --------------------------------------------------------------
474
485
  /**
@@ -62,7 +62,7 @@ export class TelegramApi {
62
62
  async answerCallbackQuery(id, text) {
63
63
  await this.call("answerCallbackQuery", { callback_query_id: id, text });
64
64
  }
65
- async downloadPhoto(fileId) {
65
+ async downloadFile(fileId) {
66
66
  const file = await this.call("getFile", { file_id: fileId });
67
67
  if (!file.file_path)
68
68
  throw new Error("telegram getFile: no file_path");
@@ -71,8 +71,7 @@ export class TelegramApi {
71
71
  });
72
72
  if (!res.ok)
73
73
  throw new Error(`telegram file download: ${res.status}`);
74
- const buf = Buffer.from(await res.arrayBuffer());
75
- const mimeType = file.file_path.endsWith(".png") ? "image/png" : "image/jpeg";
76
- return { data: buf.toString("base64"), mimeType };
74
+ const name = file.file_path.split("/").pop() || "file";
75
+ return { bytes: new Uint8Array(await res.arrayBuffer()), name };
77
76
  }
78
77
  }