@timqi/pier 0.1.4 → 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 (111) hide show
  1. package/README.md +4 -4
  2. package/dist/agent/events.js +12 -3
  3. package/dist/agent/listing.js +10 -2
  4. package/dist/agent/packages.js +3 -13
  5. package/dist/agent/pi.js +13 -40
  6. package/dist/channels/attach.js +2 -2
  7. package/dist/channels/commands.js +10 -8
  8. package/dist/channels/config.js +4 -6
  9. package/dist/channels/control.js +60 -14
  10. package/dist/channels/conversations.js +60 -13
  11. package/dist/channels/handoff.js +90 -0
  12. package/dist/channels/lark-api.js +7 -0
  13. package/dist/channels/lark-outbound.js +19 -0
  14. package/dist/channels/lark-panel.js +41 -26
  15. package/dist/channels/lark-render.js +2 -1
  16. package/dist/channels/lark.js +35 -26
  17. package/dist/channels/lines.js +1 -1
  18. package/dist/channels/panel.js +324 -115
  19. package/dist/channels/receipts.js +25 -12
  20. package/dist/channels/routes.js +21 -4
  21. package/dist/channels/runtime.js +21 -7
  22. package/dist/channels/slack-outbound.js +10 -0
  23. package/dist/channels/slack-panel.js +81 -40
  24. package/dist/channels/slack-render.js +3 -1
  25. package/dist/channels/slack.js +29 -14
  26. package/dist/channels/types.js +1 -2
  27. package/dist/cli.js +13 -5
  28. package/dist/core/identity.js +47 -12
  29. package/dist/core/reply.js +4 -1
  30. package/dist/core/router.js +20 -3
  31. package/dist/db.js +6 -1
  32. package/dist/main.js +42 -9
  33. package/dist/settings.js +3 -12
  34. package/dist/socket.js +10 -6
  35. package/dist/tasks/cli.js +17 -7
  36. package/dist/tasks/operations.js +24 -12
  37. package/dist/tools.js +1 -3
  38. package/dist/web/public/assets/{activity-NLS8W9yl.js → activity-CrybM-E8.js} +1 -1
  39. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  40. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  41. package/dist/web/public/assets/{boards-DleegoiC.js → boards-Cw7_6J6L.js} +1 -1
  42. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  43. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  44. package/dist/web/public/assets/{explorer-vJvV1Sx0.js → explorer-DV066dUD.js} +1 -1
  45. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  46. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  47. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  48. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  49. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  50. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  51. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  52. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  53. package/dist/web/public/assets/{runs-C5FveZ75.js → runs-DDagTNaM.js} +1 -1
  54. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  55. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  56. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  57. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  58. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  59. package/dist/web/public/assets/{task-runs-CJJ2j5Ks.js → task-runs-BnMack9t.js} +1 -1
  60. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  61. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  62. package/dist/web/public/assets/{tasks-R2Pv0Abg.js → tasks-BkW7YShZ.js} +1 -1
  63. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  64. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  65. package/dist/web/public/index.html +2 -2
  66. package/dist/web/public/index.html.br +0 -0
  67. package/dist/web/public/index.html.gz +0 -0
  68. package/dist/web/push.js +4 -10
  69. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  70. package/dist/websearch/cli.js +73 -0
  71. package/dist/websearch/run.js +273 -0
  72. package/docs/deploy.md +3 -3
  73. package/package.json +2 -3
  74. package/skills/pier-help/SKILL.md +22 -10
  75. package/skills/pier-tasks/SKILL.md +17 -8
  76. package/skills/pier-web/SKILL.md +50 -0
  77. package/dist/channels/telegram-api.js +0 -86
  78. package/dist/channels/telegram-panel.js +0 -97
  79. package/dist/channels/telegram-render.js +0 -69
  80. package/dist/channels/telegram.js +0 -421
  81. package/dist/extensions/index.js +0 -10
  82. package/dist/extensions/web/index.js +0 -9
  83. package/dist/extensions/web/tools.js +0 -265
  84. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  85. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  86. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  87. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  88. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  89. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  90. package/dist/web/public/assets/index-Bs-gol9o.css +0 -2
  91. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  92. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  93. package/dist/web/public/assets/index-Cyp2DKKF.js +0 -85
  94. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  95. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  96. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  97. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  98. package/dist/web/public/assets/settings-nJHIBbAz.js +0 -5
  99. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  100. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  101. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  102. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  103. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  104. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  105. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  106. /package/dist/{extensions/web → websearch}/content.js +0 -0
  107. /package/dist/{extensions/web → websearch}/http.js +0 -0
  108. /package/dist/{extensions/web → websearch}/json.js +0 -0
  109. /package/dist/{extensions/web → websearch}/language.js +0 -0
  110. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  111. /package/dist/{extensions/web → websearch}/provider.js +0 -0
@@ -1,5 +1,7 @@
1
- // Settings → Channels HTTP surface: one document per platform.
1
+ // Settings → Channels HTTP surface: one document per platform, and the
2
+ // web → IM handoff beside it.
2
3
  import { isThinkingLevel } from "../core/types.js";
4
+ import { HandoffError } from "./handoff.js";
3
5
  import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
4
6
  /** Stable mask: recomputable at write time, so "unchanged" is detectable. */
5
7
  const maskToken = (token) => token ? `${"•".repeat(8)}${token.slice(-4)}` : "";
@@ -35,14 +37,13 @@ function parseChats(raw, known) {
35
37
  enabled: asBool(edit.enabled),
36
38
  requireMention: asBool(edit.requireMention),
37
39
  requireBind: asBool(edit.requireBind),
38
- topicMode: asBool(edit.topicMode),
39
40
  cwd: asString(edit.cwd),
40
41
  model: asModel(edit.model),
41
42
  thinking: asThinking(edit.thinking),
42
43
  };
43
44
  });
44
45
  }
45
- export function registerChannelRoutes(app, store, runtime) {
46
+ export function registerChannelRoutes(app, store, runtime, handoff) {
46
47
  app.get("/api/channels/:platform", (c) => {
47
48
  const platform = c.req.param("platform");
48
49
  if (!isChannelPlatform(platform))
@@ -72,7 +73,6 @@ export function registerChannelRoutes(app, store, runtime) {
72
73
  appToken: kept(asString(body.appToken), current.appToken),
73
74
  requireMention: asBool(body.requireMention),
74
75
  requireBind: asBool(body.requireBind),
75
- topicMode: asBool(body.topicMode),
76
76
  cwd: asString(body.cwd),
77
77
  model: asModel(body.model),
78
78
  thinking: asThinking(body.thinking),
@@ -97,4 +97,21 @@ export function registerChannelRoutes(app, store, runtime) {
97
97
  store.unbind(platform, c.req.param("id"));
98
98
  return c.json({ ok: true });
99
99
  });
100
+ app.get("/api/handoff/targets", (c) => c.json({ targets: handoff.targets() }));
101
+ app.post("/api/handoff", async (c) => {
102
+ const body = (await c.req.json().catch(() => null));
103
+ const sessionId = asString(body?.sessionId);
104
+ const chatId = asString(body?.chatId);
105
+ const platform = body?.platform;
106
+ if (!sessionId || !chatId || !isChannelPlatform(platform))
107
+ return c.json({ error: "invalid body" }, 400);
108
+ try {
109
+ return c.json(await handoff.continueIn({ sessionId, platform, chatId }), 201);
110
+ }
111
+ catch (err) {
112
+ if (err instanceof HandoffError)
113
+ return c.json({ error: err.message }, err.status);
114
+ throw err;
115
+ }
116
+ });
100
117
  }
@@ -3,12 +3,10 @@
3
3
  import { logger } from "../log.js";
4
4
  import { LarkChannel } from "./lark.js";
5
5
  import { SlackChannel } from "./slack.js";
6
- import { TelegramChannel } from "./telegram.js";
7
6
  const ADAPTERS = [
8
- { platform: "telegram", needsAppToken: false, build: (deps) => new TelegramChannel(deps) },
9
- { platform: "slack", needsAppToken: true, build: (deps) => new SlackChannel(deps) },
7
+ { platform: "slack", build: (deps) => new SlackChannel(deps) },
10
8
  // Lark's "token" is the App ID and "appToken" the App Secret.
11
- { platform: "lark", needsAppToken: true, build: (deps) => new LarkChannel(deps) },
9
+ { platform: "lark", build: (deps) => new LarkChannel(deps) },
12
10
  ];
13
11
  // The injected sink is for warnings; "slack started" is not one.
14
12
  const log = logger("channels");
@@ -18,14 +16,18 @@ export class ChannelRuntime {
18
16
  store;
19
17
  router;
20
18
  control;
19
+ handoff;
21
20
  log;
22
21
  live = new Map();
23
22
  reloading = Promise.resolve();
24
23
  stopped = false;
25
- constructor(store, router, control, log = warn) {
24
+ constructor(store, router, control,
25
+ /** The pull half only; the push half needs this runtime, so main.ts closes the loop. */
26
+ handoff, log = warn) {
26
27
  this.store = store;
27
28
  this.router = router;
28
29
  this.control = control;
30
+ this.handoff = handoff;
29
31
  this.log = log;
30
32
  }
31
33
  /** Serialized: two concurrent Console saves would race into duplicate live
@@ -45,7 +47,7 @@ export class ChannelRuntime {
45
47
  return run;
46
48
  }
47
49
  async restart(adapter) {
48
- const { platform, needsAppToken, build } = adapter;
50
+ const { platform, build } = adapter;
49
51
  const existing = this.live.get(platform);
50
52
  if (existing) {
51
53
  this.live.delete(platform);
@@ -55,7 +57,7 @@ export class ChannelRuntime {
55
57
  const config = this.store.get(platform);
56
58
  if (!config.enabled || !config.token)
57
59
  return;
58
- if (needsAppToken && !config.appToken) {
60
+ if (!config.appToken) {
59
61
  // "Enabled but nothing happens" is indistinguishable from a broken adapter.
60
62
  this.log(`${platform}: enabled but no app token, not starting`);
61
63
  return;
@@ -64,6 +66,7 @@ export class ChannelRuntime {
64
66
  store: this.store,
65
67
  log: (m) => this.log(`${platform}: ${m}`),
66
68
  control: this.control,
69
+ handoff: this.handoff,
67
70
  });
68
71
  try {
69
72
  await channel.start((msg) => {
@@ -87,6 +90,17 @@ export class ChannelRuntime {
87
90
  await channel.notify(conversationId, { text, origin: { kind: "error" } });
88
91
  return true;
89
92
  }
93
+ running() {
94
+ return [...this.live.keys()];
95
+ }
96
+ /** Throws by name when the platform is not running: the caller's answer is
97
+ * "enable it in Settings", not a silent no-op. */
98
+ async openThread(platform, chatId, note) {
99
+ const channel = this.live.get(platform);
100
+ if (!channel)
101
+ throw new Error(`${platform} is not running`);
102
+ return channel.openThread(chatId, note);
103
+ }
90
104
  async stop() {
91
105
  // An in-flight restart could otherwise register an adapter after `live`
92
106
  // was cleared — running, unstoppable.
@@ -50,6 +50,16 @@ export class SlackOutbound {
50
50
  }
51
51
  return ts;
52
52
  }
53
+ /** The one root Pier posts (no `thread_ts`): the thread a web session is
54
+ * continued in has no user message to hang from. Answers the root's `ts`. */
55
+ async open(channel, note) {
56
+ const link = note.url || "_(no public URL set — Settings → Instance)_";
57
+ const sent = await this.api.postMessage({
58
+ channel,
59
+ text: `Continued from web: *${escapeMrkdwn(note.title)}*\n${link}\n_Reply in this thread to continue._`,
60
+ });
61
+ return sent.ts;
62
+ }
53
63
  budget() {
54
64
  return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
55
65
  }
@@ -1,23 +1,26 @@
1
- // Slack's half of the settings panel (panel.ts has the rest). The modal's
2
- // `private_metadata` carries the conversation, so a submission needs no
3
- // adapter-side state and survives a reload.
4
- import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
5
- import { context, escapeMrkdwn as esc, section } from "./slack-render.js";
1
+ // Slack's half of the settings panel (panel.ts has the rest). Every button's
2
+ // `value` and the modal's `private_metadata` carry the draft with the card's
3
+ // coordinates, so a click or a submission after a reload is understood from
4
+ // the payload alone and lands on the message the user is looking at.
5
+ import { ChatPanel, CWD_DRAFT_TAIL, CWD_PLACEHOLDER, holdQuestion, PANEL_PREFIX, readDraft, serializeDraft, } from "./panel.js";
6
+ import { context, escapeMrkdwn as esc, section, truncate } from "./slack-render.js";
7
+ import { chatOf } from "./types.js";
6
8
  const CWD_VIEW = "cfg_cwd";
7
9
  const CWD_BLOCK = "cwd_block";
8
10
  const CWD_INPUT = "cwd_input";
9
- const button = (b) => ({
11
+ const button = (b, value) => ({
10
12
  type: "button",
11
13
  action_id: `${PANEL_PREFIX}${b.action}`,
12
- text: { type: "plain_text", text: b.label, emoji: true },
14
+ text: { type: "plain_text", text: truncate(b.label), emoji: true },
15
+ ...(value ? { value } : {}),
13
16
  });
14
- const row = (buttons) => ({
17
+ const row = (buttons, value) => ({
15
18
  type: "actions",
16
- elements: buttons.map(button),
19
+ elements: buttons.map((b) => button(b, value)),
17
20
  });
21
+ const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: [], sessions: [] });
18
22
  export class SlackPanel extends ChatPanel {
19
23
  deps;
20
- platform = "slack";
21
24
  fence = ["`", "`"];
22
25
  constructor(deps) {
23
26
  super(deps);
@@ -26,30 +29,64 @@ export class SlackPanel extends ChatPanel {
26
29
  esc(text) {
27
30
  return esc(text);
28
31
  }
32
+ /** Said on the next redraw when a value could not be read: the picks it held are gone. */
33
+ resetNote;
34
+ /** A malformed value is a stale or foreign card: logged, drawn as an empty draft, and said. */
35
+ parseDraft(raw) {
36
+ if (!raw)
37
+ return {};
38
+ try {
39
+ return readDraft(JSON.parse(raw));
40
+ }
41
+ catch (err) {
42
+ this.deps.log(`unreadable panel value, draft reset: ${String(err)}`);
43
+ this.resetNote = "Your earlier picks could not be read — pick again.";
44
+ return {};
45
+ }
46
+ }
29
47
  // --- rendering -------------------------------------------------------------
30
- blocks(view, note) {
48
+ blocks(view, draft, note) {
49
+ const value = serializeDraft(draft);
31
50
  return [
32
51
  ...view.groups.map((g) => section([`*${g.title}*${g.suffix ?? ""}`, ...g.lines].join("\n"))),
33
- ...(view.picks?.length ? [row(view.picks)] : []),
34
- ...view.rows.filter((r) => r.length).map(row),
52
+ ...(view.picks?.length ? [row(view.picks, value)] : []),
53
+ ...view.rows.filter((r) => r.length).map((r) => row(r, value)),
35
54
  ...(note ? [context(esc(note))] : []),
36
55
  ];
37
56
  }
38
- async open(key, channel, threadTs) {
39
- const sent = await this.deps.api.postMessage({
40
- channel,
41
- thread_ts: threadTs,
42
- text: "Settings",
43
- blocks: this.blocks(await this.view(key, channel)),
44
- });
45
- this.remember(key, { chatId: channel, threadTs, ts: sent.ts, models: [] });
57
+ /** A card that cannot be posted must not look like nothing happening: the
58
+ * thread gets the reason as plain text. */
59
+ async open(key, channel, threadTs, question) {
60
+ const draft = holdQuestion(question);
61
+ const state = fresh(channel, "", draft);
62
+ let sent;
63
+ try {
64
+ sent = await this.deps.api.postMessage({
65
+ channel,
66
+ thread_ts: threadTs,
67
+ text: "Settings",
68
+ blocks: this.blocks(await this.view(key, state), draft),
69
+ });
70
+ }
71
+ catch (err) {
72
+ this.deps.log(`panel open failed: ${String(err)}`);
73
+ await this.deps.api.postMessage({
74
+ channel,
75
+ thread_ts: threadTs,
76
+ text: `Could not open the panel: ${String(err)}`,
77
+ }).catch((e) => this.deps.log(`panel open failure not posted: ${String(e)}`));
78
+ return;
79
+ }
80
+ this.remember(key, { ...state, ts: sent.ts });
46
81
  }
47
82
  async draw(state, view, note) {
83
+ const said = note ?? this.resetNote;
84
+ this.resetNote = undefined;
48
85
  await this.deps.api.updateMessage({
49
86
  channel: state.chatId,
50
87
  ts: state.ts,
51
88
  text: "Settings",
52
- blocks: this.blocks(view, note),
89
+ blocks: this.blocks(view, state.draft, said),
53
90
  }).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
54
91
  }
55
92
  async erase(state) {
@@ -57,35 +94,32 @@ export class SlackPanel extends ChatPanel {
57
94
  .catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
58
95
  }
59
96
  // --- actions ---------------------------------------------------------------
60
- /** Returns false when the action is not ours. */
61
- async onAction(interaction, key, actionId) {
62
- return this.dispatch(key, actionId, interaction, async () => {
63
- const channel = interaction.channel?.id;
64
- const message = interaction.message;
65
- if (channel && message)
66
- await this.open(key, channel, message.thread_ts ?? message.ts);
67
- });
97
+ /** Returns false when the action is not ours. `run` delivers Start's
98
+ * question as the clicker's message. */
99
+ async onAction(interaction, key, actionId, run) {
100
+ return this.dispatch(key, actionId, interaction, () => fresh(interaction.channel?.id ?? chatOf(key.conversationId), interaction.message?.ts ?? "", this.parseDraft(interaction.actions?.[0]?.value)), run);
68
101
  }
69
102
  // --- working directory (one typed answer, in a modal) ----------------------
70
- async promptCwd(key, _state, interaction) {
103
+ async promptCwd(key, state, interaction) {
71
104
  const trigger = interaction.trigger_id;
72
105
  if (!trigger) {
73
106
  await this.refresh(key, "Could not open the dialog — try again.");
74
107
  return;
75
108
  }
109
+ const metadata = { conversation: key.conversationId, ts: state.ts, draft: serializeDraft(state.draft) };
76
110
  await this.deps.api.openView(trigger, {
77
111
  type: "modal",
78
112
  callback_id: CWD_VIEW,
79
- private_metadata: key.conversationId,
80
- title: { type: "plain_text", text: "New session" },
81
- submit: { type: "plain_text", text: "Start" },
113
+ private_metadata: JSON.stringify(metadata),
114
+ title: { type: "plain_text", text: "Directory" },
115
+ submit: { type: "plain_text", text: "Set" },
82
116
  close: { type: "plain_text", text: "Cancel" },
83
117
  blocks: [
84
118
  {
85
119
  type: "input",
86
120
  block_id: CWD_BLOCK,
87
121
  label: { type: "plain_text", text: "Working directory" },
88
- hint: { type: "plain_text", text: `An absolute path. ${CWD_TAIL}` },
122
+ hint: { type: "plain_text", text: `An absolute path. ${CWD_DRAFT_TAIL}` },
89
123
  element: {
90
124
  type: "plain_text_input",
91
125
  action_id: CWD_INPUT,
@@ -100,11 +134,18 @@ export class SlackPanel extends ChatPanel {
100
134
  const view = interaction.view;
101
135
  if (view?.callback_id !== CWD_VIEW)
102
136
  return false;
103
- const key = {
104
- channelId: "slack",
105
- conversationId: view.private_metadata ?? "",
106
- };
107
- await this.startSessionIn(key, (view.state?.values?.[CWD_BLOCK]?.[CWD_INPUT]?.value ?? "").trim());
137
+ let meta;
138
+ try {
139
+ meta = JSON.parse(view.private_metadata ?? "");
140
+ }
141
+ catch (err) {
142
+ this.deps.log(`cwd modal without readable metadata, dropped: ${String(err)}`);
143
+ return true;
144
+ }
145
+ const key = { channelId: "slack", conversationId: meta.conversation };
146
+ if (!this.state(key))
147
+ this.remember(key, fresh(chatOf(meta.conversation), meta.ts, this.parseDraft(meta.draft)));
148
+ await this.chooseDir(key, (view.state?.values?.[CWD_BLOCK]?.[CWD_INPUT]?.value ?? "").trim());
108
149
  return true;
109
150
  }
110
151
  }
@@ -2,6 +2,7 @@
2
2
  // `actions` row. mrkdwn is not markdown (`*bold*`, `_italic_`, `~strike~`,
3
3
  // `<url|label>`), so the agent's markdown is translated; Slack degrades unknown
4
4
  // syntax to literal text rather than rejecting the message.
5
+ import { cut } from "../core/reply.js";
5
6
  import { balanceFences, chunkText } from "./chunk.js";
6
7
  /** Slack caps `markdown` blocks at 12,000 cumulative chars per message. */
7
8
  export const MARKDOWN_MAX = 11_000;
@@ -97,7 +98,8 @@ export const context = (text) => ({
97
98
  /** `action_id` carries an index: the label is read back off the message Slack
98
99
  * echoes with the click, so a button survives a reload. */
99
100
  export const OFFER_PREFIX = "sg:";
100
- const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON_MAX - 1)}\u2026` : label;
101
+ /** Slack refuses a button label over BUTTON_MAX: every button, the panel's too. */
102
+ export const truncate = (label) => cut(label, BUTTON_MAX);
101
103
  /** Slack wraps buttons on its own, so there is no row packing to budget. */
102
104
  export function actions(labels) {
103
105
  if (!labels.length)
@@ -10,7 +10,7 @@ import { awaitsTurn } from "../core/reply.js";
10
10
  import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
11
11
  import { logger } from "../log.js";
12
12
  import { Chains } from "./chains.js";
13
- import { parseCommand } from "./commands.js";
13
+ import { parseCommand, settingsDraft } from "./commands.js";
14
14
  import { Dedup } from "./dedup.js";
15
15
  import { Gatekeeper } from "./gatekeeper.js";
16
16
  import { ReceiptLedger, Receipts } from "./receipts.js";
@@ -24,7 +24,8 @@ const WORKING = "eyes";
24
24
  // The envelope is already acked, so this bounds concurrency (sockets,
25
25
  // downloads), not the backlog.
26
26
  const MAX_ACTIVE_CHATS = 16;
27
- const RECEIPT_STALE_MS = 30 * 60_000;
27
+ /** Only an idle conversation ages out, so this need not cover a long turn. */
28
+ const RECEIPT_STALE_MS = 10 * 60_000;
28
29
  const DRAIN_TIMEOUT_MS = 5000;
29
30
  /** Only has to cover a redelivery that crossed our immediate ack. */
30
31
  const DEDUP_TTL_MS = 5 * 60_000;
@@ -101,12 +102,12 @@ export class SlackChannel {
101
102
  setReaction: (channel, ts, emoji) => emoji
102
103
  ? this.api.addReaction(channel, ts, emoji)
103
104
  : this.api.removeReaction(channel, ts, WORKING),
104
- }, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS);
105
- if (deps.control) {
105
+ }, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
106
+ if (deps.control && deps.handoff) {
106
107
  this.panel = new SlackPanel({
107
108
  api: this.api,
108
109
  control: deps.control,
109
- store: deps.store,
110
+ handoff: deps.handoff,
110
111
  log: this.log,
111
112
  });
112
113
  }
@@ -218,14 +219,17 @@ export class SlackChannel {
218
219
  return this.bind(channel, event.user, threadTs, command?.args ?? "");
219
220
  if (command?.name === "stop")
220
221
  return this.abortTurn(here, channel, threadTs);
221
- // A bare `@bot` and `settings` are the same request.
222
- if (this.panel && (command?.name === "settings" || (!text && !files.length && !shares.length))) {
223
- return this.panel.open(here, channel, threadTs);
224
- }
225
222
  // Downloading only past the gate: an unauthorized sender must not make the
226
223
  // bot pull bytes on their behalf.
227
224
  const markers = await this.saveAttachments(files);
228
225
  const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
226
+ // A bare `@bot` and `settings` are the same request; `s <text>` drafts a
227
+ // session, so only where this message would start one: a thread root. The
228
+ // held question carries its markers, so Start sends what the user sent.
229
+ const question = threadTs === ts ? settingsDraft(text) : undefined;
230
+ if (this.panel && (question || command?.name === "settings" || (!text && !files.length && !shares.length))) {
231
+ return this.panel.open(here, channel, threadTs, question && [question, ...shared, ...markers].join("\n"));
232
+ }
229
233
  // Resolved before the mark: any await between mark() and dispatch is a
230
234
  // window in which a previous turn can settle and take this receipt with it.
231
235
  const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
@@ -272,7 +276,9 @@ export class SlackChannel {
272
276
  });
273
277
  if (!admitted)
274
278
  return;
275
- if (await this.panel?.onAction(interaction, key, actionId))
279
+ // Start's question: the card is the message the click was on, so it carries the 👀.
280
+ const run = (text) => this.deliver(key, channel, message.ts, user, text, onMessage);
281
+ if (await this.panel?.onAction(interaction, key, actionId, run))
276
282
  return;
277
283
  const text = offeredLabel(message.blocks, actionId);
278
284
  if (text === undefined) {
@@ -285,7 +291,6 @@ export class SlackChannel {
285
291
  await this.retireOptions(channel, message.ts, message.blocks);
286
292
  // A bot cannot post as the user, so the pick is echoed: otherwise the
287
293
  // thread shows an answer to a request nobody can see, with nothing to carry the eyes.
288
- const sender = { id: user, name: await this.directory.user(this.api, user) };
289
294
  const echo = await this.api.postMessage({
290
295
  channel,
291
296
  thread_ts: threadTs,
@@ -294,9 +299,14 @@ export class SlackChannel {
294
299
  this.log(`option echo failed: ${String(err)}`);
295
300
  return undefined;
296
301
  });
302
+ await this.deliver(key, channel, echo?.ts, user, text, onMessage);
303
+ }
304
+ /** A click's text as the clicker's message; `ts` is the message that carries the 👀. */
305
+ async deliver(key, channel, ts, user, text, onMessage) {
306
+ const sender = { id: user, name: await this.directory.user(this.api, user) };
297
307
  // No await between mark and dispatch — see onMessage.
298
- if (echo?.ts)
299
- this.receipts.mark(key.conversationId, channel, echo.ts);
308
+ if (ts)
309
+ this.receipts.mark(key.conversationId, channel, ts);
300
310
  onMessage({ key, senderId: user, sender, text, mode: "steer" });
301
311
  }
302
312
  /** Slack will not accept a message with neither text nor blocks, so a turn
@@ -439,6 +449,11 @@ export class SlackChannel {
439
449
  // The turn ended either way; a 👀 left up by a failed send looks like work.
440
450
  await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply), reply.meta);
441
451
  }
452
+ /** A web session's thread: the root is the one message Pier posts into a
453
+ * channel's main flow (channels/handoff.ts). */
454
+ async openThread(chatId, note) {
455
+ return conversationId(chatId, await this.out.open(chatId, note));
456
+ }
442
457
  /** The 👀 goes on the note itself: the turn it triggers has no message of
443
458
  * the user's to carry them. */
444
459
  async notify(conversation, note) {
@@ -449,6 +464,6 @@ export class SlackChannel {
449
464
  }
450
465
  const ts = await this.out.note(channel, threadTs, note);
451
466
  if (ts && awaitsTurn(note.origin))
452
- this.receipts.mark(conversation, channel, ts);
467
+ this.receipts.mark(conversation, channel, ts, note.at);
453
468
  }
454
469
  }
@@ -2,7 +2,7 @@
2
2
  // adapters and the Console view (type-only, so no node builtins here). Defaults
3
3
  // are least-privilege, and platform values are seeds copied into a chat on
4
4
  // discovery, not a runtime fallback: a switch means what it says.
5
- const PLATFORMS = ["telegram", "slack", "lark"];
5
+ const PLATFORMS = ["slack", "lark"];
6
6
  /** Validate at the boundary: an unknown platform is a 404, not a new row. */
7
7
  export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
8
8
  /** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
@@ -14,7 +14,6 @@ export const defaultChannelConfig = () => ({
14
14
  appToken: "",
15
15
  requireMention: true,
16
16
  requireBind: true,
17
- topicMode: true,
18
17
  cwd: "",
19
18
  model: null,
20
19
  thinking: null,
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ Usage
27
27
  run a command with named secrets in its env
28
28
  pier slack <subcommand> ... Slack from a shell, token from the vault (pier slack --help)
29
29
  pier task <command> ... subagents and scheduled tasks from a shell (pier task --help)
30
+ pier web search|fetch ... the public web through the provider's hosted tools (pier web --help)
30
31
  pier --version | --help
31
32
 
32
33
  Options for "service install"
@@ -42,6 +43,9 @@ const say = (message) => void process.stdout.write(`${message}\n`);
42
43
  /** Every socket route answers from memory and the database, so a Pier that
43
44
  * takes longer than this is stuck, and an agent's shell must not hang with it. */
44
45
  const SOCKET_TIMEOUT_MS = 30_000;
46
+ /** `/web` is the one route that waits on a provider: its own 90 s ceiling
47
+ * (`websearch/run.ts`) answers first, so this only catches a stuck Pier. */
48
+ const WEB_TIMEOUT_MS = 120_000;
45
49
  /** Typed on the binding: only then does a call narrow the code after it. */
46
50
  const die = (message) => {
47
51
  process.stderr.write(`${message}\n`);
@@ -52,8 +56,8 @@ const argv = process.argv.slice(2);
52
56
  const parsed = (() => {
53
57
  try {
54
58
  return parseArgs({
55
- // `slack` and `task` own their options; only the name is parsed here.
56
- args: argv[0] === "slack" || argv[0] === "task" ? [argv[0]] : argv,
59
+ // `slack`, `task` and `web` own their options; only the name is parsed here.
60
+ args: argv[0] === "slack" || argv[0] === "task" || argv[0] === "web" ? [argv[0]] : argv,
57
61
  allowPositionals: true,
58
62
  strict: true,
59
63
  options: {
@@ -125,6 +129,10 @@ else if (command === "task") {
125
129
  const { runTaskCli } = await import("./tasks/cli.js");
126
130
  process.exitCode = await runTaskCli(argv.slice(1), (params) => askPier("/task", { params }));
127
131
  }
132
+ else if (command === "web") {
133
+ const { runWebCli } = await import("./websearch/cli.js");
134
+ process.exitCode = await runWebCli(argv.slice(1), (params) => askPier("/web", { params }, WEB_TIMEOUT_MS));
135
+ }
128
136
  else if (command === "restart" || command === "reload") {
129
137
  if (subcommand)
130
138
  fail(`unexpected argument "${subcommand}"`);
@@ -277,11 +285,11 @@ function inject(wanted, values, cmd) {
277
285
  * session (`PIER_SESSION_ID`, the harness variable mapped by the shim). Not
278
286
  * running, silent, unreadable, or an identity or body Pier refuses, is one
279
287
  * `pier:` line and exit 2 before any route reads the answer. */
280
- async function askPier(path, body) {
288
+ async function askPier(path, body, timeout = SOCKET_TIMEOUT_MS) {
281
289
  const { PIER_SOCK } = await import("./paths.js");
282
290
  const answer = await new Promise((done, reject) => {
283
291
  let responded = false;
284
- const req = request({ socketPath: PIER_SOCK, method: "POST", path, headers: { "content-type": "application/json" }, timeout: SOCKET_TIMEOUT_MS }, (res) => {
292
+ const req = request({ socketPath: PIER_SOCK, method: "POST", path, headers: { "content-type": "application/json" }, timeout }, (res) => {
285
293
  responded = true;
286
294
  let raw = "";
287
295
  const unreadable = () => reject(new Error(`unreadable answer from ${PIER_SOCK} (status ${String(res.statusCode ?? 0)})`));
@@ -297,7 +305,7 @@ async function askPier(path, body) {
297
305
  }
298
306
  });
299
307
  });
300
- req.on("timeout", () => req.destroy(new Error(`Pier did not answer within ${String(SOCKET_TIMEOUT_MS / 1000)} s`)));
308
+ req.on("timeout", () => req.destroy(new Error(`Pier did not answer within ${String(timeout / 1000)} s`)));
301
309
  // A 413 arrives while the body is still being written; the EPIPE after it is not the news.
302
310
  req.on("error", (err) => responded || reject(err));
303
311
  req.end(JSON.stringify({ ...body, sessionId: process.env.PIER_SESSION_ID }));
@@ -26,8 +26,9 @@ export class SenderPrefix {
26
26
  seen = new Map();
27
27
  /** The line to put above this message, or `""` when the session already
28
28
  * knows. `conversation` is the chat's `<channelId>:<conversationId>`, told
29
- * once: a session never moves, but the rule stays the same as the rest. */
30
- next(sessionId, sender, at = Date.now(), conversation) {
29
+ * once: a session never moves, but the rule stays the same as the rest.
30
+ * `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing. */
31
+ next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false) {
31
32
  if (!sender?.id)
32
33
  return "";
33
34
  const last = this.seen.get(sessionId);
@@ -43,9 +44,14 @@ export class SenderPrefix {
43
44
  // is the id, and `U123<U123>` would read as a broken record.
44
45
  const id = sanitizeIdentity(sender.id);
45
46
  const label = sanitizeIdentity(sender.name);
46
- const who = newSpeaker ? (label === id ? `<${id}>` : `${label}<${id}>`) : "";
47
- const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
48
- const where = newPlace ? sanitizePlace(conversation) : "";
47
+ const named = opaqueIds ? label : label === id ? `<${id}>` : `${label}<${id}>`;
48
+ const who = newSpeaker ? named : "";
49
+ // A bare name has no `<>` to be told apart by, so it is only unambiguous
50
+ // next to the time: with opaque ids the clock is written whenever it is.
51
+ const clock = gap || newDay || (opaqueIds && !!who);
52
+ const when = clock ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
53
+ const place = opaqueIds ? conversation?.split(":")[0] : conversation;
54
+ const where = newPlace && place ? sanitizePlace(place) : "";
49
55
  return `[${[who, when, where].filter(Boolean).join(" ")}]`;
50
56
  }
51
57
  forget(sessionId) {
@@ -55,18 +61,26 @@ export class SenderPrefix {
55
61
  export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
56
62
  // Only the shapes `next()` emits, newline included: a human typing
57
63
  // `[14:23] on my way` is body text and must come back untouched.
58
- const HEADER = /^\[(?:([^\n[\]<>]*)<([^\n[\]<>]+)>)? ?((?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})? ?([a-z]+:[^\s[\]<>]+)?\]\n/;
64
+ const TIME = String.raw `(?<when>(?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})`;
65
+ const WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)?\]\n`);
66
+ /** The opaque-ids shape: a name with no `<>`, told apart from body text by the
67
+ * time that always follows it, and a platform with no conversation after it.
68
+ * A line of its own reading `[meeting 14:23]` is the price. */
69
+ const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?\]\n`);
59
70
  /** Read back a header this module wrote: the prefix is for the model, and a
60
71
  * surface showing a stored message renders the speaker its own way. */
61
72
  export function splitSpeaker(text) {
62
- const m = HEADER.exec(text);
63
- if (!m?.[2] && !m?.[3] && !m?.[4])
73
+ const head = WITH_ID.exec(text);
74
+ const { id, when, where } = head?.groups ?? {};
75
+ // A name on its own proves nothing: try the shape that requires a time.
76
+ const m = id || when || where ? head : NAMED.exec(text);
77
+ if (!m?.groups)
64
78
  return { text };
65
79
  return {
66
- ...(m[1] ? { name: m[1] } : {}),
67
- ...(m[2] ? { id: m[2] } : {}),
68
- ...(m[3] ? { when: m[3] } : {}),
69
- ...(m[4] ? { where: m[4] } : {}),
80
+ ...(m.groups.name ? { name: m.groups.name } : {}),
81
+ ...(m.groups.id ? { id: m.groups.id } : {}),
82
+ ...(m.groups.when ? { when: m.groups.when } : {}),
83
+ ...(m.groups.where ? { where: m.groups.where } : {}),
70
84
  text: text.slice(m[0].length),
71
85
  };
72
86
  }
@@ -94,3 +108,24 @@ export function readableTitle(title) {
94
108
  .trim();
95
109
  return said || title;
96
110
  }
111
+ /** How a session is named where it is announced (a push, a handoff): its
112
+ * readable title, else its directory. Never empty — a listing that could
113
+ * not answer must not silence the message. One line: every caller puts it
114
+ * inside emphasis or a button, where a newline breaks the markup. */
115
+ export const sessionLabel = (s) => readableTitle(s?.title)?.replace(/\s+/g, " ").trim() || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
116
+ /** Distinct directories, newest session first: the ground `projectCwds` picks
117
+ * from. */
118
+ export const distinctCwds = (list) => [...new Set([...list].sort((a, b) => b.createdAt - a.createdAt).map((s) => s.cwd))];
119
+ /** The distinct directories less the worktrees: `wt` puts a checkout beside its
120
+ * repository as `<repo>.<branch>`, and the next conversation about a project
121
+ * belongs in the project. A worktree with no such sibling stays. What every
122
+ * directory picker — web New-session menu, Settings scope, IM panel — offers. */
123
+ export function projectCwds(list) {
124
+ const all = distinctCwds(list);
125
+ const known = new Set(all);
126
+ return all.filter((cwd) => {
127
+ const slash = cwd.lastIndexOf("/");
128
+ const dot = cwd.indexOf(".", slash + 2); // not a leading dot: `.pier` is a name
129
+ return dot < 0 || !known.has(cwd.slice(0, dot));
130
+ });
131
+ }