@a-t-h-i/bot-lobby 0.6.49 → 0.6.50
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 +2 -2
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/package.json +9 -7
- package/src/classifier/classifier.ts +14 -2
- package/src/classifier/hosts.ts +12 -18
- package/src/classifier/instance.ts +7 -4
- package/src/execution/delegation.ts +2 -1
- package/src/execution/pi-runner.ts +11 -2
- package/src/lobby/host.ts +6 -0
- package/src/lobby/keys.ts +2 -1
- package/src/lobby/service.ts +71 -4
- package/src/lobby/sessions.ts +35 -0
- package/src/pi/auto-compaction.ts +46 -19
- package/src/pi/events.ts +2 -0
- package/src/pi/fresh-context.ts +10 -8
- package/src/pi/plan-checklist.ts +47 -15
- package/src/schemas/task.ts +14 -0
- package/src/state/persistence.ts +4 -1
- package/src/webui/api/index.ts +36 -2
- package/src/webui/api/lobby.ts +1 -1
- package/src/webui/api/plan-facts.ts +9 -4
- package/src/webui/api/sessions.ts +21 -0
- package/src/webui/api/tasks.ts +12 -1
- package/src/webui/api/terminal.ts +42 -0
- package/src/webui/dev/fake-service.ts +42 -1
- package/src/webui/protocol.ts +29 -2
- package/src/webui/server.ts +16 -1
- package/src/webui/terminal.ts +292 -0
- package/src/workflow/workflow.ts +3 -2
- package/webui/dist/assets/TerminalView-BrP-ENHg.css +1 -0
- package/webui/dist/assets/TerminalView-D_j85u4O.js +16 -0
- package/webui/dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
- package/webui/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
- package/webui/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/webui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/webui/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/webui/dist/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- package/webui/dist/assets/index-BkM--P3x.js +115 -0
- package/webui/dist/assets/index-DrHXotS7.css +2 -0
- package/webui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/webui/dist/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
- package/webui/dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 +0 -0
- package/webui/dist/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
- package/webui/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/webui/dist/assets/roboto-mono-latin-wght-normal-CZtBPCCa.woff2 +0 -0
- package/webui/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
- package/webui/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
- package/webui/dist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
- package/webui/dist/build.json +108 -92
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C3p3zxoV.js +0 -114
- package/webui/dist/assets/index-DE9jHz-C.css +0 -2
package/src/pi/fresh-context.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* already run as pi processes of their own, each with its own context, and
|
|
14
14
|
* are not touched here.
|
|
15
15
|
*/
|
|
16
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { relative } from "node:path";
|
|
18
18
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
19
19
|
import { peekTasks, taskDirFor } from "../state/persistence.ts";
|
|
@@ -124,11 +124,13 @@ export function registerFreshContext(pi: ExtensionAPI, configDir: string): void
|
|
|
124
124
|
// It may have ended while the oracle was idle (abandoned or archived from the lobby, finished in another window).
|
|
125
125
|
pi.on("before_agent_start", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
|
|
126
126
|
// Compacting a conversation the next request drops anyway would only cost a model call.
|
|
127
|
-
pi.on("session_before_compact", (event) => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
pi.on("session_before_compact", (event) => (staleCompaction(event) ? { cancel: true } : undefined));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** An automatic compaction of the oracle's conversation from before its latest boundary, which the next request drops anyway. */
|
|
131
|
+
export function staleCompaction(event: Pick<SessionBeforeCompactEvent, "reason" | "branchEntries">): boolean {
|
|
132
|
+
if (isSubagentProcess() || event.reason === "manual" || !freshContextOn()) return false;
|
|
133
|
+
const last = contextMarks(event.branchEntries).at(-1);
|
|
134
|
+
if (!last) return false;
|
|
135
|
+
return !event.branchEntries.some((entry) => entry.type === "message" && Date.parse(entry.timestamp) >= last.at);
|
|
134
136
|
}
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -18,7 +18,7 @@ const TOP_LEVEL_BULLET = /^()([*-])\s+(.*\S)\s*$/;
|
|
|
18
18
|
const STEP_HEADING = /^\s*(?:#{1,6}\s+)?(?:\*\*)?\s*(?:step|phase|stage)\s+#?\d+\s*(?:\*\*)?\s*[:.)\u2014\u2013-]\s*(?:\*\*)?\s*(.*?)\s*(?:\*\*)?\s*$/i;
|
|
19
19
|
const MIN_STEP_HEADINGS = 2;
|
|
20
20
|
|
|
21
|
-
export type PlanStepStatus = "done" | "current" | "pending";
|
|
21
|
+
export type PlanStepStatus = "done" | "current" | "pending" | "skipped";
|
|
22
22
|
|
|
23
23
|
export interface PlanStep {
|
|
24
24
|
text: string;
|
|
@@ -355,8 +355,8 @@ function byStart(runs: readonly AgentRun[]): AgentRun[] {
|
|
|
355
355
|
* grows and a failure can never tick one off. The latest worker run's own target
|
|
356
356
|
* is reported separately so a running or failed step reads current.
|
|
357
357
|
*/
|
|
358
|
-
function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { completed: number
|
|
359
|
-
const completed = new Set<number>();
|
|
358
|
+
function replaySteps(steps: readonly string[], runs: readonly AgentRun[], skipped: ReadonlySet<number>): { completed: Set<number>; latest: number } {
|
|
359
|
+
const completed = new Set<number>(skipped);
|
|
360
360
|
const latestRun = latestWorkerRun(runs);
|
|
361
361
|
let latest = -1;
|
|
362
362
|
for (const run of byStart(runs)) {
|
|
@@ -367,27 +367,43 @@ function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { com
|
|
|
367
367
|
const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
|
|
368
368
|
if (target >= 0) markThrough(completed, target);
|
|
369
369
|
}
|
|
370
|
-
return { completed
|
|
370
|
+
return { completed, latest };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The plan steps the user skipped, as indexes: each skip names its step by
|
|
375
|
+
* text, so a skip whose step a revised plan no longer lists drops out.
|
|
376
|
+
*/
|
|
377
|
+
export function skippedIndexes(steps: readonly string[], skipped: readonly { text: string }[] | undefined): Set<number> {
|
|
378
|
+
const found = new Set<number>();
|
|
379
|
+
if (!skipped?.length) return found;
|
|
380
|
+
const wanted = new Set(skipped.map((entry) => normalize(entry.text)));
|
|
381
|
+
steps.forEach((text, index) => { if (wanted.has(normalize(text))) found.add(index); });
|
|
382
|
+
return found;
|
|
371
383
|
}
|
|
372
384
|
|
|
373
385
|
/** One-entry memo: the panel asks for the same checklist several times per frame. */
|
|
374
|
-
let checklistMemo: { plan: string; runs: readonly AgentRun[]; steps: PlanStep[] } | undefined;
|
|
386
|
+
let checklistMemo: { plan: string; runs: readonly AgentRun[]; skipped: readonly { text: string }[] | undefined; steps: PlanStep[] } | undefined;
|
|
375
387
|
|
|
376
388
|
/**
|
|
377
389
|
* Done/current/pending per plan step. A succeeded run completes its step, so the
|
|
378
390
|
* next step becomes current (and the last step reads done); running, failed,
|
|
379
391
|
* cancelled and timeout runs keep it current. Progress is monotonic: steps
|
|
380
392
|
* already completed by successful worker runs stay done, and a later call that
|
|
381
|
-
* names an earlier step can never tick it back.
|
|
382
|
-
* and
|
|
393
|
+
* names an earlier step can never tick it back. A step the user skipped reads
|
|
394
|
+
* skipped and counts as behind the work: the next open step becomes current.
|
|
395
|
+
* Memoized on the exact plan text, runs and skips, so callers must not mutate them.
|
|
383
396
|
*/
|
|
384
|
-
export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep[] {
|
|
385
|
-
if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs) return checklistMemo.steps;
|
|
397
|
+
export function planChecklist(plan: string, runs: readonly AgentRun[], skips?: readonly { text: string }[]): PlanStep[] {
|
|
398
|
+
if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs && checklistMemo.skipped === skips) return checklistMemo.steps;
|
|
386
399
|
const texts = planSteps(plan);
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
400
|
+
const skipped = skippedIndexes(texts, skips);
|
|
401
|
+
const replay = replaySteps(texts, runs, skipped);
|
|
402
|
+
const open = nextOpenStep(texts, replay.completed);
|
|
403
|
+
let current = Math.max(open < 0 ? texts.length : open, replay.latest);
|
|
404
|
+
while (skipped.has(current)) current += 1;
|
|
405
|
+
const steps = texts.map((text, index) => ({ text, status: skipped.has(index) ? "skipped" as const : stepStatus(index, current) }));
|
|
406
|
+
checklistMemo = { plan, runs, skipped: skips, steps };
|
|
391
407
|
return steps;
|
|
392
408
|
}
|
|
393
409
|
|
|
@@ -420,11 +436,11 @@ export function runSteps(steps: readonly string[], instruction: string | undefin
|
|
|
420
436
|
* step it works on (a `Steps 2-4` run toward all three), and its success
|
|
421
437
|
* completes them, so a later run that names no step goes to the next one.
|
|
422
438
|
*/
|
|
423
|
-
export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now()): StepWork[] {
|
|
439
|
+
export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now(), skipped: ReadonlySet<number> = new Set()): StepWork[] {
|
|
424
440
|
const work: StepWork[] = steps.map(() => ({ ms: 0, active: false, activeAgentCount: 0, runIds: [] }));
|
|
425
441
|
const assigned = new Map<string, number[]>();
|
|
426
442
|
const counted = new Set<string>();
|
|
427
|
-
const completed = new Set<number>();
|
|
443
|
+
const completed = new Set<number>(skipped);
|
|
428
444
|
for (const run of byStart(runs)) {
|
|
429
445
|
if (run.role !== "worker") continue;
|
|
430
446
|
if (counted.has(run.runId)) continue;
|
|
@@ -449,3 +465,19 @@ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], no
|
|
|
449
465
|
}
|
|
450
466
|
return work;
|
|
451
467
|
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* What the oracle reads of the steps the user skipped: each by its number in
|
|
471
|
+
* the plan, with the user's reason, and that none of them is to be built.
|
|
472
|
+
* Empty when nothing is skipped (or a revised plan no longer lists the step).
|
|
473
|
+
*/
|
|
474
|
+
export function skippedStepsNote(plan: string | undefined, skips: readonly { text: string; reason: string }[] | undefined): string {
|
|
475
|
+
if (!plan || !skips?.length) return "";
|
|
476
|
+
const steps = planSteps(plan);
|
|
477
|
+
const lines = skips.flatMap((skip) => {
|
|
478
|
+
const index = steps.findIndex((text) => normalize(text) === normalize(skip.text));
|
|
479
|
+
return index < 0 ? [] : [`- Step ${index + 1}: ${skip.text} (reason: ${skip.reason})`];
|
|
480
|
+
});
|
|
481
|
+
if (lines.length === 0) return "";
|
|
482
|
+
return `Steps the user skipped (do not delegate, build or review them; the task may complete without them, and the next open step carries on):\n${lines.join("\n")}`;
|
|
483
|
+
}
|
package/src/schemas/task.ts
CHANGED
|
@@ -207,6 +207,18 @@ export interface TaskGit {
|
|
|
207
207
|
path?: string;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** A plan step the user chose to skip, with the reason they gave; matched to the plan by its text. */
|
|
211
|
+
export interface SkippedStep {
|
|
212
|
+
/** The step's text as the plan lists it. */
|
|
213
|
+
text: string;
|
|
214
|
+
/** Why the user skipped it, in their words (required). */
|
|
215
|
+
reason: string;
|
|
216
|
+
at: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Longest skip reason kept. */
|
|
220
|
+
export const MAX_SKIP_REASON = 1000;
|
|
221
|
+
|
|
210
222
|
export interface PhaseTiming {
|
|
211
223
|
phase: TaskState;
|
|
212
224
|
elapsedMs: number;
|
|
@@ -265,6 +277,8 @@ export interface Task {
|
|
|
265
277
|
qaRisk?: QaRiskAssessment;
|
|
266
278
|
/** Set only by the user: the work is accepted with the lint errors it has, so the task may complete. */
|
|
267
279
|
lintWaiver?: { at: string };
|
|
280
|
+
/** Plan steps the user skipped, each with their reason; the oracle is told not to work on them. */
|
|
281
|
+
skippedSteps?: SkippedStep[];
|
|
268
282
|
}
|
|
269
283
|
|
|
270
284
|
export function createTask(
|
package/src/state/persistence.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function saveTask(root: string, configDir: string, task: Task): void {
|
|
|
104
104
|
rememberTiming(task);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Explicit user waivers and resolved approvals must survive an in-flight worker's snapshot. */
|
|
107
|
+
/** Explicit user waivers, skipped steps and resolved approvals must survive an in-flight worker's snapshot. */
|
|
108
108
|
function preserveUserInterventions(task: Task, latest: Task): void {
|
|
109
109
|
const newlyWaived = !task.qaWaiver && latest.qaWaiver;
|
|
110
110
|
task.qaWaiver ??= latest.qaWaiver;
|
|
@@ -117,6 +117,9 @@ function preserveUserInterventions(task: Task, latest: Task): void {
|
|
|
117
117
|
const resolved = latest.approvals.find((entry) => entry.id === approval.id && entry.status !== "pending");
|
|
118
118
|
if (approval.status === "pending" && resolved) Object.assign(approval, resolved);
|
|
119
119
|
}
|
|
120
|
+
const skipped = new Set((task.skippedSteps ?? []).map((entry) => entry.text));
|
|
121
|
+
const newSkips = (latest.skippedSteps ?? []).filter((entry) => !skipped.has(entry.text));
|
|
122
|
+
if (newSkips.length > 0) task.skippedSteps = [...(task.skippedSteps ?? []), ...newSkips];
|
|
120
123
|
const keys = new Set(task.decisions.map((entry) => JSON.stringify(entry)));
|
|
121
124
|
task.decisions.push(...latest.decisions.filter((entry) => !keys.has(JSON.stringify(entry))));
|
|
122
125
|
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -15,13 +15,15 @@ import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
|
|
|
15
15
|
import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
|
|
16
16
|
import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave, plannerPrevious, plannerPreviousArchive, plannerPreviousDelete, plannerPreviousGet, plannerPreviousOpen } from "./planner.ts";
|
|
17
17
|
import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
|
|
18
|
-
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver, tasksAccept, tasksForceClose, tasksResolveApproval } from "./tasks.ts";
|
|
18
|
+
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver, tasksAccept, tasksSkipStep, tasksForceClose, tasksResolveApproval } from "./tasks.ts";
|
|
19
19
|
import { knowledgeAdd, knowledgeAsk, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
|
|
20
20
|
import { plansDiscard, plansGet, plansStart } from "./plans.ts";
|
|
21
21
|
import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
|
|
22
22
|
import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
|
|
23
|
-
import { sessionsAnswer, sessionsChat, sessionsList, sessionsMessage, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
|
|
23
|
+
import { sessionsAnswer, sessionsArchive, sessionsChat, sessionsList, sessionsMessage, sessionsRename, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
|
|
24
24
|
import { settingsGet, settingsLinters, settingsSet } from "./settings.ts";
|
|
25
|
+
import { terminalClose, terminalInput, terminalList, terminalOpen, terminalResize } from "./terminal.ts";
|
|
26
|
+
import { MAX_INPUT_CHARS } from "../terminal.ts";
|
|
25
27
|
import { themesChoose, themesGet, themesRemove, themesRename, themesSave } from "./themes.ts";
|
|
26
28
|
import { noticeText, pushNotice } from "../notices.ts";
|
|
27
29
|
|
|
@@ -134,6 +136,10 @@ function buildRoutes(): Record<string, Route> {
|
|
|
134
136
|
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
135
137
|
run: (body, ctx) => tasksAccept(body as { taskId: string }, ctx),
|
|
136
138
|
};
|
|
139
|
+
routes["tasks.skipStep"] = {
|
|
140
|
+
schema: Type.Object({ taskId: TaskId, step: Type.Integer({ minimum: 0, maximum: 49 }), text: Type.String({ minLength: 1, maxLength: 20_000 }), reason: Type.String({ minLength: 1, maxLength: 1000 }) }, { additionalProperties: false }),
|
|
141
|
+
run: (body, ctx) => tasksSkipStep(body as Parameters<typeof tasksSkipStep>[0], ctx),
|
|
142
|
+
};
|
|
137
143
|
routes["tasks.forceClose"] = {
|
|
138
144
|
schema: Type.Object({ taskId: TaskId, title: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
|
|
139
145
|
run: (body, ctx) => tasksForceClose(body as { taskId: string; title: string }, ctx),
|
|
@@ -219,6 +225,34 @@ function buildRoutes(): Record<string, Route> {
|
|
|
219
225
|
schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), claimTaskId: Type.Optional(TaskId) }, { additionalProperties: false }),
|
|
220
226
|
run: (body, ctx) => sessionsSwitch(body as { key?: string; sessionId?: string; claimTaskId?: string }, ctx),
|
|
221
227
|
};
|
|
228
|
+
const TerminalId = Type.String({ minLength: 1, maxLength: 40 });
|
|
229
|
+
const Cols = Type.Integer({ minimum: 2, maximum: 500 });
|
|
230
|
+
const Rows = Type.Integer({ minimum: 2, maximum: 300 });
|
|
231
|
+
routes["terminal.list"] = { schema: Empty, run: () => terminalList() };
|
|
232
|
+
routes["terminal.open"] = {
|
|
233
|
+
schema: Type.Object({ cols: Cols, rows: Rows }, { additionalProperties: false }),
|
|
234
|
+
run: (body, ctx) => terminalOpen(body as { cols: number; rows: number }, ctx),
|
|
235
|
+
};
|
|
236
|
+
routes["terminal.input"] = {
|
|
237
|
+
schema: Type.Object({ id: TerminalId, data: Type.String({ minLength: 1, maxLength: MAX_INPUT_CHARS }) }, { additionalProperties: false }),
|
|
238
|
+
run: (body) => terminalInput(body as { id: string; data: string }),
|
|
239
|
+
};
|
|
240
|
+
routes["terminal.resize"] = {
|
|
241
|
+
schema: Type.Object({ id: TerminalId, cols: Cols, rows: Rows }, { additionalProperties: false }),
|
|
242
|
+
run: (body) => terminalResize(body as { id: string; cols: number; rows: number }),
|
|
243
|
+
};
|
|
244
|
+
routes["terminal.close"] = {
|
|
245
|
+
schema: Type.Object({ id: TerminalId }, { additionalProperties: false }),
|
|
246
|
+
run: (body) => terminalClose(body as { id: string }),
|
|
247
|
+
};
|
|
248
|
+
routes["sessions.rename"] = {
|
|
249
|
+
schema: Type.Object({ key: Type.Optional(TaskId), name: Type.String({ minLength: 1, maxLength: 500 }) }, { additionalProperties: false }),
|
|
250
|
+
run: (body, ctx) => sessionsRename(body as { key?: string; name: string }, ctx),
|
|
251
|
+
};
|
|
252
|
+
routes["sessions.archive"] = {
|
|
253
|
+
schema: Type.Object({ key: TaskId, archived: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
254
|
+
run: (body, ctx) => sessionsArchive(body as { key: string; archived?: boolean }, ctx),
|
|
255
|
+
};
|
|
222
256
|
routes["sessions.answer"] = {
|
|
223
257
|
schema: Type.Object({ key: TaskId, dialogId: TaskId, answer: DialogAnswer }, { additionalProperties: false }),
|
|
224
258
|
run: (body, ctx) => sessionsAnswer(body as { key: string; dialogId: string; answer: { value: string } | { confirmed: boolean } | { cancelled: true } }, ctx),
|
package/src/webui/api/lobby.ts
CHANGED
|
@@ -22,7 +22,7 @@ function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotT
|
|
|
22
22
|
const active = steps.filter((step) => step.active);
|
|
23
23
|
const current = steps.find((step) => step.status === "current");
|
|
24
24
|
return {
|
|
25
|
-
progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
|
|
25
|
+
progress: { done: steps.filter((step) => step.status === "done" || step.status === "skipped").length, total: steps.length },
|
|
26
26
|
...(current ? { currentStep: current.text } : {}),
|
|
27
27
|
...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, runIds: step.runIds })) } : {}),
|
|
28
28
|
...clock,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* right now and the work time. Kept here (not via `persistedRuns`) so the
|
|
5
5
|
* server never loads the TUI.
|
|
6
6
|
*/
|
|
7
|
-
import { planChecklist, planSteps, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
|
|
7
|
+
import { planChecklist, planSteps, skippedIndexes, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
|
|
8
8
|
import type { AgentRun } from "../../schemas/findings.ts";
|
|
9
9
|
import type { Task } from "../../schemas/task.ts";
|
|
10
10
|
import type { WorkProjection } from "../../state/work-time.ts";
|
|
@@ -44,6 +44,8 @@ export interface StepView {
|
|
|
44
44
|
agents: StepAgent[];
|
|
45
45
|
/** Worker time on the step, in ms. */
|
|
46
46
|
workedMs: number;
|
|
47
|
+
/** The user skipped it: why, and when. */
|
|
48
|
+
skip?: { reason: string; at: string };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export interface StepAgent {
|
|
@@ -62,8 +64,10 @@ export interface StepAgent {
|
|
|
62
64
|
export function stepViews(task: Task, work: WorkProjection | undefined, now = Date.now()): StepView[] {
|
|
63
65
|
if (!task.plan) return [];
|
|
64
66
|
const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
+
const texts = planSteps(task.plan);
|
|
68
|
+
const checklist = planChecklist(task.plan, runs, task.skippedSteps);
|
|
69
|
+
const times = stepWork(texts, runs, now, skippedIndexes(texts, task.skippedSteps));
|
|
70
|
+
const skipOf = (text: string) => task.skippedSteps?.find((entry) => skippedIndexes([text], [entry]).size > 0);
|
|
67
71
|
const byId = new Map(runs.map((run) => [run.runId, run]));
|
|
68
72
|
return checklist.map((step, index) => {
|
|
69
73
|
const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0, runIds: [] };
|
|
@@ -71,6 +75,7 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
|
|
|
71
75
|
const run = byId.get(runId);
|
|
72
76
|
return run ? [{ runId, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), domain: run.domain, instruction: (run.instruction ?? "").split("\n")[0]!.trim(), startedAt: run.startedAt }] : [];
|
|
73
77
|
});
|
|
74
|
-
|
|
78
|
+
const skip = step.status === "skipped" ? skipOf(step.text) : undefined;
|
|
79
|
+
return { text: step.text, status: time.active && !skip ? "current" : step.status, active: time.active && !skip, activeAgentCount: time.activeAgentCount, runIds: time.runIds, agents, workedMs: Math.round(time.ms), ...(skip ? { skip: { reason: skip.reason, at: skip.at } } : {}) };
|
|
75
80
|
});
|
|
76
81
|
}
|
|
@@ -114,3 +114,24 @@ export function sessionsAnswer(body: { key: string; dialogId: string; answer: Di
|
|
|
114
114
|
session!.answer(body.dialogId, body.answer);
|
|
115
115
|
return { notice: "answer sent" };
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Rename this window's session (no key) or a background one. */
|
|
119
|
+
export function sessionsRename(body: { key?: string; name: string }, ctx: ApiContext): { notice: string } {
|
|
120
|
+
if (!ctx.service.renameSession) fail(501, "unsupported", "this lobby cannot rename sessions");
|
|
121
|
+
try {
|
|
122
|
+
return { notice: ctx.service.renameSession!(body.key, body.name) };
|
|
123
|
+
} catch (error) {
|
|
124
|
+
fail(/^no session/.test((error as Error).message) ? 404 : 400, /^no session/.test((error as Error).message) ? "not_found" : "bad_request", (error as Error).message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Put an ended background session away from the list (`archived` true, the default), or bring it back. */
|
|
129
|
+
export function sessionsArchive(body: { key: string; archived?: boolean }, ctx: ApiContext): { notice: string } {
|
|
130
|
+
if (!ctx.service.archiveSession) fail(501, "unsupported", "this lobby cannot archive sessions");
|
|
131
|
+
try {
|
|
132
|
+
return { notice: ctx.service.archiveSession!(body.key, body.archived ?? true) };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const message = (error as Error).message;
|
|
135
|
+
fail(/^no /.test(message) ? 404 : 409, /^no /.test(message) ? "not_found" : "conflict", message);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -70,7 +70,7 @@ function checkOf(task: Task): TaskRow["check"] {
|
|
|
70
70
|
function progressOf(task: Task, work: WorkProjection | undefined, now: number): TaskRow["progress"] {
|
|
71
71
|
const steps = stepViews(task, work, now);
|
|
72
72
|
if (steps.length === 0) return undefined;
|
|
73
|
-
return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
|
|
73
|
+
return { done: steps.filter((step) => step.status === "done" || step.status === "skipped").length, total: steps.length };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** The work clock as the page shows it; a finished task's no longer counts. */
|
|
@@ -302,6 +302,15 @@ export async function tasksAccept(body: { taskId: string }, ctx: ApiContext): Pr
|
|
|
302
302
|
return ctx.service.acceptTask(body.taskId);
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/** Skip a plan step with the user's reason; refused once the plan changed under the page. */
|
|
306
|
+
export async function tasksSkipStep(body: { taskId: string; step: number; text: string; reason: string }, ctx: ApiContext): Promise<{ notice: string }> {
|
|
307
|
+
const task = interventionTask(body.taskId, ctx);
|
|
308
|
+
if (!task.plan) fail(400, "bad_request", "this task has no plan yet");
|
|
309
|
+
if (!body.reason.trim()) fail(400, "bad_request", "say why you are skipping this step");
|
|
310
|
+
if (!ctx.service.skipTaskStep) fail(400, "bad_request", "skipping steps is unavailable");
|
|
311
|
+
return ctx.service.skipTaskStep(body.taskId, body.step, body.text, body.reason);
|
|
312
|
+
}
|
|
313
|
+
|
|
305
314
|
export async function tasksForceClose(body: { taskId: string; title: string }, ctx: ApiContext): Promise<{ notice: string }> {
|
|
306
315
|
const task = interventionTask(body.taskId, ctx);
|
|
307
316
|
if (body.title !== task.title) fail(400, "bad_request", "the title must match exactly");
|
|
@@ -337,6 +346,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
|
|
|
337
346
|
const details = found.plan ? planDetails(found.plan) : undefined;
|
|
338
347
|
return {
|
|
339
348
|
canAccept: Boolean(active && ctx.service.acceptTask) && acceptable(found),
|
|
349
|
+
canSkip: Boolean(active && ctx.service.skipTaskStep && found.plan) && !TERMINAL_STATES.includes(found.state),
|
|
340
350
|
canForceClose: Boolean(active && ctx.service.forceCloseTask) && !TERMINAL_STATES.includes(found.state) && (ctx.service.canForceCloseTask?.(found.id) ?? true),
|
|
341
351
|
timing: projectPhaseTiming(found, new Date(now).toISOString()),
|
|
342
352
|
...(clock ? { work: clock } : {}),
|
|
@@ -348,6 +358,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
|
|
|
348
358
|
steps: stepViews(found, work, now).map((step) => ({
|
|
349
359
|
text: step.text,
|
|
350
360
|
status: step.status === "pending" ? "open" : step.status,
|
|
361
|
+
...(step.skip ? { skip: step.skip } : {}),
|
|
351
362
|
...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, agents: step.agents } : {}),
|
|
352
363
|
})),
|
|
353
364
|
amendments: [...found.amendments],
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** `terminal.*`: shells in the project's folder for the page's terminal drawer (see `../terminal.ts`). */
|
|
2
|
+
import { fail, type ApiContext } from "./index.ts";
|
|
3
|
+
import { terminalBackend, terminals, TerminalError } from "../terminal.ts";
|
|
4
|
+
import type { TerminalInfo } from "../protocol.ts";
|
|
5
|
+
|
|
6
|
+
function guard<T>(run: () => T): T {
|
|
7
|
+
try {
|
|
8
|
+
return run();
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error instanceof TerminalError) fail(error.missing ? 404 : 409, error.missing ? "not_found" : "conflict", error.message);
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function terminalList(): Promise<{ available: boolean; reason?: string; terminals: TerminalInfo[] }> {
|
|
16
|
+
const backend = await terminalBackend();
|
|
17
|
+
return { available: backend.kind !== "none", ...(backend.kind === "none" ? { reason: backend.reason } : {}), terminals: terminals.list() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function terminalOpen(body: { cols: number; rows: number }, ctx: ApiContext): Promise<TerminalInfo> {
|
|
21
|
+
try {
|
|
22
|
+
return await terminals.open(ctx.service.projectRoot?.() ?? process.cwd(), body.cols, body.rows);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error instanceof TerminalError) fail(409, "conflict", error.message);
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function terminalInput(body: { id: string; data: string }): Record<string, never> {
|
|
30
|
+
guard(() => terminals.input(body.id, body.data));
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function terminalResize(body: { id: string; cols: number; rows: number }): Record<string, never> {
|
|
35
|
+
guard(() => terminals.resize(body.id, body.cols, body.rows));
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function terminalClose(body: { id: string }): Record<string, never> {
|
|
40
|
+
guard(() => terminals.close(body.id));
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
@@ -9,6 +9,7 @@ import { LobbyFeed } from "../../lobby/feed.ts";
|
|
|
9
9
|
import { promptHub } from "../../lobby/prompt-hub.ts";
|
|
10
10
|
import { lobbyTopics } from "../../lobby/topics.ts";
|
|
11
11
|
import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby/sessions.ts";
|
|
12
|
+
import { cleanSessionName } from "../../lobby/sessions.ts";
|
|
12
13
|
import type { PlanComment } from "../../state/comments.ts";
|
|
13
14
|
import type { LobbyService } from "../../lobby/host.ts";
|
|
14
15
|
import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
|
|
@@ -280,11 +281,16 @@ function fakeBackground(entry: NonNullable<ScenarioFixture["backgroundSessions"]
|
|
|
280
281
|
stop() {
|
|
281
282
|
status = "exited";
|
|
282
283
|
dialogs.length = 0;
|
|
284
|
+
lobbyTopics.bump("sessions");
|
|
283
285
|
},
|
|
284
286
|
answer(id: string, _answer: DialogAnswer) {
|
|
285
287
|
const index = dialogs.findIndex((dialog) => dialog.id === id);
|
|
286
288
|
if (index >= 0) dialogs.splice(index, 1);
|
|
287
289
|
},
|
|
290
|
+
rename(name: string) {
|
|
291
|
+
session.name = name;
|
|
292
|
+
lobbyTopics.bump("sessions");
|
|
293
|
+
},
|
|
288
294
|
};
|
|
289
295
|
return session as unknown as BackgroundSession;
|
|
290
296
|
}
|
|
@@ -335,6 +341,8 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
335
341
|
const auto = new Set(fixture.autoTasks ?? []);
|
|
336
342
|
const comments = new Map<string, Array<Record<string, unknown>>>(Object.entries(fixture.taskComments ?? {}));
|
|
337
343
|
const backgrounds = (fixture.backgroundSessions ?? []).map(fakeBackground);
|
|
344
|
+
const archivedBackgrounds: BackgroundSession[] = [];
|
|
345
|
+
let sessionName = fixture.status.sessionName;
|
|
338
346
|
let planning = fakePlanner(fixture.mockPlanner ?? defaultPlanner());
|
|
339
347
|
let config: BotLobbyConfig = resolveConfig(fixture.settings?.config ?? {});
|
|
340
348
|
const models = fixture.settings?.models ?? DEFAULT_MODELS;
|
|
@@ -391,7 +399,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
391
399
|
let commentSeq = 0;
|
|
392
400
|
const service = {
|
|
393
401
|
sessionId: () => fixture.status.sessionId,
|
|
394
|
-
sessionName: () =>
|
|
402
|
+
sessionName: () => sessionName,
|
|
395
403
|
masterBusy: () => fixture.status.busy,
|
|
396
404
|
config: () => config,
|
|
397
405
|
linters: () => fixture.settings?.linters ?? [{ tool: "ESLint", folder: "", installed: true }, { tool: "Ruff", folder: "services/api", installed: false }],
|
|
@@ -481,6 +489,15 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
481
489
|
},
|
|
482
490
|
sendToTask: (taskId: string, _text: string) => (tasks.some((task) => (task as { id: string }).id === taskId) ? `sent — the session driving ${taskId} passes it to its oracle` : `no task ${taskId}`),
|
|
483
491
|
sendToSession: (_sessionId: string, _text: string) => "sent — that session passes it to its oracle within a few seconds",
|
|
492
|
+
skipTaskStep: async (taskId: string, step: number, text: string, reason: string) => {
|
|
493
|
+
const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; skippedSteps?: Array<{ text: string; reason: string; at: string }>; decisions?: Array<{ domain: string; text: string; createdAt: string }> } | undefined;
|
|
494
|
+
if (!task) throw new Error(`no task ${taskId}`);
|
|
495
|
+
const at = new Date().toISOString();
|
|
496
|
+
task.skippedSteps = [...(task.skippedSteps ?? []), { text, reason: reason.trim(), at }];
|
|
497
|
+
task.decisions = [...(task.decisions ?? []), { domain: "master", text: `The user skipped step ${step + 1} ("${text}") from the Tasks screen. Reason: ${reason.trim()}`, createdAt: at }];
|
|
498
|
+
lobbyTopics.bump("tasks");
|
|
499
|
+
return { notice: `step ${step + 1} skipped; the oracle has been told` };
|
|
500
|
+
},
|
|
484
501
|
resumeTask: async (taskId: string) => {
|
|
485
502
|
const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; title: string; paused?: boolean; ownerSessionId?: string } | undefined;
|
|
486
503
|
if (!task) return { notice: `no task ${taskId}` };
|
|
@@ -500,6 +517,30 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
500
517
|
return { notice: `resumed ${taskId} in a background session`, key: session.key };
|
|
501
518
|
},
|
|
502
519
|
sessions: () => [...backgrounds],
|
|
520
|
+
renameSession: (key: string | undefined, name: string) => {
|
|
521
|
+
const clean = cleanSessionName(name);
|
|
522
|
+
if (!key) {
|
|
523
|
+
sessionName = clean;
|
|
524
|
+
lobbyTopics.bump("status");
|
|
525
|
+
lobbyTopics.bump("sessions");
|
|
526
|
+
return `renamed this window to ${clean}`;
|
|
527
|
+
}
|
|
528
|
+
const session = backgrounds.find((entry) => entry.key === key);
|
|
529
|
+
if (!session) throw new Error(`no session ${key}`);
|
|
530
|
+
session.rename(clean);
|
|
531
|
+
return `renamed ${key} to ${clean}`;
|
|
532
|
+
},
|
|
533
|
+
archiveSession: (key: string, archived: boolean) => {
|
|
534
|
+
const from = archived ? backgrounds : archivedBackgrounds;
|
|
535
|
+
const index = from.findIndex((entry) => entry.key === key);
|
|
536
|
+
if (index < 0) throw new Error(archived ? `no session ${key} to archive` : `no archived session ${key}`);
|
|
537
|
+
const session = from[index]!;
|
|
538
|
+
if (session.alive) throw new Error(`${session.name} is still running: stop it before archiving it`);
|
|
539
|
+
from.splice(index, 1);
|
|
540
|
+
(archived ? archivedBackgrounds : backgrounds).push(session);
|
|
541
|
+
lobbyTopics.bump("sessions");
|
|
542
|
+
return archived ? `archived ${session.name}` : `brought back ${session.name}`;
|
|
543
|
+
},
|
|
503
544
|
startSession: (start: { request?: string; plan?: { id: string; title: string }; auto?: boolean }) => {
|
|
504
545
|
const key = `S${backgrounds.length + 1}`;
|
|
505
546
|
const session = fakeBackground({ key, name: start.plan?.title ?? start.request?.trim().slice(0, 40) ?? "mock session", status: "starting", ...(start.plan ? { planId: start.plan.id } : {}) });
|
package/src/webui/protocol.ts
CHANGED
|
@@ -218,6 +218,23 @@ export type StreamEvent =
|
|
|
218
218
|
| { type: "reply"; text: string }
|
|
219
219
|
| { type: "notice"; text: string; level: NoticeLevel };
|
|
220
220
|
|
|
221
|
+
/** A terminal the server runs (`terminal.*`): a shell in the project's folder. */
|
|
222
|
+
export interface TerminalInfo {
|
|
223
|
+
id: string;
|
|
224
|
+
title: string;
|
|
225
|
+
cwd: string;
|
|
226
|
+
alive: boolean;
|
|
227
|
+
/** The shell's exit code, once it ended. */
|
|
228
|
+
exitCode?: number;
|
|
229
|
+
startedAt: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** One message on a terminal's stream (`GET /api/terminal.stream?id=…`). */
|
|
233
|
+
export type TerminalStreamEvent =
|
|
234
|
+
| { type: "reset"; data: string; alive: boolean; code?: number }
|
|
235
|
+
| { type: "data"; data: string }
|
|
236
|
+
| { type: "exit"; code?: number };
|
|
237
|
+
|
|
221
238
|
/** The planning session as `planner.get` describes it. */
|
|
222
239
|
export interface PlannerSnapshot {
|
|
223
240
|
/** Seats on the panel next round. */
|
|
@@ -324,6 +341,8 @@ export interface EntryRefInfo {
|
|
|
324
341
|
/** `tasks.get`: one task read whole, in the terminal's wording. */
|
|
325
342
|
export interface TaskDetail {
|
|
326
343
|
canAccept: boolean;
|
|
344
|
+
/** Open steps can be skipped (the user gives a reason). */
|
|
345
|
+
canSkip: boolean;
|
|
327
346
|
canForceClose: boolean;
|
|
328
347
|
timing?: TimingProjection;
|
|
329
348
|
delivery?: Delivery;
|
|
@@ -337,9 +356,9 @@ export interface TaskDetail {
|
|
|
337
356
|
planDetails?: string;
|
|
338
357
|
/**
|
|
339
358
|
* The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
|
|
340
|
-
* each with its worker time (ms, as of the reply).
|
|
359
|
+
* each with its worker time (ms, as of the reply); `skip` carries the reason the user gave for skipping a step.
|
|
341
360
|
*/
|
|
342
|
-
steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
|
|
361
|
+
steps: Array<{ text: string; status: "done" | "current" | "open" | "skipped"; skip?: { reason: string; at: string }; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
|
|
343
362
|
/** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
|
|
344
363
|
work?: WorkClock;
|
|
345
364
|
amendments: string[];
|
|
@@ -520,6 +539,7 @@ export interface Api {
|
|
|
520
539
|
"tasks.open": { request: { taskId: string }; result: { sessionId?: string; key?: string; notice?: string } };
|
|
521
540
|
"tasks.resume": { request: { taskId: string }; result: { notice: string; key?: string } };
|
|
522
541
|
"tasks.accept": { request: { taskId: string }; result: { notice: string } };
|
|
542
|
+
"tasks.skipStep": { request: { taskId: string; step: number; text: string; reason: string }; result: { notice: string } };
|
|
523
543
|
"tasks.forceClose": { request: { taskId: string; title: string }; result: { notice: string } };
|
|
524
544
|
"tasks.resolveApproval": { request: { taskId: string; approvalId: string; decision: "approved" | "rejected"; note?: string }; result: { notice: string } };
|
|
525
545
|
"tasks.deliveryReview": { request: { taskId: string }; result: { delivery: Delivery } };
|
|
@@ -535,6 +555,13 @@ export interface Api {
|
|
|
535
555
|
"sessions.message": { request: { key?: string; sessionId?: string; text: string; attachments?: string[] }; result: { notice?: string } };
|
|
536
556
|
"sessions.switch": { request: { key?: string; sessionId?: string; claimTaskId?: string }; result: { notice: string } };
|
|
537
557
|
"sessions.answer": { request: { key: string; dialogId: string; answer: DialogAnswer }; result: { notice: string } };
|
|
558
|
+
"sessions.rename": { request: { key?: string; name: string }; result: { notice: string } };
|
|
559
|
+
"sessions.archive": { request: { key: string; archived?: boolean }; result: { notice: string } };
|
|
560
|
+
"terminal.list": { request: Record<string, never>; result: { available: boolean; reason?: string; terminals: TerminalInfo[] } };
|
|
561
|
+
"terminal.open": { request: { cols: number; rows: number }; result: TerminalInfo };
|
|
562
|
+
"terminal.input": { request: { id: string; data: string }; result: Record<string, never> };
|
|
563
|
+
"terminal.resize": { request: { id: string; cols: number; rows: number }; result: Record<string, never> };
|
|
564
|
+
"terminal.close": { request: { id: string }; result: Record<string, never> };
|
|
538
565
|
"prompts.list": { request: Record<string, never>; result: { prompts: WebPrompt[] } };
|
|
539
566
|
"prompts.answer": { request: { id: string; answer: unknown }; result: { notice?: string } };
|
|
540
567
|
"prompts.dismiss": { request: { id: string }; result: Record<string, never> };
|
package/src/webui/server.ts
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
sessionCookie,
|
|
28
28
|
sessionValue,
|
|
29
29
|
} from "./auth.ts";
|
|
30
|
-
import { callName, HttpError, routeApiCall, sendError, sendJson } from "./api/index.ts";
|
|
30
|
+
import { applyBaseHeaders, callName, HttpError, routeApiCall, sendError, sendJson } from "./api/index.ts";
|
|
31
|
+
import { terminals } from "./terminal.ts";
|
|
31
32
|
import { distDir, serveStatic } from "./static.ts";
|
|
32
33
|
import { EventHub } from "./events.ts";
|
|
33
34
|
import { servePreview } from "./files.ts";
|
|
@@ -292,6 +293,19 @@ async function route(req: IncomingMessage, res: ServerResponse, state: RouteStat
|
|
|
292
293
|
openStream(req, res, url, state);
|
|
293
294
|
return;
|
|
294
295
|
}
|
|
296
|
+
if (url.pathname === "/api/terminal.stream") {
|
|
297
|
+
if (req.method !== "GET") {
|
|
298
|
+
sendError(res, "unsupported", "only GET", 405);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!isSignedIn(req.headers.cookie, state.session)) {
|
|
302
|
+
sendError(res, "unauthorized", "open the link Pi printed", 401);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
applyBaseHeaders(res);
|
|
306
|
+
if (!terminals.stream(req, res, url.searchParams.get("id") ?? "")) sendError(res, "not_found", "no such terminal", 404);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
295
309
|
await routeApi(req, res, url, state);
|
|
296
310
|
}
|
|
297
311
|
|
|
@@ -388,6 +402,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
|
|
388
402
|
return `http://127.0.0.1:${state.port}/#token=${state.link}`;
|
|
389
403
|
},
|
|
390
404
|
close: async () => {
|
|
405
|
+
terminals.closeAll();
|
|
391
406
|
state.opener!.close();
|
|
392
407
|
state.registry!.close();
|
|
393
408
|
hub.close();
|