@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,23 +1,118 @@
1
1
  // The in-chat settings panel, minus the platform: one message edited in place,
2
2
  // every payload namespaced `cfg:` so a tap never reaches the agent. Choices
3
- // travel as an index: Telegram's callback data caps at 64 bytes.
4
- import { compact, thinkingLabel } from "../core/reply.js";
3
+ // travel as an index: platform callback payloads are small and opaque. A thread
4
+ // without a session shows a draft — cwd, model, reasoning, a pending question —
5
+ // that rides every button's value, so the card is the store and a restart
6
+ // loses nothing.
7
+ import { sessionLabel, splitSpeaker } from "../core/identity.js";
8
+ import { splitInboundFiles } from "../core/inbound-file.js";
9
+ import { compact, cut, splitReply, thinkingLabel } from "../core/reply.js";
10
+ import { isThinkingLevel, } from "../core/types.js";
11
+ import { HAS_SESSION, NO_SESSION } from "./control.js";
12
+ import { HandoffError } from "./handoff.js";
5
13
  export const PANEL_PREFIX = "cfg:";
6
- const MODELS_PER_PAGE = 8;
7
- export const CWD_TAIL = "A new session starts there; the current one stays in its own directory.";
14
+ const PER_PAGE = 8;
15
+ /** The picker's reach: five pages of the newest unbound sessions. */
16
+ const SESSIONS_LISTED = 40;
17
+ const TITLE_CHARS = 40;
18
+ const QUESTION_CHARS = 80;
19
+ /** Exchanges excerpted under the session, and the width of one line: four of
20
+ * these stay far inside Slack's 3000-character section. */
21
+ const RECENT_EXCHANGES = 2;
22
+ const EXCERPT_CHARS = 150;
23
+ /** Characters of the serialized draft — what a button actually carries. Slack
24
+ * caps a value at 2000 and Lark a card at 30 KB; the rest of the 2000 is room
25
+ * for the cwd, model and reasoning a pick adds after the question. */
26
+ const DRAFT_CHARS = 1700;
27
+ /** Pages whose every pick Pi's fixed-at-creation cwd or the row would refuse:
28
+ * reachable only by tapping a card that gained a session since it was drawn. */
29
+ const DRAFT_ONLY = new Set(["cwd", "cwdtype", "sessions", "session"]);
30
+ export const CWD_DRAFT_TAIL = "Start creates the session there.";
8
31
  export const CWD_PLACEHOLDER = "/path/to/project";
9
- const onOff = (v) => (v ? "on" : "off");
32
+ export const QUESTION_TOO_LONG = "Your question is too long to hold — send it again after Start.";
10
33
  const btn = (label, action) => ({ label, action });
34
+ /** A button label is the last two segments; the numbered line above has the whole path. */
35
+ const shortDir = (path) => {
36
+ const parts = path.split("/").filter(Boolean);
37
+ return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : path;
38
+ };
39
+ /** One transcript line as a reader sees it: what was said, minus what was
40
+ * written for the model (the speaker header, attachment markers, the
41
+ * next-step block, a silent turn's reason). */
42
+ const excerpt = (text, role) => {
43
+ const said = role === "user" ? splitInboundFiles(splitSpeaker(text).text).text : splitReply(text).text;
44
+ return cut(said.replace(/\s+/g, " ").trim(), EXCERPT_CHARS);
45
+ };
46
+ const shortTitle = (s) => cut(sessionLabel(s), TITLE_CHARS);
47
+ /** Compact age, as the web sidebar spells it ("now", "12m", "3h", "2d"). */
48
+ const age = (ts, now) => {
49
+ const mins = Math.round((now - ts) / 60_000);
50
+ if (mins < 1)
51
+ return "now";
52
+ if (mins < 60)
53
+ return `${String(mins)}m`;
54
+ if (mins < 1440)
55
+ return `${String(Math.round(mins / 60))}h`;
56
+ return `${String(Math.round(mins / 1440))}d`;
57
+ };
58
+ /** Page `page` of `items`, clamped: a stale Next past the end lands on the last page. */
59
+ const paged = (items, page) => {
60
+ const pages = Math.max(1, Math.ceil(items.length / PER_PAGE));
61
+ const at = Math.min(Math.max(page, 0), pages - 1);
62
+ const from = at * PER_PAGE;
63
+ return { at, pages, slice: items.slice(from, from + PER_PAGE), from };
64
+ };
65
+ const pager = (action, at, pages) => [
66
+ ...(at > 0 ? [btn("‹ Prev", `${action}:${String(at - 1)}`)] : []),
67
+ ...(at < pages - 1 ? [btn("Next ›", `${action}:${String(at + 1)}`)] : []),
68
+ btn("‹ Back", "panel"),
69
+ ];
70
+ /** A refusal's sentence (a control refusal, a HandoffError) is the whole
71
+ * answer; any other failure names what was attempted. */
72
+ const failed = (what, err) => err instanceof HandoffError || (err instanceof Error && (err.message === NO_SESSION || err.message === HAS_SESSION))
73
+ ? err.message
74
+ : `${what}: ${String(err)}`;
75
+ /** `undefined` when there is nothing to carry, so a with-session panel's buttons stay bare. */
76
+ export const serializeDraft = (draft) => Object.keys(draft).length ? JSON.stringify(draft) : undefined;
77
+ /** The question, or the fact that it was too long — measured as the button
78
+ * carries it, escapes included, not as the bytes the user typed. */
79
+ export const holdQuestion = (q) => {
80
+ if (!q)
81
+ return {};
82
+ return JSON.stringify({ q }).length > DRAFT_CHARS ? { dropped: true } : { q };
83
+ };
84
+ /** The held question as the card shows it: one line, its attachments counted, not listed. */
85
+ const heldLine = (q) => {
86
+ const { text, paths } = splitInboundFiles(q);
87
+ const files = paths.length ? ` · ${paths.length} file${paths.length > 1 ? "s" : ""}` : "";
88
+ return `${text.replace(/\s+/g, " ").trim()}${files}`;
89
+ };
90
+ /** A platform echoed this; only the fields the draft knows, each type-checked. */
91
+ export const readDraft = (raw) => {
92
+ const draft = {};
93
+ if (!raw || typeof raw !== "object")
94
+ return draft;
95
+ const v = raw;
96
+ if (typeof v.cwd === "string")
97
+ draft.cwd = v.cwd;
98
+ const model = v.model;
99
+ if (model && typeof model.provider === "string" && typeof model.id === "string") {
100
+ draft.model = { provider: model.provider, id: model.id };
101
+ }
102
+ if (isThinkingLevel(v.thinking))
103
+ draft.thinking = v.thinking;
104
+ if (typeof v.q === "string" && v.q)
105
+ draft.q = v.q;
106
+ if (v.dropped === true)
107
+ draft.dropped = true;
108
+ return draft;
109
+ };
11
110
  export class ChatPanel {
12
111
  deps;
13
112
  panels = new Map();
14
113
  constructor(deps) {
15
114
  this.deps = deps;
16
115
  }
17
- /** Gates this platform has and the other does not. */
18
- gateExtras(_chat, _policy) {
19
- return "";
20
- }
21
116
  code(text) {
22
117
  return `${this.fence[0]}${this.esc(text)}${this.fence[1]}`;
23
118
  }
@@ -28,190 +123,304 @@ export class ChatPanel {
28
123
  return this.panels.get(key.conversationId);
29
124
  }
30
125
  // --- rendering ---------------------------------------------------------------
31
- async view(key, chatId) {
126
+ async view(key, state) {
32
127
  const status = await this.deps.control.status(key);
128
+ if (!status)
129
+ return this.draftView(key, state.draft);
130
+ // The conversation is right above the card: no excerpt, and no way to a
131
+ // second session — that is a new thread.
132
+ return {
133
+ groups: [{ title: "Session", lines: this.sessionLines(status) }],
134
+ rows: [[
135
+ btn("Model & reasoning", "pins:0"),
136
+ ...(status.state === "streaming" ? [btn("⏹ Stop", "stop")] : []),
137
+ ]],
138
+ };
139
+ }
140
+ draftView(key, draft) {
141
+ const { cwd, model, thinking } = this.effective(key, draft);
142
+ const question = draft.q ? [`▸ ${this.esc(cut(heldLine(draft.q), QUESTION_CHARS))}`]
143
+ : draft.dropped ? [QUESTION_TOO_LONG] : [];
33
144
  return {
34
- groups: [
35
- {
145
+ groups: [{
36
146
  title: "Session",
37
- lines: status
38
- ? this.sessionLines(status)
39
- : ["None yet — send a message to start one."],
40
- },
41
- {
42
- title: this.platform === "slack" ? "Channel" : "Chat",
43
- lines: this.chatLines(chatId),
44
- },
45
- ],
147
+ suffix: draft.cwd || draft.model || draft.thinking ? undefined : " · chat defaults",
148
+ lines: [
149
+ `Starts in ${cwd ? this.code(cwd) : "Pier's directory"} · ${model ? this.esc(model.id) : "Pi default"} · ${thinking ? `reasoning ${thinking}` : "default reasoning"}`,
150
+ ...question,
151
+ ],
152
+ }],
46
153
  rows: [
47
- [btn("Model", "models:0"), btn("Reasoning", "think")],
48
- [btn("New session", "new"), btn("New session in…", "cwd")],
49
- [
50
- ...(status?.state === "streaming" ? [btn("⏹ Stop", "stop")] : []),
51
- btn("Close", "close"),
52
- ],
154
+ [btn("Model & reasoning", "pins:0"), btn("Directory…", "cwd"), btn("Continue web session…", "sessions:0")],
155
+ [btn("Start", "start"), btn("Close", "close")],
53
156
  ],
54
157
  };
55
158
  }
159
+ /** What Start would create with: the draft over the chat defaults. */
160
+ effective(key, draft) {
161
+ const launch = this.deps.control.launchFor(key);
162
+ return {
163
+ cwd: draft.cwd ?? launch.cwd,
164
+ model: draft.model ?? launch.model,
165
+ thinking: draft.thinking ?? launch.thinking,
166
+ };
167
+ }
56
168
  sessionLines(status) {
57
169
  const usage = status.tokens !== null && status.contextWindow
58
170
  ? `${compact(status.tokens)}/${compact(status.contextWindow)} tok`
59
171
  : "not measured yet";
172
+ const fresh = status.empty && status.state === "idle";
60
173
  return [
61
- `${this.code(status.sessionId.slice(0, 8))} · ${status.state}`,
174
+ `${this.code(status.sessionId.slice(0, 8))} · ${fresh ? "created, no message yet" : status.state}`,
62
175
  `Directory: ${this.code(status.cwd || "?")}`,
63
176
  `Model: ${status.model ? this.esc(status.model.id) : "Pi default"} · ${thinkingLabel(status.thinking)}`,
64
- `Context: ${usage}`,
177
+ `Context: ${status.empty ? "empty — the first message you send runs here." : usage}`,
65
178
  ];
66
179
  }
67
- chatLines(chatId) {
68
- const chat = this.deps.store.chat(this.platform, chatId);
69
- if (!chat)
70
- return [this.code(chatId)];
71
- const policy = this.deps.store.policy(this.platform, chatId);
72
- // A DM is bind-only by construction, so the group knobs would be a lie.
73
- const gates = chat.kind === "dm"
74
- ? "bound users only"
75
- : `mention ${onOff(policy.requireMention)} · bind ${onOff(policy.requireBind)}${this.gateExtras(chat, policy)}`;
76
- return [`${this.esc(chat.name || chatId)} · ${chat.kind} · ${this.code(chatId)}`, gates];
180
+ /** A listing that failed is one line on the card, logged — never an empty
181
+ * list that reads as "nothing here". */
182
+ async listed(read, what) {
183
+ try {
184
+ return { items: await read };
185
+ }
186
+ catch (err) {
187
+ const unavailable = `${what}: ${String(err)}`;
188
+ this.deps.log(unavailable);
189
+ return { items: [], unavailable };
190
+ }
191
+ }
192
+ /** An excerpt, not a summary: the last exchanges are what makes a session
193
+ * one has been away from recognisable on a phone. */
194
+ async recentGroups(key) {
195
+ const { items: exchanges, unavailable } = await this.listed(this.deps.control.recent(key, RECENT_EXCHANGES), "Could not read the transcript");
196
+ if (unavailable)
197
+ return [{ title: "Recent", lines: [unavailable] }];
198
+ const lines = exchanges.flatMap(({ user, assistant }) => {
199
+ const reply = assistant === undefined ? "" : excerpt(assistant, "assistant");
200
+ return [
201
+ `▸ ${this.esc(excerpt(user, "user"))}`,
202
+ ...(reply ? [`◂ ${this.esc(reply)}`] : []),
203
+ ];
204
+ });
205
+ return lines.length ? [{ title: "Recent", lines }] : [];
77
206
  }
78
207
  async refresh(key, note) {
79
208
  const state = this.state(key);
80
209
  if (!state)
81
210
  return;
82
- await this.draw(state, await this.view(key, state.chatId), note);
211
+ await this.draw(state, await this.view(key, state), note);
212
+ }
213
+ /** The card's last draw, once an operation completed: the session as it now
214
+ * is, no button. The panel is released with it, so nothing can tap it again
215
+ * and the card stays as a record; another @bot opens a fresh one. `recent`
216
+ * excerpts the transcript — what tells a phone reader which conversation
217
+ * was continued. */
218
+ async settle(key, state, note, recent) {
219
+ const status = await this.deps.control.status(key);
220
+ if (!status)
221
+ return this.refresh(key, note);
222
+ await this.draw(state, {
223
+ groups: [{ title: "Session", lines: this.sessionLines(status) }, ...recent ? await this.recentGroups(key) : []],
224
+ rows: [],
225
+ }, note);
226
+ this.panels.delete(key.conversationId);
83
227
  }
84
228
  // --- actions -----------------------------------------------------------------
85
- /** Returns false when the payload is not ours. `reopen` recovers a panel
86
- * whose state died with a previous process; it costs one tap. */
87
- async dispatch(key, payload, ctx, reopen) {
229
+ /** Returns false when the payload is not ours. `recover` rebuilds the state
230
+ * of a panel whose process died — from the tapped button's value, so the
231
+ * tap is honoured on the card the user is looking at. `run` hands Start's
232
+ * pending question to the router as the tapper's message. */
233
+ async dispatch(key, payload, ctx, recover, run) {
88
234
  if (!payload.startsWith(PANEL_PREFIX))
89
235
  return false;
90
236
  const [action = "", arg = ""] = payload.slice(PANEL_PREFIX.length).split(":");
91
- const state = this.state(key);
92
- if (!state && action !== "close") {
93
- await reopen();
237
+ let state = this.state(key);
238
+ if (!state) {
239
+ state = recover();
240
+ this.remember(key, state);
241
+ }
242
+ if (DRAFT_ONLY.has(action) && this.deps.control.knows(key)) {
243
+ await this.refresh(key, HAS_SESSION);
94
244
  return true;
95
245
  }
96
246
  switch (action) {
97
247
  case "close":
98
248
  this.panels.delete(key.conversationId);
99
- if (state)
100
- await this.erase(state);
249
+ await this.erase(state);
101
250
  return true;
102
251
  case "panel":
103
252
  await this.refresh(key);
104
253
  return true;
105
- case "models":
106
- await this.showModels(key, Number(arg) || 0);
254
+ case "pins":
255
+ await this.showPins(key, Number(arg) || 0);
107
256
  return true;
108
- case "model":
109
- await this.pickModel(key, Number(arg));
257
+ case "pin":
258
+ await this.pickPin(key, Number(arg));
110
259
  return true;
111
- case "think":
112
- if (arg)
113
- await this.pickThinking(key, arg);
114
- else
115
- await this.showThinking(key);
260
+ case "sessions":
261
+ await this.showSessions(key, Number(arg) || 0);
116
262
  return true;
117
- case "new": {
118
- const id = await this.deps.control.newSession(key);
119
- await this.refresh(key, `Started session ${id.slice(0, 8)}.`);
263
+ case "session":
264
+ await this.pickSession(key, Number(arg));
120
265
  return true;
121
- }
122
266
  case "cwd":
267
+ if (arg)
268
+ await this.pickDir(key, Number(arg));
269
+ else
270
+ await this.showDirs(key);
271
+ return true;
272
+ case "cwdtype":
123
273
  await this.promptCwd(key, state, ctx);
124
274
  return true;
275
+ case "start":
276
+ await this.start(key, state, run);
277
+ return true;
125
278
  case "stop":
126
279
  await this.deps.control.abort(key);
127
- await this.refresh(key, "Stop requested.");
280
+ await this.settle(key, state, "Stop requested.", false);
128
281
  return true;
129
282
  default:
130
283
  this.deps.log(`unknown panel action: ${action}`);
131
284
  return true;
132
285
  }
133
286
  }
134
- async showModels(key, page) {
287
+ /** The operator's pins, model and reasoning in one pick: the catalog is the
288
+ * Console's business, and a chat is the wrong place to browse it. */
289
+ async showPins(key, page) {
135
290
  const state = this.state(key);
136
291
  if (!state)
137
292
  return;
138
- // An empty list and a catalog that could not be read are two different
139
- // answers; the second must not draw as the first.
140
- let unavailable;
141
- state.models = await this.deps.control.models().catch((err) => {
142
- unavailable = `Could not list models: ${String(err)}`;
143
- this.deps.log(unavailable);
144
- return [];
145
- });
293
+ const pins = this.deps.control.pins();
146
294
  const status = await this.deps.control.status(key);
147
- const pages = Math.max(1, Math.ceil(state.models.length / MODELS_PER_PAGE));
148
- const at = Math.min(Math.max(page, 0), pages - 1);
149
- const slice = state.models.slice(at * MODELS_PER_PAGE, (at + 1) * MODELS_PER_PAGE);
295
+ const current = status ?? this.effective(key, state.draft);
296
+ const { at, pages, slice, from } = paged(pins, page);
150
297
  await this.draw(state, {
151
298
  groups: [{
152
- title: "Model",
299
+ title: "Model & reasoning",
153
300
  suffix: ` · page ${at + 1}/${pages}`,
154
- lines: state.models.length ? [] : [unavailable ?? "No models with configured auth."],
301
+ lines: slice.length
302
+ ? slice.map((pin, i) => {
303
+ const ticked = current.model?.provider === pin.provider && current.model.id === pin.id
304
+ && current.thinking === pin.thinking;
305
+ return `${String(from + i + 1)}. ${ticked ? "✓ " : ""}${this.esc(pin.id)} · ${thinkingLabel(pin.thinking)}${pin.note ? ` — ${this.esc(pin.note)}` : ""}`;
306
+ })
307
+ : ["No pinned models — Settings → Models → Model menu."],
155
308
  }],
156
- picks: slice.map((model, i) => {
157
- const current = status?.model?.provider === model.provider && status.model.id === model.id;
158
- return btn(`${current ? "✓ " : ""}${model.id}`, `model:${at * MODELS_PER_PAGE + i}`);
159
- }),
160
- rows: [[
161
- ...(at > 0 ? [btn("‹ Prev", `models:${at - 1}`)] : []),
162
- ...(at < pages - 1 ? [btn("Next ›", `models:${at + 1}`)] : []),
163
- btn("‹ Back", "panel"),
164
- ]],
309
+ // The note stays on the line: a platform truncates a button label.
310
+ picks: slice.map((pin, i) => btn(`${String(from + i + 1)} ${pin.id}`, `pin:${String(from + i)}`)),
311
+ rows: [pager("pins", at, pages)],
165
312
  });
166
313
  }
167
- async pickModel(key, index) {
168
- const model = this.state(key)?.models[index];
169
- if (!model)
314
+ async showSessions(key, page) {
315
+ const state = this.state(key);
316
+ if (!state)
317
+ return;
318
+ const { items, unavailable } = await this.listed(this.deps.handoff.unbound(SESSIONS_LISTED), "Could not list sessions");
319
+ state.sessions = items;
320
+ const { at, pages, slice, from } = paged(state.sessions, page);
321
+ const now = Date.now();
322
+ await this.draw(state, {
323
+ groups: [{
324
+ title: "Continue web session",
325
+ suffix: ` · page ${at + 1}/${pages}`,
326
+ lines: slice.length
327
+ ? slice.map((s, i) => `${String(from + i + 1)}. ${this.esc(shortTitle(s))} · ${this.code(s.cwd.split("/").filter(Boolean).at(-1) ?? s.cwd)} · ${age(s.modified ?? s.createdAt, now)}`)
328
+ : [unavailable ?? "No unbound sessions."],
329
+ }],
330
+ picks: slice.map((s, i) => btn(`${String(from + i + 1)} ${shortTitle(s)}`, `session:${String(from + i)}`)),
331
+ rows: [pager("sessions", at, pages)],
332
+ });
333
+ }
334
+ async pickSession(key, index) {
335
+ const state = this.state(key);
336
+ const session = state?.sessions[index];
337
+ if (!state || !session)
338
+ return this.refresh(key, "That session is no longer listed.");
339
+ try {
340
+ await this.deps.handoff.continueHere(key, session.id);
341
+ // The bound session has its own settings; the draft is spent.
342
+ state.draft = {};
343
+ await this.settle(key, state, `Continuing session ${session.id.slice(0, 8)} — reply in this thread.`, true);
344
+ }
345
+ catch (err) {
346
+ await this.refresh(key, failed("Could not continue that session", err));
347
+ }
348
+ }
349
+ async pickPin(key, index) {
350
+ const state = this.state(key);
351
+ const pin = this.deps.control.pins()[index];
352
+ if (!state || !pin)
170
353
  return this.refresh(key, "That model is no longer listed.");
354
+ const model = { provider: pin.provider, id: pin.id };
355
+ if (!this.deps.control.knows(key)) {
356
+ state.draft = { ...state.draft, model, thinking: pin.thinking };
357
+ return this.refresh(key);
358
+ }
171
359
  try {
172
360
  await this.deps.control.setModel(key, model);
173
- await this.refresh(key, `Model set to ${model.id}.`);
361
+ await this.deps.control.setThinking(key, pin.thinking);
362
+ await this.settle(key, state, `Model set to ${pin.id} · ${thinkingLabel(pin.thinking)}.`, false);
174
363
  }
175
364
  catch (err) {
176
- await this.refresh(key, `Could not set that model: ${String(err)}`);
365
+ await this.refresh(key, failed("Could not set that model", err));
177
366
  }
178
367
  }
179
- async showThinking(key) {
368
+ async showDirs(key) {
180
369
  const state = this.state(key);
181
370
  if (!state)
182
371
  return;
183
- const status = await this.deps.control.status(key);
184
- const levels = status?.thinkingLevels ?? [];
372
+ const { items, unavailable } = await this.listed(this.deps.control.recentDirs(key), "Could not list recent directories");
373
+ state.dirs = items;
185
374
  await this.draw(state, {
186
375
  groups: [{
187
- title: "Reasoning",
188
- lines: levels.length ? [] : ["This model has no levels."],
376
+ title: "Directory",
377
+ lines: state.dirs.length
378
+ ? state.dirs.map((dir, i) => `${String(i + 1)}. ${this.code(dir)}`)
379
+ : [unavailable ?? "No sessions yet — type a path."],
189
380
  }],
190
- picks: levels.map((level) => btn(`${status?.thinking === level ? "✓ " : ""}${thinkingLabel(level)}`, `think:${level}`)),
191
- rows: [[btn("‹ Back", "panel")]],
381
+ picks: state.dirs.map((dir, i) => btn(`${String(i + 1)} ${shortDir(dir)}`, `cwd:${String(i)}`)),
382
+ rows: [[btn("Type a path…", "cwdtype"), btn("‹ Back", "panel")]],
192
383
  });
193
384
  }
194
- async pickThinking(key, level) {
195
- await this.deps.control.setThinking(key, level);
196
- await this.refresh(key, `Reasoning set to ${thinkingLabel(level)}.`);
197
- }
198
- /** Pi fixes cwd at session creation, so "change the working directory" *is*
199
- * "start a new session there". */
200
- async startSessionIn(key, path) {
201
- if (!path.startsWith("/")) {
202
- const error = "That is not an absolute path — nothing changed.";
203
- await this.refresh(key, error);
204
- return { error };
205
- }
385
+ async pickDir(key, index) {
386
+ const dir = this.state(key)?.dirs[index];
387
+ if (!dir)
388
+ return this.refresh(key, "That directory is no longer listed.");
389
+ await this.chooseDir(key, dir);
390
+ }
391
+ /** Pi fixes cwd at session creation, so a directory is only ever the draft's:
392
+ * a thread that already has a session keeps it, and a new one is a new thread. */
393
+ async chooseDir(key, path) {
394
+ const state = this.state(key);
395
+ if (!state)
396
+ return;
397
+ if (this.deps.control.knows(key))
398
+ return this.refresh(key, HAS_SESSION);
399
+ if (!path.startsWith("/"))
400
+ return this.refresh(key, "That is not an absolute path — nothing changed.");
401
+ state.draft = { ...state.draft, cwd: path };
402
+ await this.refresh(key);
403
+ }
404
+ /** Create with the draft, bind the thread, run the question as the tapper's
405
+ * first message. A session that appeared meanwhile (a message raced the
406
+ * tap) is not replaced: the draft would orphan it. */
407
+ async start(key, state, run) {
408
+ if (this.deps.control.knows(key))
409
+ return this.refresh(key, HAS_SESSION);
410
+ const { q, dropped: _dropped, ...launch } = state.draft;
411
+ let id;
206
412
  try {
207
- const id = await this.deps.control.newSession(key, path);
208
- await this.refresh(key, `Started session ${id.slice(0, 8)} in ${path}.`);
209
- return { id };
413
+ id = await this.deps.control.newSession(key, launch);
210
414
  }
211
415
  catch (err) {
212
- const error = `Could not start a session there: ${String(err)}`;
213
- await this.refresh(key, error);
214
- return { error };
416
+ return this.refresh(key, failed("Could not start a session", err));
417
+ }
418
+ state.draft = {};
419
+ if (q) {
420
+ await run(q);
421
+ return this.settle(key, state, `Started ${id.slice(0, 8)} — running your question.`, false);
215
422
  }
423
+ const cwd = (await this.deps.control.status(key))?.cwd ?? launch.cwd ?? "?";
424
+ await this.settle(key, state, `Started ${id.slice(0, 8)} in ${cwd}.`, false);
216
425
  }
217
426
  }
@@ -17,13 +17,13 @@ export class ReceiptLedger {
17
17
  this.platform = platform;
18
18
  this.db = db;
19
19
  }
20
- add(receipt) {
20
+ add(receipt, createdAt = Date.now()) {
21
21
  this.db.prepare(`
22
22
  INSERT INTO receipts(platform, conversation_id, chat_id, message_id, created_at)
23
23
  VALUES (?, ?, ?, ?, ?)
24
24
  ON CONFLICT(platform, chat_id, message_id) DO UPDATE SET
25
25
  conversation_id = excluded.conversation_id, created_at = excluded.created_at
26
- `).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId, Date.now());
26
+ `).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId, createdAt);
27
27
  }
28
28
  /** Returned once, then gone. `bookedBy` claims only what was on the books by
29
29
  * then — see `Receipts.settle`. */
@@ -38,16 +38,20 @@ export class ReceiptLedger {
38
38
  .run(this.platform, conversationId, ...args);
39
39
  return rows.map(toReceipt);
40
40
  }
41
- /** Claim receipts older than `ageMs`; `0` claims everything (startup sweep). */
42
- takeStale(ageMs, now = Date.now()) {
41
+ /** Claim receipts older than `ageMs`; `0` claims everything (startup sweep).
42
+ * A conversation `working` says yes to is skipped, whatever its age: its
43
+ * turn is still going to settle. */
44
+ takeStale(ageMs, working = () => false, now = Date.now()) {
43
45
  const cutoff = now - ageMs;
44
46
  const rows = this.db.prepare(`
45
47
  SELECT conversation_id, chat_id, message_id FROM receipts
46
48
  WHERE platform = ? AND created_at <= ?
47
49
  `).all(this.platform, cutoff);
48
- this.db.prepare("DELETE FROM receipts WHERE platform = ? AND created_at <= ?")
49
- .run(this.platform, cutoff);
50
- return rows.map(toReceipt);
50
+ const claimed = rows.map(toReceipt).filter(({ conversationId }) => !working(conversationId));
51
+ const drop = this.db.prepare("DELETE FROM receipts WHERE platform = ? AND chat_id = ? AND message_id = ?");
52
+ for (const { chatId, messageId } of claimed)
53
+ drop.run(this.platform, chatId, messageId);
54
+ return claimed;
51
55
  }
52
56
  }
53
57
  /** A receipt is booked synchronously (an instant turn must not clear an
@@ -59,21 +63,29 @@ export class Receipts {
59
63
  log;
60
64
  emoji;
61
65
  staleMs;
66
+ working;
62
67
  applying = new Map();
63
68
  sweptAt = 0;
64
69
  constructor(api, ledger, log, emoji,
65
- /** After this, a receipt's turn is assumed never to settle. */
66
- staleMs) {
70
+ /** After this, a receipt whose conversation is idle is assumed never to
71
+ * settle. A turn still running keeps its 👀 however long it takes. */
72
+ staleMs, working) {
67
73
  this.api = api;
68
74
  this.ledger = ledger;
69
75
  this.log = log;
70
76
  this.emoji = emoji;
71
77
  this.staleMs = staleMs;
78
+ this.working = working;
72
79
  }
73
- mark(conversationId, chatId, messageId) {
80
+ /** `at` is when the turn this receipt belongs to began; it defaults to now,
81
+ * which is right for a message someone typed. A system note is posted *by*
82
+ * the turn that will clear it, so booking it at `now` — a round trip after
83
+ * the turn started — would put it outside that turn's scope and leave the
84
+ * reaction up until the stale sweep. */
85
+ mark(conversationId, chatId, messageId, at) {
74
86
  this.applying.set(`${chatId}:${messageId}`, this.api.setReaction(chatId, messageId, this.emoji)
75
87
  .catch((err) => this.log(`reaction failed: ${String(err)}`)));
76
- this.ledger.add({ conversationId, chatId, messageId });
88
+ this.ledger.add({ conversationId, chatId, messageId }, at);
77
89
  }
78
90
  /** Only the messages *this* turn was working on: a message queued mid-turn
79
91
  * is still owed an answer. `meta` says when the turn began; no meta clears
@@ -98,7 +110,8 @@ export class Receipts {
98
110
  if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
99
111
  return Promise.resolve();
100
112
  this.sweptAt = now;
101
- return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
113
+ // The startup sweep needs no liveness check: no turn survives the process.
114
+ return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs, all ? undefined : this.working));
102
115
  }
103
116
  async clear(receipts) {
104
117
  // A slow apply must not let a later receipt clear first.