@a-t-h-i/bot-lobby 0.6.13 → 0.6.15
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.
- package/README.md +70 -79
- package/package.json +4 -2
- package/prompts/master.md +3 -2
- package/prompts/qa.md +33 -11
- package/prompts/reviewer.md +5 -2
- package/prompts/worker.md +5 -2
- package/src/ask/relay.ts +1 -2
- package/src/ask/tool.ts +4 -6
- package/src/ask/types.ts +16 -0
- package/src/ask/web.ts +63 -0
- package/src/index.ts +7 -1
- package/src/lobby/ask.ts +4 -5
- package/src/lobby/blocks.ts +57 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +178 -0
- package/src/lobby/keys.ts +10 -41
- package/src/lobby/prompt-hub.ts +30 -77
- package/src/lobby/prompts.ts +8 -12
- package/src/lobby/runtime.ts +49 -310
- package/src/lobby/service.ts +54 -134
- package/src/lobby/task-rows.ts +28 -0
- package/src/pi/commands.ts +14 -20
- package/src/pi/model-settings.ts +112 -0
- package/src/pi/start-task.ts +1 -1
- package/src/pi/tools.ts +6 -7
- package/src/pi/ui.ts +0 -9
- package/src/schemas/configuration.ts +9 -33
- package/src/state/attachments.ts +82 -0
- package/src/webui/api/index.ts +17 -15
- package/src/webui/api/lobby.ts +4 -3
- package/src/webui/api/planner.ts +6 -4
- package/src/webui/api/quickfix.ts +4 -3
- package/src/webui/api/sessions.ts +9 -7
- package/src/webui/api/settings.ts +20 -8
- package/src/webui/api/status.ts +4 -15
- package/src/webui/api/tasks.ts +11 -15
- package/src/webui/command.ts +35 -19
- package/src/webui/dev/fake-service.ts +1 -2
- package/src/webui/dev/fixtures.ts +2 -5
- package/src/webui/events.ts +1 -1
- package/src/webui/protocol.ts +22 -11
- package/src/webui/server.ts +42 -1
- package/src/webui/uploads.ts +68 -0
- package/webui/dist/assets/index-B1PnCyl2.css +2 -0
- package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
- package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/webui/dist/build.json +142 -110
- package/webui/dist/index.html +4 -3
- package/src/ask/dialog.ts +0 -193
- package/src/ask/image.ts +0 -202
- package/src/ask/png.ts +0 -179
- package/src/ask/state.ts +0 -175
- package/src/ask/view.ts +0 -163
- package/src/lobby/layout.ts +0 -496
- package/src/lobby/markdown.ts +0 -121
- package/src/lobby/mini.ts +0 -167
- package/src/lobby/tabs/excalidraw.ts +0 -95
- package/src/lobby/tabs/git.ts +0 -162
- package/src/lobby/tabs/home.ts +0 -544
- package/src/lobby/tabs/issues.ts +0 -73
- package/src/lobby/tabs/knowledge.ts +0 -135
- package/src/lobby/tabs/metrics.ts +0 -300
- package/src/lobby/tabs/plan.ts +0 -262
- package/src/lobby/tabs/quickfix.ts +0 -136
- package/src/lobby/tabs/tasks.ts +0 -412
- package/src/lobby/theme.ts +0 -30
- package/src/lobby/view.ts +0 -3018
- package/src/pi/settings-ui.ts +0 -708
- package/src/width.ts +0 -102
- package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
- package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/pi/tools.ts
CHANGED
|
@@ -9,10 +9,9 @@ import { classifier, effortFor, hintsFor, knowledgeFor, triageFor } from "../cla
|
|
|
9
9
|
import { truncate } from "../text.ts";
|
|
10
10
|
import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
|
|
11
11
|
import { whileAsking } from "../state/budget.ts";
|
|
12
|
-
import { askUser } from "../ask/
|
|
13
|
-
import { promptHub } from "../lobby/prompt-hub.ts";
|
|
12
|
+
import { askChoice, askText, askUser, canAsk } from "../ask/web.ts";
|
|
14
13
|
import type { AskQuestion } from "../ask/types.ts";
|
|
15
|
-
import { unescapeBreaks } from "../lobby/
|
|
14
|
+
import { unescapeBreaks } from "../lobby/blocks.ts";
|
|
16
15
|
import { isQuiet } from "./quiet.ts";
|
|
17
16
|
import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
|
|
18
17
|
import { agentName, describeRun } from "./run-summary.ts";
|
|
@@ -126,11 +125,11 @@ export function workflowDeps(
|
|
|
126
125
|
triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
|
|
127
126
|
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
128
127
|
// Time spent waiting on the user is not the task's: its budget clock waits too.
|
|
129
|
-
//
|
|
130
|
-
ask: async (question) => (
|
|
131
|
-
choose: async (title, options) => (
|
|
128
|
+
// The questions go to the web page, where a free-text answer may run to several lines.
|
|
129
|
+
ask: async (question) => (canAsk() ? whileAsking(() => askText(question)) : undefined),
|
|
130
|
+
choose: async (title, options) => (canAsk() ? whileAsking(() => askChoice(title, options)) : undefined),
|
|
132
131
|
// An agent's own questions (the designer's), relayed as the questionnaire.
|
|
133
|
-
...(
|
|
132
|
+
...(canAsk() ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
|
|
134
133
|
notify: (message, level = "info") => {
|
|
135
134
|
if (hasUI) ctx.ui.notify(message, level);
|
|
136
135
|
},
|
package/src/pi/ui.ts
CHANGED
|
@@ -80,17 +80,8 @@ export function isMinimized(): boolean {
|
|
|
80
80
|
return minimized;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** Told when minimize changes, so the lobby can step aside too. */
|
|
84
|
-
let minimizeListener: ((value: boolean) => void) | undefined;
|
|
85
|
-
|
|
86
|
-
export function onMinimizeChange(listener: ((value: boolean) => void) | undefined): void {
|
|
87
|
-
minimizeListener = listener;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
83
|
export function setMinimized(value: boolean): void {
|
|
91
|
-
if (minimized === value) return;
|
|
92
84
|
minimized = value;
|
|
93
|
-
minimizeListener?.(value);
|
|
94
85
|
}
|
|
95
86
|
|
|
96
87
|
/** Flip minimize/restore and refresh the footer; the session is unchanged. */
|
|
@@ -120,39 +120,24 @@ export function isPanelMember(value: string): value is PanelMember {
|
|
|
120
120
|
export const LOBBY_PANELS = ["conversation", "activity", "thinking"] as const;
|
|
121
121
|
export type LobbyPanel = (typeof LOBBY_PANELS)[number];
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
export const LOBBY_WEB_QUESTIONS = ["both", "terminal"] as const;
|
|
125
|
-
export type LobbyWebQuestions = (typeof LOBBY_WEB_QUESTIONS)[number];
|
|
126
|
-
|
|
127
|
-
/** The loopback web UI: off until started with `/bot-lobby web`. */
|
|
123
|
+
/** The loopback web UI: it starts with every interactive session. */
|
|
128
124
|
export interface LobbyWebConfig {
|
|
129
|
-
/** Start the server with the session. */
|
|
130
|
-
enabled: boolean;
|
|
131
125
|
/** Base port (tried, then the next free up to +20); 0 means any free port. */
|
|
132
126
|
port: number;
|
|
133
|
-
/** Open the link in the browser
|
|
127
|
+
/** Open the link in the browser when the session starts. */
|
|
134
128
|
openBrowser: boolean;
|
|
135
|
-
questions: LobbyWebQuestions;
|
|
136
129
|
}
|
|
137
130
|
|
|
138
|
-
/** The
|
|
131
|
+
/** The web lobby. */
|
|
139
132
|
export interface LobbyConfig {
|
|
140
|
-
/** Open by itself when this session starts or resumes a task. */
|
|
141
|
-
autoOpen: boolean;
|
|
142
133
|
/** Who sits on the planning panel next to the oracle, until toggled in the Plan tab. */
|
|
143
134
|
planningPanel: PanelMember[];
|
|
144
|
-
/** Put the panel's questions to the user as soon as a round ends, while the Plan tab is open. */
|
|
145
|
-
autoAsk: boolean;
|
|
146
135
|
/** Show the GitHub Issues tab (off for now). */
|
|
147
136
|
issues: boolean;
|
|
148
|
-
/** Which panes
|
|
137
|
+
/** Which panes the Lobby tab shows. */
|
|
149
138
|
panels: Record<LobbyPanel, boolean>;
|
|
150
|
-
/** Key overrides by action name, e.g. `{ "
|
|
139
|
+
/** Key overrides by action name, e.g. `{ "search": "alt+f" }`. */
|
|
151
140
|
keys: Record<string, string>;
|
|
152
|
-
/** A one-line status (task steps, planning round, quick fix, or idle) under the editor while the lobby is hidden. */
|
|
153
|
-
miniLine: boolean;
|
|
154
|
-
/** Clicks and the wheel work in the lobby (click a draft line to comment on it); shift+drag still selects text. */
|
|
155
|
-
mouse: boolean;
|
|
156
141
|
/**
|
|
157
142
|
* Planning rounds before the oracle finalizes the plan on its own: the last
|
|
158
143
|
* round skips the seats and asks nothing; later replies only revise. 0 = unlimited.
|
|
@@ -271,17 +256,13 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
271
256
|
scratchpadMaxChars: 2000,
|
|
272
257
|
},
|
|
273
258
|
lobby: {
|
|
274
|
-
autoOpen: true,
|
|
275
259
|
planningPanel: [...PANEL_MEMBERS],
|
|
276
|
-
autoAsk: true,
|
|
277
260
|
issues: false,
|
|
278
261
|
panels: { conversation: true, activity: true, thinking: true },
|
|
279
262
|
keys: {},
|
|
280
|
-
mouse: true,
|
|
281
|
-
miniLine: true,
|
|
282
263
|
maxPlanningRounds: 5,
|
|
283
264
|
splitPlanAbove: 8,
|
|
284
|
-
web: {
|
|
265
|
+
web: { port: 7347, openBrowser: true },
|
|
285
266
|
},
|
|
286
267
|
classifier: {
|
|
287
268
|
enabled: false,
|
|
@@ -344,7 +325,7 @@ function flag(value: unknown, fallback: boolean): boolean {
|
|
|
344
325
|
}
|
|
345
326
|
|
|
346
327
|
function normalizeLobby(value: unknown): LobbyConfig {
|
|
347
|
-
const source = value as {
|
|
328
|
+
const source = value as { planningPanel?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
|
|
348
329
|
const defaults = DEFAULT_CONFIG.lobby;
|
|
349
330
|
const panel = Array.isArray(source?.planningPanel)
|
|
350
331
|
? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
|
|
@@ -352,26 +333,21 @@ function normalizeLobby(value: unknown): LobbyConfig {
|
|
|
352
333
|
const panels = (source?.panels ?? {}) as Partial<Record<LobbyPanel, unknown>>;
|
|
353
334
|
const keys = source?.keys && typeof source.keys === "object" ? source.keys as Record<string, unknown> : {};
|
|
354
335
|
return {
|
|
355
|
-
autoOpen: flag(source?.autoOpen, defaults.autoOpen),
|
|
356
336
|
planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
|
|
357
|
-
autoAsk: flag(source?.autoAsk, defaults.autoAsk),
|
|
358
337
|
issues: flag(source?.issues, defaults.issues),
|
|
359
338
|
panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
|
|
360
339
|
keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
|
|
361
|
-
mouse: flag(source?.mouse, defaults.mouse),
|
|
362
|
-
miniLine: flag(source?.miniLine, defaults.miniLine),
|
|
363
340
|
maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
|
|
364
341
|
splitPlanAbove: roundLimit(source?.splitPlanAbove, defaults.splitPlanAbove),
|
|
365
342
|
web: normalizeLobbyWeb(source?.web, defaults.web),
|
|
366
343
|
};
|
|
367
344
|
}
|
|
368
345
|
|
|
369
|
-
/** The loopback web UI's config: 0 or a real port
|
|
346
|
+
/** The loopback web UI's config: 0 or a real port; anything else keeps the default. */
|
|
370
347
|
function normalizeLobbyWeb(value: unknown, fallback: LobbyWebConfig): LobbyWebConfig {
|
|
371
348
|
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
372
349
|
const port = source.port === 0 ? 0 : webPort(source.port, fallback.port);
|
|
373
|
-
|
|
374
|
-
return { enabled: flag(source.enabled, fallback.enabled), port, openBrowser: flag(source.openBrowser, fallback.openBrowser), questions };
|
|
350
|
+
return { port, openBrowser: flag(source.openBrowser, fallback.openBrowser) };
|
|
375
351
|
}
|
|
376
352
|
|
|
377
353
|
/** A TCP port (0 means any free port); anything else keeps the default. */
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files attached in the lobby's composer live in a temp folder outside the
|
|
3
|
+
* repository. Each one sent while a task was in play is remembered against
|
|
4
|
+
* that task, and removed when the task completes, is abandoned, archived or
|
|
5
|
+
* deleted. Files never sent, or sent with no task, go after a few days.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** The folder the page's preview route serves attachments from. */
|
|
12
|
+
export function attachmentDir(): string {
|
|
13
|
+
return join(tmpdir(), "bot-lobby-previews", "attachments");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function indexPath(): string {
|
|
17
|
+
return join(tmpdir(), "bot-lobby-previews", "attachments.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readIndex(): Record<string, string> {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(indexPath(), "utf8")) as Record<string, unknown>;
|
|
23
|
+
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeIndex(index: Record<string, string>): void {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(join(tmpdir(), "bot-lobby-previews"), { recursive: true });
|
|
32
|
+
writeFileSync(indexPath(), JSON.stringify(index), { mode: 0o600 });
|
|
33
|
+
} catch {
|
|
34
|
+
// The files then fall to the age sweep.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Remember that `ids` belong to `taskId`. */
|
|
39
|
+
export function claimAttachments(ids: readonly string[], taskId: string): void {
|
|
40
|
+
if (ids.length === 0) return;
|
|
41
|
+
const index = readIndex();
|
|
42
|
+
for (const id of ids) index[id] = taskId;
|
|
43
|
+
writeIndex(index);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Delete every file that was sent while `taskId` was in play. */
|
|
47
|
+
export function releaseAttachments(taskId: string): number {
|
|
48
|
+
const index = readIndex();
|
|
49
|
+
let removed = 0;
|
|
50
|
+
for (const [id, owner] of Object.entries(index)) {
|
|
51
|
+
if (owner !== taskId) continue;
|
|
52
|
+
try {
|
|
53
|
+
rmSync(join(attachmentDir(), id), { force: true });
|
|
54
|
+
removed += 1;
|
|
55
|
+
} catch {
|
|
56
|
+
// A file already gone is the goal.
|
|
57
|
+
}
|
|
58
|
+
delete index[id];
|
|
59
|
+
}
|
|
60
|
+
if (removed > 0 || Object.keys(index).length !== Object.keys(readIndex()).length) writeIndex(index);
|
|
61
|
+
return removed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Delete files older than `maxAgeMs` that no task claimed. */
|
|
65
|
+
export function sweepAttachments(maxAgeMs: number, now = Date.now()): number {
|
|
66
|
+
const dir = attachmentDir();
|
|
67
|
+
if (!existsSync(dir)) return 0;
|
|
68
|
+
const claimed = new Set(Object.keys(readIndex()));
|
|
69
|
+
let removed = 0;
|
|
70
|
+
for (const name of readdirSync(dir)) {
|
|
71
|
+
if (claimed.has(name)) continue;
|
|
72
|
+
try {
|
|
73
|
+
if (now - statSync(join(dir, name)).mtimeMs > maxAgeMs) {
|
|
74
|
+
rmSync(join(dir, name), { force: true });
|
|
75
|
+
removed += 1;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Skip what cannot be read.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return removed;
|
|
82
|
+
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { ServerResponse } from "node:http";
|
|
|
9
9
|
import { Type, type TObject } from "typebox";
|
|
10
10
|
import { Compile } from "typebox/compile";
|
|
11
11
|
import { statusFor, type ApiName, type ErrorCode } from "../protocol.ts";
|
|
12
|
-
import type { LobbyService } from "../../lobby/
|
|
12
|
+
import type { LobbyService } from "../../lobby/host.ts";
|
|
13
13
|
import { statusGet } from "./status.ts";
|
|
14
14
|
import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
|
|
15
15
|
import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
|
|
@@ -78,6 +78,8 @@ const Empty = Type.Object({}, { additionalProperties: false });
|
|
|
78
78
|
const TaskId = Type.String({ minLength: 1, maxLength: 200 });
|
|
79
79
|
const GhNumber = Type.Integer({ minimum: 1, maximum: 1_000_000_000 });
|
|
80
80
|
const NoticeText = Type.String({ maxLength: 20_000 });
|
|
81
|
+
/** Ids of files uploaded through `files.upload`; the message names them by path. */
|
|
82
|
+
const Attachments = Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { maxItems: 8 }));
|
|
81
83
|
const CommentText = Type.String({ maxLength: 10_000 });
|
|
82
84
|
const DialogAnswer = Type.Union([
|
|
83
85
|
Type.Object({ value: Type.String() }, { additionalProperties: false }),
|
|
@@ -112,8 +114,8 @@ function buildRoutes(): Record<string, Route> {
|
|
|
112
114
|
run: (body, ctx) => lobbyHistory(body as { before?: number }, ctx),
|
|
113
115
|
};
|
|
114
116
|
routes["lobby.send"] = {
|
|
115
|
-
schema: Type.Object({ text: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
|
|
116
|
-
run: (body, ctx) => lobbySend(body as { text: string }, ctx),
|
|
117
|
+
schema: Type.Object({ text: Type.String({ maxLength: 20_000 }), attachments: Attachments }, { additionalProperties: false }),
|
|
118
|
+
run: (body, ctx) => lobbySend(body as { text: string; attachments?: string[] }, ctx),
|
|
117
119
|
};
|
|
118
120
|
routes["lobby.abort"] = { schema: Empty, run: (_body, ctx) => lobbyAbort(ctx) };
|
|
119
121
|
routes["tasks.list"] = { schema: Empty, run: (_body, ctx) => tasksList(ctx) };
|
|
@@ -127,8 +129,8 @@ function buildRoutes(): Record<string, Route> {
|
|
|
127
129
|
run: (body, ctx) => tasksComments(body as { taskId: string }, ctx),
|
|
128
130
|
};
|
|
129
131
|
routes["tasks.comment"] = {
|
|
130
|
-
schema: Type.Object({ taskId: TaskId, text: CommentText }, { additionalProperties: false }),
|
|
131
|
-
run: (body, ctx) => tasksComment(body as { taskId: string; text: string }, ctx),
|
|
132
|
+
schema: Type.Object({ taskId: TaskId, text: CommentText, attachments: Attachments }, { additionalProperties: false }),
|
|
133
|
+
run: (body, ctx) => tasksComment(body as { taskId: string; text: string; attachments?: string[] }, ctx),
|
|
132
134
|
};
|
|
133
135
|
routes["tasks.archive"] = {
|
|
134
136
|
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
@@ -147,8 +149,8 @@ function buildRoutes(): Record<string, Route> {
|
|
|
147
149
|
run: (body, ctx) => tasksAuto(body as { taskId: string; on: boolean }, ctx),
|
|
148
150
|
};
|
|
149
151
|
routes["tasks.message"] = {
|
|
150
|
-
schema: Type.Object({ taskId: TaskId, text: NoticeText }, { additionalProperties: false }),
|
|
151
|
-
run: (body, ctx) => tasksMessage(body as { taskId: string; text: string }, ctx),
|
|
152
|
+
schema: Type.Object({ taskId: TaskId, text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
|
|
153
|
+
run: (body, ctx) => tasksMessage(body as { taskId: string; text: string; attachments?: string[] }, ctx),
|
|
152
154
|
};
|
|
153
155
|
routes["plans.get"] = {
|
|
154
156
|
schema: Type.Object({ planId: TaskId }, { additionalProperties: false }),
|
|
@@ -168,16 +170,16 @@ function buildRoutes(): Record<string, Route> {
|
|
|
168
170
|
run: (body, ctx) => sessionsChat(body as { key?: string; sessionId?: string; before?: number }, ctx),
|
|
169
171
|
};
|
|
170
172
|
routes["sessions.start"] = {
|
|
171
|
-
schema: Type.Object({ request: Type.Optional(NoticeText), planId: Type.Optional(TaskId), auto: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
172
|
-
run: (body, ctx) => sessionsStart(body as { request?: string; planId?: string; auto?: boolean }, ctx),
|
|
173
|
+
schema: Type.Object({ request: Type.Optional(NoticeText), planId: Type.Optional(TaskId), auto: Type.Optional(Type.Boolean()), attachments: Attachments }, { additionalProperties: false }),
|
|
174
|
+
run: (body, ctx) => sessionsStart(body as { request?: string; planId?: string; auto?: boolean; attachments?: string[] }, ctx),
|
|
173
175
|
};
|
|
174
176
|
routes["sessions.stop"] = {
|
|
175
177
|
schema: Type.Object({ key: TaskId }, { additionalProperties: false }),
|
|
176
178
|
run: (body, ctx) => sessionsStop(body as { key: string }, ctx),
|
|
177
179
|
};
|
|
178
180
|
routes["sessions.message"] = {
|
|
179
|
-
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), text: NoticeText }, { additionalProperties: false }),
|
|
180
|
-
run: (body, ctx) => sessionsMessage(body as { key?: string; sessionId?: string; text: string }, ctx),
|
|
181
|
+
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
|
|
182
|
+
run: (body, ctx) => sessionsMessage(body as { key?: string; sessionId?: string; text: string; attachments?: string[] }, ctx),
|
|
181
183
|
};
|
|
182
184
|
routes["sessions.switch"] = {
|
|
183
185
|
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), claimTaskId: Type.Optional(TaskId) }, { additionalProperties: false }),
|
|
@@ -202,8 +204,8 @@ function buildRoutes(): Record<string, Route> {
|
|
|
202
204
|
run: (body, ctx) => plannerNew(body as { seed?: { issue: { number: number; title: string; url?: string }; body: string }; seats?: ("backend" | "designer" | "qa" | "researcher")[] }, ctx),
|
|
203
205
|
};
|
|
204
206
|
routes["planner.send"] = {
|
|
205
|
-
schema: Type.Object({ text: NoticeText }, { additionalProperties: false }),
|
|
206
|
-
run: (body, ctx) => plannerSend(body as { text: string }, ctx),
|
|
207
|
+
schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
|
|
208
|
+
run: (body, ctx) => plannerSend(body as { text: string; attachments?: string[] }, ctx),
|
|
207
209
|
};
|
|
208
210
|
routes["planner.toggleSeat"] = {
|
|
209
211
|
schema: Type.Object({ member: Member }, { additionalProperties: false }),
|
|
@@ -218,8 +220,8 @@ function buildRoutes(): Record<string, Route> {
|
|
|
218
220
|
routes["planner.save"] = { schema: Empty, run: (_body, ctx) => plannerSave(ctx) };
|
|
219
221
|
routes["quickfix.list"] = { schema: Empty, run: (_body, ctx) => quickfixList(ctx) };
|
|
220
222
|
routes["quickfix.submit"] = {
|
|
221
|
-
schema: Type.Object({ text: NoticeText }, { additionalProperties: false }),
|
|
222
|
-
run: (body, ctx) => quickfixSubmit(body as { text: string }, ctx),
|
|
223
|
+
schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
|
|
224
|
+
run: (body, ctx) => quickfixSubmit(body as { text: string; attachments?: string[] }, ctx),
|
|
223
225
|
};
|
|
224
226
|
routes["quickfix.cancel"] = {
|
|
225
227
|
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
package/src/webui/api/lobby.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { MAX_ACTIVITY, MAX_CHAT, MAX_THOUGHTS } from "../../lobby/feed.ts";
|
|
8
8
|
import type { Task } from "../../schemas/task.ts";
|
|
9
9
|
import type { ApiContext } from "./index.ts";
|
|
10
|
+
import { withAttachments } from "../uploads.ts";
|
|
10
11
|
import { fail } from "./index.ts";
|
|
11
12
|
import { taskSteps } from "./plan-facts.ts";
|
|
12
13
|
import type { LobbySnapshot, SnapshotTask } from "../protocol.ts";
|
|
@@ -74,9 +75,9 @@ export function lobbyHistory(body: { before?: number }, ctx: ApiContext): { entr
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** Text for the oracle; a notice when it was not simply sent. */
|
|
77
|
-
export function lobbySend(body: { text: string }, ctx: ApiContext): { notice?: string } {
|
|
78
|
-
if (!body.text.trim()) return { notice: "type something first" };
|
|
79
|
-
const notice = ctx.service.toOracle(body.text);
|
|
78
|
+
export function lobbySend(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
|
|
79
|
+
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
80
|
+
const notice = ctx.service.toOracle(withAttachments(body.text, body.attachments, ctx.service.zen().task?.id));
|
|
80
81
|
return notice ? { notice } : {};
|
|
81
82
|
}
|
|
82
83
|
|
package/src/webui/api/planner.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { MEMBER_LABELS } from "../../lobby/planner.ts";
|
|
|
8
8
|
import type { PanelMember } from "../../schemas/configuration.ts";
|
|
9
9
|
import type { PlannerSnapshot } from "../protocol.ts";
|
|
10
10
|
import type { ApiContext } from "./index.ts";
|
|
11
|
+
import { withAttachments } from "../uploads.ts";
|
|
11
12
|
import { fail } from "./index.ts";
|
|
12
13
|
|
|
13
14
|
/** The planning session as the page reads it; absent means no session yet. */
|
|
@@ -47,11 +48,12 @@ export function plannerNew(body: { seed?: { issue: { number: number; title: stri
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** Send a message to the panel; the round runs in the background. */
|
|
50
|
-
export function plannerSend(body: { text: string }, ctx: ApiContext): { notice: string } {
|
|
51
|
-
if (!body.text.trim()) fail(400, "bad_request", "describe the task to plan");
|
|
51
|
+
export function plannerSend(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice: string } {
|
|
52
|
+
if (!body.text.trim() && !body.attachments?.length) fail(400, "bad_request", "describe the task to plan");
|
|
53
|
+
const text = withAttachments(body.text, body.attachments);
|
|
52
54
|
const session = ctx.service.planner() ?? ctx.service.newPlanner(undefined, [...ctx.service.defaultPanel()]);
|
|
53
|
-
if (session!.busy) fail(409, "conflict", "the panel is still thinking —
|
|
54
|
-
session!.send(
|
|
55
|
+
if (session!.busy) fail(409, "conflict", "the panel is still thinking — wait for the round to finish");
|
|
56
|
+
session!.send(text).catch(() => {});
|
|
55
57
|
return { notice: "sent — the panel is on the next round" };
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* terminal shows.
|
|
6
6
|
*/
|
|
7
7
|
import type { ApiContext } from "./index.ts";
|
|
8
|
+
import { withAttachments } from "../uploads.ts";
|
|
8
9
|
import { fail } from "./index.ts";
|
|
9
10
|
import type { QuickFixJob } from "../../lobby/quickfix.ts";
|
|
10
11
|
|
|
@@ -14,9 +15,9 @@ export function quickfixList(ctx: ApiContext): { jobs: QuickFixJob[] } {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Queue a quick fix; it starts at once when nothing else is running. */
|
|
17
|
-
export function quickfixSubmit(body: { text: string }, ctx: ApiContext): { notice: string; id: string } {
|
|
18
|
-
if (!body.text.trim()) fail(400, "bad_request", "describe a quick change first");
|
|
19
|
-
const job = ctx.service.quickfix.submit(body.text);
|
|
18
|
+
export function quickfixSubmit(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice: string; id: string } {
|
|
19
|
+
if (!body.text.trim() && !body.attachments?.length) fail(400, "bad_request", "describe a quick change first");
|
|
20
|
+
const job = ctx.service.quickfix.submit(withAttachments(body.text, body.attachments));
|
|
20
21
|
return { notice: ctx.service.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`, id: job.id };
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { BackgroundSession, DialogAnswer } from "../../lobby/sessions.ts";
|
|
9
9
|
import type { ApiContext } from "./index.ts";
|
|
10
|
+
import { withAttachments } from "../uploads.ts";
|
|
10
11
|
import { fail } from "./index.ts";
|
|
11
12
|
import type { BackgroundSessionInfo } from "../protocol.ts";
|
|
12
13
|
|
|
@@ -50,7 +51,7 @@ function pageChat(all: unknown[], older: boolean, before?: number): { entries: u
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/** Start a task in a new background session, from a request or a saved plan. */
|
|
53
|
-
export function sessionsStart(body: { request?: string; planId?: string; auto?: boolean }, ctx: ApiContext): { notice: string; key?: string } {
|
|
54
|
+
export function sessionsStart(body: { request?: string; planId?: string; auto?: boolean; attachments?: string[] }, ctx: ApiContext): { notice: string; key?: string } {
|
|
54
55
|
if (body.request?.trim() && body.planId) fail(400, "bad_request", "start from a request or a plan, not both");
|
|
55
56
|
if (body.planId) {
|
|
56
57
|
const plan = ctx.service.plans().find((entry) => entry.id === body.planId);
|
|
@@ -59,8 +60,8 @@ export function sessionsStart(body: { request?: string; planId?: string; auto?:
|
|
|
59
60
|
if (typeof session === "string") return { notice: session };
|
|
60
61
|
return { notice: `started ${session.name} in a new session`, key: session.key };
|
|
61
62
|
}
|
|
62
|
-
if (!body.request?.trim()) fail(400, "bad_request", "describe the task first");
|
|
63
|
-
const session = ctx.service.startSession({ request: body.request
|
|
63
|
+
if (!body.request?.trim() && !body.attachments?.length) fail(400, "bad_request", "describe the task first");
|
|
64
|
+
const session = ctx.service.startSession({ request: withAttachments((body.request ?? "").trim(), body.attachments), ...(body.auto ? { auto: true } : {}) });
|
|
64
65
|
if (typeof session === "string") return { notice: session };
|
|
65
66
|
return { notice: `started ${session.name} in a new session`, key: session.key };
|
|
66
67
|
}
|
|
@@ -74,16 +75,17 @@ export function sessionsStop(body: { key: string }, ctx: ApiContext): { notice:
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** Message a session: background ones directly, other terminals through their inbox. */
|
|
77
|
-
export function sessionsMessage(body: { key?: string; sessionId?: string; text: string }, ctx: ApiContext): { notice?: string } {
|
|
78
|
+
export function sessionsMessage(body: { key?: string; sessionId?: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
|
|
78
79
|
if (!body.key && !body.sessionId) fail(400, "bad_request", "a session key or id is required");
|
|
79
|
-
if (!body.text.trim()) return { notice: "type something first" };
|
|
80
|
+
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
81
|
+
const text = withAttachments(body.text, body.attachments);
|
|
80
82
|
const background = backgroundOf(ctx, body.key, body.sessionId);
|
|
81
83
|
if (background) {
|
|
82
84
|
if (!background.alive) fail(409, "conflict", `${background.name} has ended`);
|
|
83
|
-
background.send(
|
|
85
|
+
background.send(text);
|
|
84
86
|
return {};
|
|
85
87
|
}
|
|
86
|
-
return { notice: ctx.service.sendToSession(body.sessionId!,
|
|
88
|
+
return { notice: ctx.service.sendToSession(body.sessionId!, text) };
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/** Run a session in this window (stopping its background process first). */
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `settings.get` / `settings.set`: the effective bot-lobby config (never a
|
|
3
3
|
* secret) and the models Pi offers. A set merges the patch over the current
|
|
4
|
-
* config, normalises it through
|
|
5
|
-
* saves it the way the settings menu does, so the terminal sees the change.
|
|
6
|
-
* The one terminal wording reused here is the port's; everything else the
|
|
4
|
+
* config, normalises it through `resolveConfig` and saves it; everything the
|
|
7
5
|
* normaliser clamps or drops, so no unknown field is ever written.
|
|
8
6
|
*/
|
|
9
7
|
import { resolveConfig, type BotLobbyConfig } from "../../schemas/configuration.ts";
|
|
10
8
|
import { loadConfig, saveConfig } from "../../state/project.ts";
|
|
11
9
|
import { lobbyTopics } from "../../lobby/topics.ts";
|
|
10
|
+
import { prefillModels } from "../../pi/model-settings.ts";
|
|
11
|
+
import { kindLabel } from "../../pi/model-support.ts";
|
|
12
|
+
import { pushNotice } from "../notices.ts";
|
|
12
13
|
import type { SettingsInfo } from "../protocol.ts";
|
|
13
14
|
import type { ApiContext } from "./index.ts";
|
|
14
15
|
import { fail } from "./index.ts";
|
|
@@ -49,7 +50,7 @@ function writeConfig(ctx: ApiContext, config: BotLobbyConfig): void {
|
|
|
49
50
|
else saveConfig(config);
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/** Reject a port that is not 0 or 1-65535,
|
|
53
|
+
/** Reject a port that is not 0 or 1-65535, . */
|
|
53
54
|
function checkPort(patch: Record<string, unknown>): void {
|
|
54
55
|
const lobby = isPlain(patch.lobby) ? patch.lobby : undefined;
|
|
55
56
|
const web = lobby && isPlain(lobby.web) ? lobby.web : undefined;
|
|
@@ -65,7 +66,7 @@ function agentEntries(patch: Record<string, unknown>): unknown[] {
|
|
|
65
66
|
return [patch.master, ...agents, patch.researcher, patch.quickFix, patch.planner, patch.scout];
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/** Reject a time limit that is not a positive number,
|
|
69
|
+
/** Reject a time limit that is not a positive number, . */
|
|
69
70
|
function checkTimeouts(patch: Record<string, unknown>): void {
|
|
70
71
|
for (const entry of agentEntries(patch)) {
|
|
71
72
|
const timeout = isPlain(entry) ? entry.timeoutMs : undefined;
|
|
@@ -76,12 +77,23 @@ function checkTimeouts(patch: Record<string, unknown>): void {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The page's first settings call. Subagents never inherit a model silently:
|
|
82
|
+
* any whose model is still unset is pinned to the session's model and saved,
|
|
83
|
+
* so the page always shows what each agent runs on.
|
|
84
|
+
*/
|
|
80
85
|
export function settingsGet(ctx: ApiContext): SettingsInfo {
|
|
81
|
-
|
|
86
|
+
let config = readConfig(ctx);
|
|
87
|
+
const prefilled = prefillModels(config, ctx.service.sessionModel?.());
|
|
88
|
+
if (prefilled.filled.length > 0) {
|
|
89
|
+
writeConfig(ctx, prefilled.config);
|
|
90
|
+
config = prefilled.config;
|
|
91
|
+
pushNotice(`${prefilled.filled.map(kindLabel).join(", ")} now run on the session model; change them here any time.`);
|
|
92
|
+
}
|
|
93
|
+
return { config: stripSecrets(config), models: ctx.service.models?.() ?? [] };
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
/** Merge, normalise and save the patch; the
|
|
96
|
+
/** Merge, normalise and save the patch; the lobby rereads the config. */
|
|
85
97
|
export function settingsSet(body: { patch: Record<string, unknown> }, ctx: ApiContext): { config: BotLobbyConfig } {
|
|
86
98
|
checkPort(body.patch);
|
|
87
99
|
checkTimeouts(body.patch);
|
package/src/webui/api/status.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `status.get`: everything the page shell needs before it reads a tab —
|
|
3
3
|
* workspace, session, tabs, shortcuts and open windows. Tab ids and labels
|
|
4
|
-
* come from the shared strings, shortcuts from the
|
|
5
|
-
* web-only tab cycling defaults), so the page matches the terminal exactly.
|
|
4
|
+
* come from the shared strings, shortcuts from the key map.
|
|
6
5
|
*/
|
|
7
6
|
import { loadConfig } from "../../state/project.ts";
|
|
8
|
-
import { LOBBY_ACTIONS, keyLabel,
|
|
7
|
+
import { LOBBY_ACTIONS, keyLabel, keyMap } from "../../lobby/keys.ts";
|
|
9
8
|
import { TAB_LABELS, visibleTabs } from "../../lobby/prompts.ts";
|
|
10
9
|
import type { ApiContext } from "./index.ts";
|
|
11
10
|
import type { KeyInfo, StatusInfo, TabInfo } from "../protocol.ts";
|
|
@@ -15,7 +14,7 @@ function tabs(issuesEnabled: boolean): TabInfo[] {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
function keys(overrides: Readonly<Record<string, string>>): KeyInfo[] {
|
|
18
|
-
const map =
|
|
17
|
+
const map = keyMap(overrides);
|
|
19
18
|
return (Object.keys(LOBBY_ACTIONS) as Array<keyof typeof LOBBY_ACTIONS>).map((action) => ({
|
|
20
19
|
action,
|
|
21
20
|
key: map[action],
|
|
@@ -24,16 +23,6 @@ function keys(overrides: Readonly<Record<string, string>>): KeyInfo[] {
|
|
|
24
23
|
}));
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/** Whether pi itself is asking in the terminal (a later surface reports it; false until one does). */
|
|
28
|
-
function terminalDialog(ctx: ApiContext): boolean {
|
|
29
|
-
const extra = ctx.service as unknown as { terminalDialog?: () => boolean };
|
|
30
|
-
try {
|
|
31
|
-
return extra.terminalDialog?.() ?? false;
|
|
32
|
-
} catch {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
26
|
/** The page shell's first call. */
|
|
38
27
|
export function statusGet(ctx: ApiContext): StatusInfo {
|
|
39
28
|
const service = ctx.service;
|
|
@@ -45,9 +34,9 @@ export function statusGet(ctx: ApiContext): StatusInfo {
|
|
|
45
34
|
sessionId: service.sessionId() ?? undefined,
|
|
46
35
|
sessionName: service.sessionName() ?? undefined,
|
|
47
36
|
busy: service.masterBusy(),
|
|
48
|
-
terminalDialog: terminalDialog(ctx),
|
|
49
37
|
port: ctx.port,
|
|
50
38
|
issuesEnabled,
|
|
39
|
+
panels: loadConfig().lobby.panels,
|
|
51
40
|
tabs: tabs(issuesEnabled),
|
|
52
41
|
keys: keys(loadConfig().lobby.keys),
|
|
53
42
|
windows: [],
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Tasks tab over HTTP: rows, archived tasks, plan comments, and the row
|
|
3
|
-
* actions (comment, archive, restore, delete, auto mode, message).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The row shape mirrors `taskRows` in `src/lobby/tabs/tasks.ts`, whose pure
|
|
7
|
-
* data part is rebuilt here instead of imported: that module draws through
|
|
8
|
-
* `layout.ts` and would load the TUI (`@earendil-works/pi-tui`) into the
|
|
9
|
-
* server. The one type import above is erased at compile time.
|
|
3
|
+
* actions (comment, archive, restore, delete, auto mode, message). The row
|
|
4
|
+
* shape is `TaskRow` in `src/lobby/task-rows.ts`.
|
|
10
5
|
*/
|
|
11
6
|
import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
|
|
12
7
|
import type { PlannedTask } from "../../state/backlog.ts";
|
|
13
8
|
import { describeRun, runFromLog } from "../../pi/run-summary.ts";
|
|
14
9
|
import { pendingApprovals } from "../../workflow/approvals.ts";
|
|
15
|
-
import type { TaskRow } from "../../lobby/
|
|
10
|
+
import type { TaskRow } from "../../lobby/task-rows.ts";
|
|
16
11
|
import type { TaskDetail } from "../protocol.ts";
|
|
17
12
|
import type { ApiContext } from "./index.ts";
|
|
13
|
+
import { withAttachments } from "../uploads.ts";
|
|
18
14
|
import { fail } from "./index.ts";
|
|
19
15
|
import { taskSteps } from "./plan-facts.ts";
|
|
20
|
-
import type { TaskSection } from "../../lobby/
|
|
16
|
+
import type { TaskSection } from "../../lobby/task-rows.ts";
|
|
21
17
|
|
|
22
18
|
interface RowContext {
|
|
23
19
|
names: ReadonlyMap<string, string>;
|
|
@@ -140,9 +136,9 @@ export function tasksComments(body: { taskId: string }, ctx: ApiContext): { comm
|
|
|
140
136
|
}
|
|
141
137
|
|
|
142
138
|
/** Comment on a task's plan; blank text is a no-op notice like the lobby's. */
|
|
143
|
-
export function tasksComment(body: { taskId: string; text: string }, ctx: ApiContext): { notice?: string } {
|
|
144
|
-
if (!body.text.trim()) return { notice: "type something first" };
|
|
145
|
-
return { notice: ctx.service.comment(body.taskId, body.text) };
|
|
139
|
+
export function tasksComment(body: { taskId: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
|
|
140
|
+
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
141
|
+
return { notice: ctx.service.comment(body.taskId, withAttachments(body.text, body.attachments, body.taskId)) };
|
|
146
142
|
}
|
|
147
143
|
|
|
148
144
|
/** Archive, restore or delete a task; each answers the terminal's notice. */
|
|
@@ -171,9 +167,9 @@ export function tasksAuto(body: { taskId: string; on: boolean }, ctx: ApiContext
|
|
|
171
167
|
}
|
|
172
168
|
|
|
173
169
|
/** Leave a message for a task's oracle; blank text is a no-op notice. */
|
|
174
|
-
export function tasksMessage(body: { taskId: string; text: string }, ctx: ApiContext): { notice: string } {
|
|
175
|
-
if (!body.text.trim()) return { notice: "type something first" };
|
|
176
|
-
return { notice: ctx.service.sendToTask(body.taskId, body.text) };
|
|
170
|
+
export function tasksMessage(body: { taskId: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice: string } {
|
|
171
|
+
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
172
|
+
return { notice: ctx.service.sendToTask(body.taskId, withAttachments(body.text, body.attachments, body.taskId)) };
|
|
177
173
|
}
|
|
178
174
|
|
|
179
175
|
/** How many recent runs the detail lists (the terminal's count). */
|