@timqi/pier 0.0.1

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,172 @@
1
+ // Channel config persistence and the permission gate every adapter shares.
2
+ // One JSON document per platform holds credentials, defaults, bound users and
3
+ // the discovered chats — token, groups and permissions in one place, so a
4
+ // surface configuring a platform reads and writes exactly one row.
5
+ // The shapes live in types.ts; this file is the store and the policy.
6
+ import { pierDb } from "../db.js";
7
+ import { defaultChannelConfig, } from "./types.js";
8
+ const BIND_CODE_TTL_MS = 10 * 60_000;
9
+ /** Envelope from secrets.ts. A token that matches was sealed by us; anything
10
+ * else is legacy plaintext, still honored and re-sealed on the next save. */
11
+ const SEALED = /^v1:[0-9a-f]{8}:/;
12
+ export class ChannelStore {
13
+ secrets;
14
+ db;
15
+ cache = new Map();
16
+ /** Without `secrets`, tokens persist as given — tests and one-off tools.
17
+ * With it, tokens are sealed at rest; a locked store throws on both paths
18
+ * rather than serving a token it cannot read. */
19
+ constructor(db = pierDb(), secrets) {
20
+ this.secrets = secrets;
21
+ this.db = db;
22
+ }
23
+ /**
24
+ * The live cached document. Private on purpose: handing it out let a caller
25
+ * mutate config without saving, so memory and disk could disagree with no
26
+ * way to tell which was right. Internal readers use this; everyone outside
27
+ * gets a copy from get().
28
+ */
29
+ cached(platform) {
30
+ const hit = this.cache.get(platform);
31
+ if (hit)
32
+ return hit;
33
+ const row = this.db.prepare("SELECT json FROM channels WHERE platform = ?").get(platform);
34
+ const config = row
35
+ ? { ...defaultChannelConfig(), ...JSON.parse(row.json) }
36
+ : defaultChannelConfig();
37
+ if (this.secrets) {
38
+ for (const key of ["token", "appToken"]) {
39
+ if (SEALED.test(config[key]))
40
+ config[key] = this.secrets.decrypt(config[key]);
41
+ }
42
+ }
43
+ this.cache.set(platform, config);
44
+ return config;
45
+ }
46
+ /** A detached copy: edit it freely, then hand it back to save(). */
47
+ get(platform) {
48
+ return structuredClone(this.cached(platform));
49
+ }
50
+ save(platform, config) {
51
+ // Clone on the way in too, so the caller keeping its object and mutating
52
+ // it later cannot reach into the cache behind save()'s back.
53
+ this.cache.set(platform, structuredClone(config));
54
+ // The cache holds plaintext (it is what adapters connect with); only the
55
+ // row is sealed.
56
+ const stored = this.secrets ? structuredClone(config) : config;
57
+ if (this.secrets) {
58
+ for (const key of ["token", "appToken"]) {
59
+ if (stored[key])
60
+ stored[key] = this.secrets.encrypt(stored[key]);
61
+ }
62
+ }
63
+ this.db.prepare(`
64
+ INSERT INTO channels(platform, json) VALUES (?, ?)
65
+ ON CONFLICT(platform) DO UPDATE SET json = excluded.json
66
+ `).run(platform, JSON.stringify(stored));
67
+ }
68
+ chat(platform, chatId) {
69
+ return this.get(platform).chats.find((c) => c.id === chatId);
70
+ }
71
+ /**
72
+ * Record a chat the bot just met. Telegram has no "list my chats" API, so
73
+ * discovery is passive. A new chat copies the platform defaults and owns
74
+ * them from then on — the mention and bind gates are what keep it harmless
75
+ * until an operator configures it.
76
+ */
77
+ discoverChat(platform, chat) {
78
+ const config = this.get(platform);
79
+ const known = config.chats.find((c) => c.id === chat.id);
80
+ if (known) {
81
+ if (known.name === chat.name && known.kind === chat.kind)
82
+ return;
83
+ known.name = chat.name || known.name;
84
+ known.kind = chat.kind;
85
+ }
86
+ else {
87
+ config.chats.push({
88
+ id: chat.id,
89
+ name: chat.name,
90
+ kind: chat.kind,
91
+ enabled: true,
92
+ requireMention: config.requireMention,
93
+ requireBind: config.requireBind,
94
+ topicMode: config.topicMode,
95
+ cwd: config.cwd,
96
+ model: config.model,
97
+ thinking: config.thinking,
98
+ });
99
+ }
100
+ this.save(platform, config);
101
+ }
102
+ // Read-only and on the per-message path: no clone, nothing here escapes.
103
+ // An undiscovered chat falls back to the platform seed; in practice the
104
+ // adapter discovers before it asks.
105
+ policy(platform, chatId) {
106
+ const config = this.cached(platform);
107
+ const chat = config.chats.find((c) => c.id === chatId);
108
+ if (chat)
109
+ return chat;
110
+ return {
111
+ enabled: true,
112
+ requireMention: config.requireMention,
113
+ requireBind: config.requireBind,
114
+ topicMode: config.topicMode,
115
+ cwd: config.cwd,
116
+ model: config.model,
117
+ thinking: config.thinking,
118
+ };
119
+ }
120
+ isBound(platform, userId) {
121
+ return this.cached(platform).users.some((u) => u.id === userId);
122
+ }
123
+ /** Single-use, short-lived code an operator reads off the Console. */
124
+ issueBindCode(platform) {
125
+ const config = this.get(platform);
126
+ const code = Math.random().toString(36).slice(2, 8).toUpperCase();
127
+ config.bindCode = { code, expiresAt: Date.now() + BIND_CODE_TTL_MS };
128
+ this.save(platform, config);
129
+ return config.bindCode;
130
+ }
131
+ redeemBindCode(platform, code, user) {
132
+ const config = this.get(platform);
133
+ const pending = config.bindCode;
134
+ if (!pending || pending.expiresAt < Date.now())
135
+ return false;
136
+ if (pending.code !== code.trim().toUpperCase())
137
+ return false;
138
+ config.bindCode = null;
139
+ if (!config.users.some((u) => u.id === user.id)) {
140
+ config.users.push({ id: user.id, name: user.name, boundAt: Date.now() });
141
+ }
142
+ this.save(platform, config);
143
+ return true;
144
+ }
145
+ unbind(platform, userId) {
146
+ const config = this.get(platform);
147
+ config.users = config.users.filter((u) => u.id !== userId);
148
+ this.save(platform, config);
149
+ }
150
+ }
151
+ /**
152
+ * The whole inbound permission policy, platform-blind and total.
153
+ *
154
+ * A group denial is silent by contract: a group where the bot answers "you are
155
+ * not allowed" to every passing message is worse than one that stays quiet.
156
+ * A DM is the exception — two parties, so silence is just confusing — and the
157
+ * adapter answers `not-bound` there.
158
+ */
159
+ export function gate({ policy, isDm, addressed, bound, bindRequest }) {
160
+ if (!policy.enabled)
161
+ return "chat-disabled";
162
+ // A DM has exactly two parties: mention is meaningless, and bind is not
163
+ // optional there — it is the only thing between a stranger and an agent with
164
+ // a shell. `requireMention`/`requireBind` are group settings by construction.
165
+ if (isDm)
166
+ return bound || bindRequest ? "allow" : "not-bound";
167
+ if (policy.requireMention && !addressed)
168
+ return "not-addressed";
169
+ if (policy.requireBind && !bound && !bindRequest)
170
+ return "not-bound";
171
+ return "allow";
172
+ }
@@ -0,0 +1,71 @@
1
+ // What an adapter is allowed to do to a session, beyond handing it a prompt.
2
+ //
3
+ // `Channel` has exactly one inbound path (`onMessage`) and keeps it: an in-chat
4
+ // settings panel needs to read a session's model and change it, which is not a
5
+ // prompt and must not become a second seam. So the channel layer — which owns
6
+ // the router already — hands adapters this narrow, platform-blind interface.
7
+ // Everything here is a thin wrapper over core; no policy lives in it.
8
+ import { parseConversation as parseSlack } from "./slack.js";
9
+ import { parseConversation as parseTelegram } from "./telegram.js";
10
+ export function createControl({ router, factory, conversations, store }) {
11
+ const launchFor = (key) => {
12
+ const platform = key.channelId;
13
+ // Decoding a conversation id back to a chat id is the adapter layer's
14
+ // business, never core's — so each platform's own parser is used here.
15
+ const chatId = platform === "telegram"
16
+ ? parseTelegram(key.conversationId).chatId
17
+ : platform === "slack"
18
+ ? parseSlack(key.conversationId).channel
19
+ : undefined;
20
+ if (chatId === undefined)
21
+ return {};
22
+ const policy = store.policy(platform, chatId);
23
+ return {
24
+ cwd: policy.cwd || undefined,
25
+ model: policy.model ?? undefined,
26
+ thinking: policy.thinking ?? undefined,
27
+ };
28
+ };
29
+ return {
30
+ launchFor,
31
+ knows: (key) => conversations.get(key) !== undefined,
32
+ abort: (key) => router.abortConversation(key),
33
+ async status(key) {
34
+ const session = router.sessionOf(key);
35
+ if (!session)
36
+ return null;
37
+ // AgentSession has no cwd; the factory's listing is where it lives.
38
+ const listed = await factory.list();
39
+ const usage = session.contextUsage;
40
+ return {
41
+ sessionId: session.id,
42
+ cwd: listed.find((s) => s.id === session.id)?.cwd ?? "",
43
+ state: session.state,
44
+ model: session.model,
45
+ thinking: session.thinkingLevel,
46
+ thinkingLevels: session.availableThinkingLevels(),
47
+ tokens: usage?.tokens ?? null,
48
+ contextWindow: usage?.contextWindow ?? null,
49
+ };
50
+ },
51
+ models: () => factory.availableModels(),
52
+ async setModel(key, model) {
53
+ await router.sessionOf(key)?.setModel(model);
54
+ },
55
+ async setThinking(key, level) {
56
+ router.sessionOf(key)?.setThinkingLevel(level);
57
+ },
58
+ async newSession(key, cwd) {
59
+ const launch = launchFor(key);
60
+ const session = await factory.create({
61
+ ...launch,
62
+ cwd: cwd || launch.cwd || process.cwd(),
63
+ });
64
+ // Persist before attaching: a crash in between must not leave the chat
65
+ // pointing at a session nobody recorded.
66
+ conversations.set(key, session.id);
67
+ router.attach(key, session);
68
+ return session.id;
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,65 @@
1
+ // Durable conversation → session routing for IM channels.
2
+ //
3
+ // core/router.ts keeps its map in memory, which is enough for the surfaces
4
+ // whose conversation id already IS a session id (web) or that persist their
5
+ // target themselves (tasks). An IM conversation id is a chat or a topic, so
6
+ // without this table a restart would silently hand every group a brand-new
7
+ // session: the chat history stays on screen while the agent forgets all of
8
+ // it, and the old transcript becomes unreachable.
9
+ //
10
+ // Owned by channels/ rather than core/ so core stays storage-agnostic — the
11
+ // same split tasks/ already uses for its target session ids.
12
+ import { pierDb } from "../db.js";
13
+ export class ConversationStore {
14
+ db;
15
+ constructor(db = pierDb()) {
16
+ this.db = db;
17
+ }
18
+ get(key) {
19
+ const row = this.db.prepare(`
20
+ SELECT session_id FROM conversations WHERE channel_id = ? AND conversation_id = ?
21
+ `).get(key.channelId, key.conversationId);
22
+ return row?.session_id;
23
+ }
24
+ set(key, sessionId) {
25
+ this.db.prepare(`
26
+ INSERT INTO conversations(channel_id, conversation_id, session_id, updated_at)
27
+ VALUES (?, ?, ?, ?)
28
+ ON CONFLICT(channel_id, conversation_id) DO UPDATE SET
29
+ session_id = excluded.session_id, updated_at = excluded.updated_at
30
+ `).run(key.channelId, key.conversationId, sessionId, Date.now());
31
+ }
32
+ /** Drop a mapping whose session Pi no longer has, so the next message
33
+ * starts a fresh one instead of failing forever. */
34
+ forget(key) {
35
+ this.db.prepare(`
36
+ DELETE FROM conversations WHERE channel_id = ? AND conversation_id = ?
37
+ `).run(key.channelId, key.conversationId);
38
+ }
39
+ }
40
+ /**
41
+ * The IM half of the router's session factory: reuse this conversation's
42
+ * session across restarts, and only create when there is nothing to resume.
43
+ * Wired in main.ts, so neither core nor an adapter learns where the mapping
44
+ * lives.
45
+ */
46
+ export function resolveConversation(store, factory, launchFor, onStale) {
47
+ return async (key) => {
48
+ const known = store.get(key);
49
+ if (known) {
50
+ try {
51
+ return await factory.resume(known);
52
+ }
53
+ catch (err) {
54
+ // Pi never persisted it (a first turn that never landed) or the
55
+ // transcript was deleted. Re-route rather than fail every message.
56
+ onStale?.(`${key.channelId}:${key.conversationId} lost session ${known}: ${String(err)}`);
57
+ store.forget(key);
58
+ }
59
+ }
60
+ const launch = launchFor(key);
61
+ const session = await factory.create({ ...launch, cwd: launch.cwd ?? process.cwd() });
62
+ store.set(key, session.id);
63
+ return session;
64
+ };
65
+ }
@@ -0,0 +1,63 @@
1
+ // The two inbound decisions every adapter makes identically: may this message
2
+ // through, and may this stranger be told how to bind.
3
+ //
4
+ // Both were written twice before landing here, and both have a rule that is
5
+ // easy to get subtly wrong on the second copy — a drop must always name its
6
+ // verdict, and the throttle map must prune rather than grow. Keeping them in
7
+ // one place is what makes "log every drop" and "bound every map fed by
8
+ // strangers" true of every platform instead of one.
9
+ import { gate } from "./config.js";
10
+ /** How often one unbound DM sender may be told how to bind. */
11
+ const BIND_HINT_EVERY_MS = 10 * 60_000;
12
+ export class Gatekeeper {
13
+ store;
14
+ platform;
15
+ log;
16
+ noun;
17
+ /** Last time each unbound DM sender was told how to bind. */
18
+ hints = new Map();
19
+ constructor(store, platform, log,
20
+ /** What the platform calls a conversation, for the drop log. */
21
+ noun = "chat") {
22
+ this.store = store;
23
+ this.platform = platform;
24
+ this.log = log;
25
+ this.noun = noun;
26
+ }
27
+ /**
28
+ * The whole inbound permission decision, plus the log line every drop owes.
29
+ * A silently skipped branch is indistinguishable from a bug, so the verdict
30
+ * is always named.
31
+ */
32
+ admit(what, chatId, req) {
33
+ const verdict = gate({
34
+ policy: this.store.policy(this.platform, chatId),
35
+ isDm: req.isDm,
36
+ addressed: req.addressed,
37
+ bound: this.store.isBound(this.platform, req.userId),
38
+ bindRequest: req.bindRequest ?? false,
39
+ });
40
+ if (verdict === "allow")
41
+ return true;
42
+ this.log(`dropped ${what} in ${this.noun} ${chatId}: ${verdict}`);
43
+ return false;
44
+ }
45
+ /**
46
+ * May this sender be told how to bind? Groups stay silent by contract, but a
47
+ * DM that swallows every message looks broken rather than locked — and a bot
48
+ * that answers every one is an echo amplifier.
49
+ *
50
+ * Anyone can DM a bot, so this map is fed by strangers: expired entries are
51
+ * dropped on the way past instead of keeping one per sender forever.
52
+ */
53
+ mayHint(userId, now = Date.now()) {
54
+ if (now - (this.hints.get(userId) ?? 0) < BIND_HINT_EVERY_MS)
55
+ return false;
56
+ for (const [id, at] of this.hints) {
57
+ if (now - at >= BIND_HINT_EVERY_MS)
58
+ this.hints.delete(id);
59
+ }
60
+ this.hints.set(userId, now);
61
+ return true;
62
+ }
63
+ }
@@ -0,0 +1,233 @@
1
+ // The in-chat settings panel, minus the platform.
2
+ //
3
+ // One message edited in place — a new message per tap would bury the chat.
4
+ // Every payload is namespaced `cfg:` and consumed here, so a panel tap can
5
+ // never be mistaken for one of the agent's next-step buttons (whose payload is
6
+ // the label itself) and never reaches the agent. Choices travel as an index
7
+ // rather than a name: Telegram's callback data caps at 64 bytes, and an index
8
+ // cannot be invalidated by a label someone rewrote.
9
+ //
10
+ // What is left to a platform is markup, how its one message is sent, edited
11
+ // and deleted, and how it asks for a single typed answer — Slack has modals,
12
+ // Telegram has a forced reply. Everything above that is the same panel, so it
13
+ // is written once here.
14
+ import { compact, thinkingLabel } from "../core/reply.js";
15
+ export const PANEL_PREFIX = "cfg:";
16
+ const MODELS_PER_PAGE = 8;
17
+ const onOff = (v) => (v ? "on" : "off");
18
+ const btn = (label, action) => ({ label, action });
19
+ export class ChatPanel {
20
+ deps;
21
+ panels = new Map();
22
+ constructor(deps) {
23
+ this.deps = deps;
24
+ }
25
+ /** Gates this platform has and the other does not. */
26
+ gateExtras(_chat, _policy) {
27
+ return "";
28
+ }
29
+ code(text) {
30
+ return `${this.fence[0]}${this.esc(text)}${this.fence[1]}`;
31
+ }
32
+ remember(key, state) {
33
+ this.panels.set(key.conversationId, state);
34
+ }
35
+ state(key) {
36
+ return this.panels.get(key.conversationId);
37
+ }
38
+ // --- rendering ---------------------------------------------------------------
39
+ /** The panel proper: this session, this chat, and what can be done to them. */
40
+ async view(key, chatId) {
41
+ const status = await this.deps.control.status(key);
42
+ return {
43
+ groups: [
44
+ {
45
+ title: "Session",
46
+ lines: status
47
+ ? this.sessionLines(status)
48
+ : ["None yet — send a message to start one."],
49
+ },
50
+ // Slack calls it a channel, Telegram a chat; the panel says what the
51
+ // person reading it says.
52
+ {
53
+ title: this.platform === "slack" ? "Channel" : "Chat",
54
+ lines: this.chatLines(chatId),
55
+ },
56
+ ],
57
+ rows: [
58
+ [btn("Model", "models:0"), btn("Reasoning", "think")],
59
+ [btn("New session", "new"), btn("New session in…", "cwd")],
60
+ [
61
+ ...(status?.state === "streaming" ? [btn("⏹ Stop", "stop")] : []),
62
+ btn("Close", "close"),
63
+ ],
64
+ ],
65
+ };
66
+ }
67
+ sessionLines(status) {
68
+ const usage = status.tokens !== null && status.contextWindow
69
+ ? `${compact(status.tokens)}/${compact(status.contextWindow)} tok`
70
+ : "not measured yet";
71
+ return [
72
+ `${this.code(status.sessionId.slice(0, 8))} · ${status.state}`,
73
+ `Directory: ${this.code(status.cwd || "?")}`,
74
+ `Model: ${status.model ? this.esc(status.model.id) : "Pi default"} · ${thinkingLabel(status.thinking)}`,
75
+ `Context: ${usage}`,
76
+ ];
77
+ }
78
+ chatLines(chatId) {
79
+ const chat = this.deps.store.chat(this.platform, chatId);
80
+ if (!chat)
81
+ return [this.code(chatId)];
82
+ const policy = this.deps.store.policy(this.platform, chatId);
83
+ // A DM is bind-only by construction, so the group knobs would be a lie.
84
+ const gates = chat.kind === "dm"
85
+ ? "bound users only"
86
+ : `mention ${onOff(policy.requireMention)} · bind ${onOff(policy.requireBind)}${this.gateExtras(chat, policy)}`;
87
+ return [`${this.esc(chat.name || chatId)} · ${chat.kind} · ${this.code(chatId)}`, gates];
88
+ }
89
+ /** Redraw the panel this conversation owns. */
90
+ async refresh(key, note) {
91
+ const state = this.state(key);
92
+ if (!state)
93
+ return;
94
+ await this.draw(state, await this.view(key, state.chatId), note);
95
+ }
96
+ // --- actions -----------------------------------------------------------------
97
+ /**
98
+ * Handle a `cfg:` payload. Returns false when it is not ours, so the caller
99
+ * can treat it as one of the agent's next-step labels instead.
100
+ *
101
+ * `reopen` is how a panel left behind by a previous process recovers: its
102
+ * state died with that process, and redrawing from the platform's own copy
103
+ * of the message is the only honest answer. It costs one tap.
104
+ */
105
+ async dispatch(key, payload, ctx, reopen) {
106
+ if (!payload.startsWith(PANEL_PREFIX))
107
+ return false;
108
+ const [action = "", arg = ""] = payload.slice(PANEL_PREFIX.length).split(":");
109
+ const state = this.state(key);
110
+ if (!state && action !== "close") {
111
+ await reopen();
112
+ return true;
113
+ }
114
+ switch (action) {
115
+ case "close":
116
+ this.panels.delete(key.conversationId);
117
+ if (state)
118
+ await this.erase(state);
119
+ return true;
120
+ case "panel":
121
+ await this.refresh(key);
122
+ return true;
123
+ case "models":
124
+ await this.showModels(key, Number(arg) || 0);
125
+ return true;
126
+ case "model":
127
+ await this.pickModel(key, Number(arg));
128
+ return true;
129
+ case "think":
130
+ if (arg)
131
+ await this.pickThinking(key, arg);
132
+ else
133
+ await this.showThinking(key);
134
+ return true;
135
+ case "new": {
136
+ const id = await this.deps.control.newSession(key);
137
+ await this.refresh(key, `Started session ${id.slice(0, 8)}.`);
138
+ return true;
139
+ }
140
+ case "cwd":
141
+ await this.promptCwd(key, state, ctx);
142
+ return true;
143
+ case "stop":
144
+ await this.deps.control.abort(key);
145
+ await this.refresh(key, "Stop requested.");
146
+ return true;
147
+ default:
148
+ this.deps.log(`unknown panel action: ${action}`);
149
+ return true;
150
+ }
151
+ }
152
+ async showModels(key, page) {
153
+ const state = this.state(key);
154
+ if (!state)
155
+ return;
156
+ state.models = await this.deps.control.models().catch(() => []);
157
+ const status = await this.deps.control.status(key);
158
+ const pages = Math.max(1, Math.ceil(state.models.length / MODELS_PER_PAGE));
159
+ const at = Math.min(Math.max(page, 0), pages - 1);
160
+ const slice = state.models.slice(at * MODELS_PER_PAGE, (at + 1) * MODELS_PER_PAGE);
161
+ await this.draw(state, {
162
+ groups: [{
163
+ title: "Model",
164
+ suffix: ` · page ${at + 1}/${pages}`,
165
+ lines: state.models.length ? [] : ["No models with configured auth."],
166
+ }],
167
+ picks: slice.map((model, i) => {
168
+ const current = status?.model?.provider === model.provider && status.model.id === model.id;
169
+ return btn(`${current ? "✓ " : ""}${model.id}`, `model:${at * MODELS_PER_PAGE + i}`);
170
+ }),
171
+ rows: [[
172
+ ...(at > 0 ? [btn("‹ Prev", `models:${at - 1}`)] : []),
173
+ ...(at < pages - 1 ? [btn("Next ›", `models:${at + 1}`)] : []),
174
+ btn("‹ Back", "panel"),
175
+ ]],
176
+ });
177
+ }
178
+ async pickModel(key, index) {
179
+ const model = this.state(key)?.models[index];
180
+ if (!model)
181
+ return this.refresh(key, "That model is no longer listed.");
182
+ try {
183
+ await this.deps.control.setModel(key, model);
184
+ await this.refresh(key, `Model set to ${model.id}.`);
185
+ }
186
+ catch (err) {
187
+ await this.refresh(key, `Could not set that model: ${String(err)}`);
188
+ }
189
+ }
190
+ async showThinking(key) {
191
+ const state = this.state(key);
192
+ if (!state)
193
+ return;
194
+ const status = await this.deps.control.status(key);
195
+ const levels = status?.thinkingLevels ?? [];
196
+ await this.draw(state, {
197
+ groups: [{
198
+ title: "Reasoning",
199
+ lines: levels.length ? [] : ["This model has no levels."],
200
+ }],
201
+ picks: levels.map((level) => btn(`${status?.thinking === level ? "✓ " : ""}${thinkingLabel(level)}`, `think:${level}`)),
202
+ rows: [[btn("‹ Back", "panel")]],
203
+ });
204
+ }
205
+ async pickThinking(key, level) {
206
+ await this.deps.control.setThinking(key, level);
207
+ await this.refresh(key, `Reasoning set to ${thinkingLabel(level)}.`);
208
+ }
209
+ /**
210
+ * The one action that is not reversible in place: Pi fixes cwd at session
211
+ * creation, so "change the working directory" *is* "start a new session
212
+ * there". Shared because both platforms have to say so and handle the same
213
+ * two failures.
214
+ */
215
+ async startSessionIn(key, path) {
216
+ if (!path.startsWith("/")) {
217
+ // A silent no-op here reads as "the button is broken".
218
+ const error = "That is not an absolute path — nothing changed.";
219
+ await this.refresh(key, error);
220
+ return { error };
221
+ }
222
+ try {
223
+ const id = await this.deps.control.newSession(key, path);
224
+ await this.refresh(key, `Started session ${id.slice(0, 8)} in ${path}.`);
225
+ return { id };
226
+ }
227
+ catch (err) {
228
+ const error = `Could not start a session there: ${String(err)}`;
229
+ await this.refresh(key, error);
230
+ return { error };
231
+ }
232
+ }
233
+ }