@a-t-h-i/bot-lobby 0.6.49 → 0.6.51

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 (52) hide show
  1. package/README.md +2 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -3
  3. package/package.json +9 -7
  4. package/src/classifier/classifier.ts +14 -2
  5. package/src/classifier/hosts.ts +12 -18
  6. package/src/classifier/instance.ts +7 -4
  7. package/src/execution/delegation.ts +2 -1
  8. package/src/execution/pi-runner.ts +11 -2
  9. package/src/lobby/host.ts +6 -0
  10. package/src/lobby/keys.ts +2 -1
  11. package/src/lobby/service.ts +71 -4
  12. package/src/lobby/sessions.ts +35 -0
  13. package/src/pi/auto-compaction.ts +46 -19
  14. package/src/pi/events.ts +2 -0
  15. package/src/pi/fresh-context.ts +10 -8
  16. package/src/pi/plan-checklist.ts +47 -15
  17. package/src/schemas/task.ts +14 -0
  18. package/src/state/persistence.ts +4 -1
  19. package/src/webui/api/index.ts +36 -2
  20. package/src/webui/api/lobby.ts +1 -1
  21. package/src/webui/api/plan-facts.ts +9 -4
  22. package/src/webui/api/sessions.ts +21 -0
  23. package/src/webui/api/tasks.ts +12 -1
  24. package/src/webui/api/terminal.ts +42 -0
  25. package/src/webui/dev/fake-service.ts +42 -1
  26. package/src/webui/protocol.ts +29 -2
  27. package/src/webui/server.ts +16 -1
  28. package/src/webui/terminal.ts +292 -0
  29. package/src/workflow/workflow.ts +3 -2
  30. package/webui/dist/assets/TerminalView-5smhC28e.js +16 -0
  31. package/webui/dist/assets/TerminalView-BrP-ENHg.css +1 -0
  32. package/webui/dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
  33. package/webui/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  34. package/webui/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  35. package/webui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  36. package/webui/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  37. package/webui/dist/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  38. package/webui/dist/assets/index-B_zgVfN5.css +2 -0
  39. package/webui/dist/assets/index-Dahyhy96.js +115 -0
  40. package/webui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. package/webui/dist/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
  42. package/webui/dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 +0 -0
  43. package/webui/dist/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
  44. package/webui/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  45. package/webui/dist/assets/roboto-mono-latin-wght-normal-CZtBPCCa.woff2 +0 -0
  46. package/webui/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
  47. package/webui/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
  48. package/webui/dist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  49. package/webui/dist/build.json +108 -92
  50. package/webui/dist/index.html +2 -2
  51. package/webui/dist/assets/index-C3p3zxoV.js +0 -114
  52. package/webui/dist/assets/index-DE9jHz-C.css +0 -2
package/README.md CHANGED
@@ -12,7 +12,7 @@ pi install npm:@a-t-h-i/bot-lobby
12
12
 
13
13
  Try it first with `pi -e npm:@a-t-h-i/bot-lobby`, or from a checkout with `pi -e ./src/index.ts`.
14
14
  The questionnaire and web tools are built in, so don't also install extensions with the same tool names.
15
- Requires **Pi 1.0.x** (1.0.1 or later).
15
+ Requires **Pi 1.3.x** (1.3.0 or later).
16
16
 
17
17
  ## Use
18
18
 
@@ -51,7 +51,7 @@ or external boards.
51
51
  The composer appears only on **Lobby**, **Tasks**, **Plan**, **Quick fix** and **Knowledge**, including
52
52
  supported detail routes and Knowledge's Docs/Chat views. On Git, Metrics and Settings it stays mounted
53
53
  but hidden and inert, preserving drafts and attachments (including uploads that finish while hidden)
54
- without taking space. Phone navigation remains available.
54
+ without taking space.
55
55
 
56
56
  | Agent | Does |
57
57
  | --- | --- |
@@ -3,7 +3,7 @@
3
3
  ## T3 Code
4
4
 
5
5
  The web UI's look (colours, radii, shadows, control and dialog styles, the
6
- sidebar rail, the chat rows, the work log, the composer surface, the select
6
+ sidebar rail, the chat rows, the work log, the composer (its surface, footer controls and send button), the select
7
7
  fields and drop-down lists, the loading and status animations and the
8
8
  built-in colour themes) is adapted from T3 Code,
9
9
  https://github.com/pingdotgg/t3code. No T3 Code package is a
@@ -32,8 +32,9 @@ naming the file it came from.
32
32
  | `webui/src/components/reui/badge.tsx` | `apps/web/src/components/ui/badge.tsx` |
33
33
  | `webui/src/components/reui/select.tsx` | `apps/web/src/components/ui/select.tsx` |
34
34
  | `webui/src/components/reui/popover.tsx` | `apps/web/src/components/ui/popover.tsx` |
35
- | `webui/src/app/Sidebar.tsx` | `apps/web/src/components/ui/sidebar.tsx` |
36
- | `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx` |
35
+ | `webui/src/app/Sidebar.tsx` | `apps/web/src/components/Sidebar.tsx`, `apps/web/src/components/ui/sidebar.tsx` |
36
+ | `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `ComposerControl.tsx` |
37
+ | `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
37
38
  | `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
38
39
  | `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
39
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.49",
3
+ "version": "0.6.51",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -47,16 +47,16 @@
47
47
  "test": "node --test --test-reporter=dot test/*.test.ts"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": ">=1.0.1 <1.1.0",
51
- "@earendil-works/pi-coding-agent": ">=1.0.1 <1.1.0",
52
- "@earendil-works/pi-tui": ">=1.0.1 <1.1.0",
50
+ "@earendil-works/pi-ai": ">=1.3.0 <1.4.0",
51
+ "@earendil-works/pi-coding-agent": ">=1.3.0 <1.4.0",
52
+ "@earendil-works/pi-tui": ">=1.3.0 <1.4.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@axe-core/playwright": "4.13.0",
57
- "@earendil-works/pi-ai": "1.0.1",
58
- "@earendil-works/pi-coding-agent": "1.0.1",
59
- "@earendil-works/pi-tui": "1.0.1",
57
+ "@earendil-works/pi-ai": "1.3.0",
58
+ "@earendil-works/pi-coding-agent": "1.3.0",
59
+ "@earendil-works/pi-tui": "1.3.0",
60
60
  "@fontsource-variable/dm-sans": "^5.3.0",
61
61
  "@fontsource-variable/figtree": "^5.3.0",
62
62
  "@fontsource-variable/fira-code": "^5.3.0",
@@ -79,6 +79,8 @@
79
79
  "@types/react": "19.3.0",
80
80
  "@types/react-dom": "19.3.0",
81
81
  "@vitejs/plugin-react": "6.1.1",
82
+ "@xterm/addon-fit": "0.11.0",
83
+ "@xterm/xterm": "6.0.0",
82
84
  "class-variance-authority": "0.7.1",
83
85
  "clsx": "2.1.1",
84
86
  "lucide-react": "1.49.0",
@@ -2,7 +2,8 @@
2
2
  * The classifier facade every feature calls. It never throws into the
3
3
  * workflow: any failure (no key, a timeout, an API error, a request over
4
4
  * budget) returns undefined, and the caller falls back to what bot-lobby did
5
- * before the classifier existed. It answers with Jev on its host, or with the
5
+ * before the classifier existed. It answers with Jev on its host (through pi's
6
+ * classifier catalog and logins when pi lists it), or with the
6
7
  * model `classifier.engine` names (any classifier or chat model pi is signed
7
8
  * in to; see `models.ts`). Three failures in a row pause it for ten
8
9
  * minutes with a single warning, so a dead endpoint cannot tax every step.
@@ -12,7 +13,7 @@ import type { ClassifierConfig, ClassifierFeature } from "../schemas/configurati
12
13
  import type { MetricRecord } from "../state/metrics.ts";
13
14
  import { JevError, noul, systemOne, type Answer, type FetchLike, type SystemOneRequest } from "./client.ts";
14
15
  import { fitsBudget, requestSize, LIMITS } from "./limits.ts";
15
- import { keyHint, resolveTarget, type KeySource } from "./hosts.ts";
16
+ import { keyHint, piModelRef, resolveTarget, type KeySource } from "./hosts.ts";
16
17
  import type { EngineAnswer } from "./models.ts";
17
18
 
18
19
  export type ClassifierPurpose = ClassifierFeature | "test";
@@ -32,6 +33,8 @@ export interface ClassifierDeps {
32
33
  keys?: KeySource;
33
34
  /** Answers with the model `classifier.engine` names, through pi's registry; absent before a session starts. */
34
35
  engine?: EngineAnswer;
36
+ /** Whether pi's registry lists a classifier model (`provider/id`); Jev on its host goes through pi when it does. */
37
+ lists?: (ref: string) => boolean;
35
38
  fetch?: FetchLike;
36
39
  sleep?: (ms: number) => Promise<void>;
37
40
  env?: NodeJS.ProcessEnv;
@@ -112,6 +115,15 @@ export class Classifier {
112
115
  }
113
116
  return { ok: false, error };
114
117
  }
118
+ const ref = piModelRef(host, config);
119
+ if (!config.baseUrl && this.deps.engine && this.deps.lists?.(ref)) {
120
+ const result = await this.callEngine(purpose, ref, request, options);
121
+ // A limited-time free model that ends answers 404/410; never switch to the paid one silently.
122
+ if (!result.ok && model.endsWith("-free") && /\b(404|410)\b|not found|gone/i.test(result.error)) {
123
+ return { ok: false, error: `${result.error} — ${host.label}'s free ${model} may have ended; set the classifier's model to ${model.replace(/-free$/, "")} (paid) in /bot-lobby settings to keep using Jev there` };
124
+ }
125
+ return result;
126
+ }
115
127
  const started = this.now();
116
128
  try {
117
129
  const response = await systemOne(request, {
@@ -6,14 +6,17 @@
6
6
  * - OpenCode Zen serves Jev with the OpenCode key pi uses for Zen and Go
7
7
  * (`/login opencode` or `opencode-go`, or OPENCODE_API_KEY), on the free
8
8
  * `jev-1.13-free` model while OpenCode offers it.
9
- * - TypeSafe direct: bot-lobby registers a `typesafe` provider so `/login
10
- * typesafe` stores the key in pi's auth.json.
9
+ * - TypeSafe direct: pi's own `typesafe` provider (`/login typesafe` or
10
+ * TYPESAFE_API_KEY).
11
11
  * - OpenRouter and Vercel AI Gateway use the key pi holds for them.
12
12
  *
13
+ * Every host's Jev is in pi's classifier catalog, so the classifier calls it
14
+ * through pi (`piModel` is its id there); only a custom URL or a model pi does
15
+ * not list goes to the System One API directly (see `classifier.ts`).
16
+ *
13
17
  * `auto` (the default) takes OpenCode when pi holds an OpenCode key, else
14
18
  * TypeSafe.
15
19
  */
16
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
20
  import type { ClassifierConfig, JevHostName } from "../schemas/configuration.ts";
18
21
 
19
22
  export type JevHostId = Exclude<JevHostName, "auto">;
@@ -27,13 +30,15 @@ export interface JevHost {
27
30
  env: string;
28
31
  baseUrl: string;
29
32
  model: string;
33
+ /** The default model's id in pi's classifier catalog, when it differs from `model`. */
34
+ piModel?: string;
30
35
  headers?: Readonly<Record<string, string>>;
31
36
  }
32
37
 
33
38
  export const JEV_HOST_TABLE: Readonly<Record<JevHostId, JevHost>> = Object.freeze({
34
39
  opencode: { name: "opencode", label: "OpenCode Zen", piProviders: ["opencode", "opencode-go"], env: "OPENCODE_API_KEY", baseUrl: "https://opencode.ai/zen", model: "jev-1.13-free" },
35
40
  typesafe: { name: "typesafe", label: "TypeSafe", piProviders: ["typesafe"], env: "TYPESAFE_API_KEY", baseUrl: "https://api.typesafe.ai", model: "jev-latest" },
36
- openrouter: { name: "openrouter", label: "OpenRouter", piProviders: ["openrouter"], env: "OPENROUTER_API_KEY", baseUrl: "https://openrouter.ai/api", model: "jev-latest", headers: { "HTTP-Referer": "https://github.com/a-t-h-i/bot-lobby", "X-Title": "bot-lobby" } },
41
+ openrouter: { name: "openrouter", label: "OpenRouter", piProviders: ["openrouter"], env: "OPENROUTER_API_KEY", baseUrl: "https://openrouter.ai/api", model: "jev-latest", piModel: "~typesafe/jev-latest", headers: { "HTTP-Referer": "https://github.com/a-t-h-i/bot-lobby", "X-Title": "bot-lobby" } },
37
42
  vercel: { name: "vercel", label: "Vercel AI Gateway", piProviders: ["vercel-ai-gateway"], env: "AI_GATEWAY_API_KEY", baseUrl: "https://ai-gateway.vercel.sh/typesafe", model: "typesafe-ai/jev" },
38
43
  });
39
44
 
@@ -129,18 +134,7 @@ export function describeKey(host: JevHost, status: StatusSource | undefined, env
129
134
  return `missing: ${host.name === "typesafe" ? "/login typesafe → Use an API key" : `/login ${host.piProviders[0]}`}, or set ${host.env}`;
130
135
  }
131
136
 
132
- /**
133
- * Register TypeSafe as a pi provider with no chat models, so `/login
134
- * typesafe` stores the Jev key with pi's own locking and `/logout` removes
135
- * it. Nothing is added to `/model`. A pi that refuses the registration only
136
- * loses the login entry: the environment variable still works.
137
- */
138
- export function registerJevProvider(pi: ExtensionAPI): boolean {
139
- const host = JEV_HOST_TABLE.typesafe;
140
- try {
141
- pi.registerProvider(host.piProviders[0]!, { name: "TypeSafe (Jev classifier)", baseUrl: host.baseUrl, apiKey: `$${host.env}`, models: [] });
142
- return true;
143
- } catch {
144
- return false;
145
- }
137
+ /** The `provider/id` pi lists a host's Jev under: the host's first pi provider, with the config's model if it names one. */
138
+ export function piModelRef(host: JevHost, config: Pick<ClassifierConfig, "model">): string {
139
+ return `${host.piProviders[0]}/${config.model || host.piModel || host.model}`;
146
140
  }
@@ -11,7 +11,7 @@ import { isSubagentProcess } from "../pi/quiet.ts";
11
11
  import { Classifier } from "./classifier.ts";
12
12
  import { scriptPicker } from "./scripts.ts";
13
13
  import type { ScriptPicker } from "../scripts/pick.ts";
14
- import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
14
+ import type { KeySource, KeyStatus } from "./hosts.ts";
15
15
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
16
16
  import { withRepoMap } from "../knowledge/repo-map.ts";
17
17
  import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
@@ -19,7 +19,7 @@ import { lobbyFeed } from "../lobby/feed.ts";
19
19
  import { triageLine, triageWithContext } from "./triage.ts";
20
20
  import type { TaskTriage } from "../schemas/task.ts";
21
21
  import { effortRouter, type EffortRouter } from "./effort.ts";
22
- import { engineAnswer, engineClassifiers, type EngineAnswer, type EngineModel, type Registry } from "./models.ts";
22
+ import { engineAnswer, engineClassifiers, splitRef, type EngineAnswer, type EngineModel, type Registry } from "./models.ts";
23
23
 
24
24
  interface Binding {
25
25
  cwd: string;
@@ -43,6 +43,10 @@ export function classifier(): Classifier {
43
43
  if (!binding) throw new Error(`${ref} is not reachable before a pi session starts`);
44
44
  return binding.engine(ref, request, options);
45
45
  },
46
+ lists: (ref) => {
47
+ const parts = splitRef(ref);
48
+ return Boolean(parts && binding?.registry.findOfType("classifier", parts.provider, parts.id));
49
+ },
46
50
  metrics: (record) => {
47
51
  if (binding) appendMetrics(binding.root, binding.configDir, [record]);
48
52
  },
@@ -114,9 +118,8 @@ export function availableClassifiers(): EngineModel[] {
114
118
  return classifierModels;
115
119
  }
116
120
 
117
- /** Register the `typesafe` login entry and bind the classifier to each session's key store. */
121
+ /** Bind the classifier to each session's key store and model registry. */
118
122
  export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
119
- registerJevProvider(pi);
120
123
  pi.on("session_start", (_event, ctx) => {
121
124
  binding = {
122
125
  cwd: ctx.cwd,
@@ -75,7 +75,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
75
75
  const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : [])])];
76
76
  const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
77
77
  const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
78
- if (depth > 1) return { tools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
78
+ if (depth > 1) return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
79
79
 
80
80
  const address = options.env?.[DESK_ENV.address];
81
81
  let desk = address ? deskSessionAt(address) : undefined;
@@ -189,6 +189,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
189
189
  let closed = false;
190
190
  return {
191
191
  tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
192
+ mcpTools,
192
193
  env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
193
194
  note: `${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
194
195
  onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
@@ -362,12 +362,21 @@ export function finishedThought(line: string): string | undefined {
362
362
  return text.length > MAX_THOUGHT_CHARS ? `${text.slice(0, MAX_THOUGHT_CHARS - 1)}…` : text;
363
363
  }
364
364
 
365
+ /** pi's MCP resource tools; the agent policy lets them reach only servers with a granted tool. */
366
+ export const MCP_RESOURCE_TOOLS: readonly string[] = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
367
+
365
368
  /** Build the `pi` argv for one isolated, headless RPC agent run; the task goes over stdin. */
366
369
  export function buildPiArgs(options: Omit<PiRunOptions, "task"> & { systemPromptFile?: string }): string[] {
367
370
  const args = ["--mode", "rpc", "--no-session", "--no-prompt-templates", "--no-themes", "--extension", fileURLToPath(new URL("../index.ts", import.meta.url))];
368
371
  if (options.model && options.model !== "inherit") args.push("--model", options.model);
369
372
  if (options.thinking) args.push("--thinking", options.thinking);
370
- if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
373
+ if (options.tools && options.tools.length > 0) {
374
+ // pi keeps every MCP tool under --tools unless an entry names one, so name the granted ones (and the resource
375
+ // tools their servers need), or run without MCP at all when nothing is granted.
376
+ const mcp = options.mcpTools?.filter((name) => name.startsWith("mcp__")) ?? [];
377
+ args.push("--tools", [...options.tools, ...(mcp.length ? [...mcp, ...MCP_RESOURCE_TOOLS] : [])].join(","));
378
+ if (!mcp.length) args.push("--no-mcp");
379
+ }
371
380
  if (options.systemPromptFile) args.push("--append-system-prompt", options.systemPromptFile);
372
381
  return args;
373
382
  }
@@ -775,7 +784,7 @@ export async function runPiAgent(given: PiRunOptions, run: ProcessRunner = spawn
775
784
  try {
776
785
  prompt = writePromptFile([options.systemPrompt, host.note].filter(Boolean).join("\n\n"));
777
786
  if (options.signal?.aborted) return { status: "cancelled", output: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
778
- const args = buildPiArgs({ ...options, tools: host.tools, systemPromptFile: prompt.file });
787
+ const args = buildPiArgs({ ...options, tools: host.tools, mcpTools: host.mcpTools, systemPromptFile: prompt.file });
779
788
  const outcome = await run(args, {
780
789
  cwd: options.cwd,
781
790
  signal: options.signal,
package/src/lobby/host.ts CHANGED
@@ -160,6 +160,10 @@ export interface LobbyService {
160
160
  sessionName(): string | undefined;
161
161
  /** Background sessions this window started, oldest first. */
162
162
  sessions(): readonly BackgroundSession[];
163
+ /** Rename this window's session (no key) or a background one; returns a notice (throws when it cannot). */
164
+ renameSession?(key: string | undefined, name: string): string;
165
+ /** Put an ended background session away from the list, or bring it back; returns a notice (throws when it cannot). */
166
+ archiveSession?(key: string, archived: boolean): string;
163
167
  /** Start a task in a new background session, named after the task; the session, or why not. */
164
168
  startSession(start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string;
165
169
  /** Auto mode for any task, whichever session drives it. */
@@ -174,6 +178,8 @@ export interface LobbyService {
174
178
  */
175
179
  resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
176
180
  acceptTask?(taskId: string): Promise<{ notice: string }>;
181
+ /** Skip a plan step (0-based, named by its text too) with the user's reason. */
182
+ skipTaskStep?(taskId: string, step: number, text: string, reason: string): Promise<{ notice: string }>;
177
183
  forceCloseTask?(taskId: string, title: string): Promise<{ notice: string }>;
178
184
  canForceCloseTask?(taskId: string): boolean;
179
185
  resolveTaskApproval?(taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }>;
package/src/lobby/keys.ts CHANGED
@@ -9,9 +9,10 @@ export const LOBBY_ACTIONS = {
9
9
  help: { key: "alt+h", help: "show or hide this list" },
10
10
  settings: { key: "alt+s", help: "settings: each agent's model and effort" },
11
11
  savePlan: { key: "ctrl+s", help: "save the plan from the Plan tab" },
12
- sidebar: { key: "alt+b", help: "open the sidebar on a phone" },
12
+ sidebar: { key: "alt+b", help: "fold or open the sidebar (on a phone, open it)" },
13
13
  activity: { key: "alt+a", help: "open or fold every step in the Lobby conversation" },
14
14
  thinking: { key: "alt+t", help: "show or hide Thinking on the Lobby" },
15
+ terminal: { key: "ctrl+`", help: "show or hide the terminal" },
15
16
  nextTab: { key: "alt+]", help: "next tab" },
16
17
  prevTab: { key: "alt+[", help: "previous tab" },
17
18
  } as const;
@@ -4,7 +4,7 @@
4
4
  * the prompt hub (see ../ask/web.ts).
5
5
  */
6
6
  import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
7
+ import { MAX_SKIP_REASON, TERMINAL_STATES, type Task } from "../schemas/task.ts";
8
8
  import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { loadTask, ownedTask, peekOwnedTask, peekTasks, saveTask } from "../state/persistence.ts";
@@ -36,7 +36,7 @@ import { pendingRequest, startRequest } from "../pi/route.ts";
36
36
  import { deliverComments, setAuto } from "../pi/owner.ts";
37
37
  import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
38
38
  import { availableClassifiers, classifier, effortFor, hintsFor } from "../classifier/instance.ts";
39
- import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
39
+ import { cleanSessionName, launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
40
40
  import { askForSession } from "./session-questions.ts";
41
41
  import { SessionChats } from "./session-files.ts";
42
42
  import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
@@ -55,6 +55,8 @@ import type { Runtime } from "./runtime.ts";
55
55
  import { findLinters } from "../execution/lint.ts";
56
56
  import { handleResolveApproval, lastQaAsks, waiveLint, waiveQa } from "../workflow/workflow.ts";
57
57
  import { recordDecision } from "../master/decisions.ts";
58
+ import { stepViews } from "../webui/api/plan-facts.ts";
59
+ import { truncate } from "../text.ts";
58
60
  import { fail } from "../webui/api/index.ts";
59
61
 
60
62
  /** The state background callbacks report to; set while the lobby service runs. */
@@ -218,6 +220,31 @@ export function backgroundSessions(): readonly BackgroundSession[] {
218
220
  return registry?.sessions ?? [];
219
221
  }
220
222
 
223
+ /** Rename this window's session (no key) or one of its background sessions. */
224
+ function renameSession(state: Runtime, key: string | undefined, name: string): string {
225
+ const clean = cleanSessionName(name);
226
+ if (!key) {
227
+ state.pi.setSessionName(clean);
228
+ lobbyTopics.bump("status");
229
+ lobbyTopics.bump("sessions");
230
+ return `renamed this window to ${clean}`;
231
+ }
232
+ const session = backgroundSessions().find((entry) => entry.key === key && entry.projectRoot === state.root);
233
+ if (!session) throw new Error(`no session ${key}`);
234
+ session.rename(clean);
235
+ return `renamed ${key} to ${clean}`;
236
+ }
237
+
238
+ /** Put an ended background session away from the sidebar, or bring it back. */
239
+ function archiveSession(state: Runtime, key: string, archived: boolean): string {
240
+ const list = registry ? (archived ? registry.sessions : registry.archived) : [];
241
+ const session = list.find((entry) => entry.key === key && entry.projectRoot === state.root);
242
+ if (!session) throw new Error(archived ? `no session ${key} to archive` : `no archived session ${key}`);
243
+ if (session.alive) throw new Error(`${session.name} is still running: stop it before archiving it`);
244
+ registry!.archive(key, archived);
245
+ return archived ? `archived ${session.name}` : `brought back ${session.name}`;
246
+ }
247
+
221
248
  /** A background session changed: say so when one asks something or fails. */
222
249
  function sessionsChanged(): void {
223
250
  lobbyTopics.bump("sessions");
@@ -399,9 +426,19 @@ function mutableTask(state: Runtime, taskId: string): Task {
399
426
  }
400
427
 
401
428
  async function wakeTask(state: Runtime, task: Task, message: string): Promise<string> {
429
+ if (!tellOracle(state, task, message)) return (await resumeTask(state, task.id)).notice;
430
+ return "the owning oracle has been notified";
431
+ }
432
+
433
+ /**
434
+ * Leave a message in the task's inbox and hand it to its oracle now when a
435
+ * session here runs the task; false when the task is paused or stopped, so the
436
+ * oracle reads it once the task carries on.
437
+ */
438
+ function tellOracle(state: Runtime, task: Task, message: string): boolean {
402
439
  const inbox = sendToInbox(state.root, state.configDir, task.id, message, state.ctx.sessionManager.getSessionId());
403
440
  const route = resumeRoute(task, taskDrivers(state));
404
- if (route.kind === "stopped" || route.kind === "paused") return (await resumeTask(state, task.id)).notice;
441
+ if (route.kind === "stopped" || route.kind === "paused") return false;
405
442
  if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) {
406
443
  state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
407
444
  markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
@@ -412,7 +449,7 @@ async function wakeTask(state: Runtime, task: Task, message: string): Promise<st
412
449
  markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
413
450
  }
414
451
  }
415
- return "the owning oracle has been notified";
452
+ return true;
416
453
  }
417
454
 
418
455
  function canForceCloseTask(state: Runtime, taskId: string): boolean {
@@ -446,6 +483,33 @@ async function acceptTask(state: Runtime, taskId: string): Promise<{ notice: str
446
483
  return { notice: `${task.id} accepted as it is; ${wake}` };
447
484
  }
448
485
 
486
+ /**
487
+ * Skip a plan step on the user's word, with their reason: kept on the task
488
+ * (the checklist shows it skipped, the reason under it), recorded as a
489
+ * decision, and passed to the task's oracle so it carries on without it. A
490
+ * paused or stopped task is not resumed: its oracle reads it when it carries on.
491
+ */
492
+ async function skipTaskStep(state: Runtime, taskId: string, step: number, text: string, reason: string): Promise<{ notice: string }> {
493
+ const task = mutableTask(state, taskId);
494
+ const why = reason.trim().replace(/\s+/g, " ");
495
+ if (!why) fail(400, "bad_request", "say why you are skipping this step");
496
+ if (why.length > MAX_SKIP_REASON) fail(400, "bad_request", `keep the reason under ${MAX_SKIP_REASON} characters`);
497
+ if (!task.plan) fail(400, "bad_request", "this task has no plan yet");
498
+ const steps = stepViews(task, taskWork(state.root, state.configDir, task.id));
499
+ const found = steps[step];
500
+ if (!found || found.text !== text) fail(409, "conflict", "the plan changed; refresh the task");
501
+ if (found.status === "done") fail(409, "conflict", "that step is already done");
502
+ if (found.status === "skipped") fail(409, "conflict", "that step is already skipped");
503
+ task.skippedSteps = [...(task.skippedSteps ?? []), { text, reason: why, at: new Date().toISOString() }];
504
+ recordDecision(task, `The user skipped step ${step + 1} ("${truncate(text, 200)}") from the Tasks screen. Reason: ${why}`);
505
+ saveTask(state.root, state.configDir, task);
506
+ lobbyTopics.bump("tasks");
507
+ lobbyTopics.bump("lobby");
508
+ const message = `The user skipped step ${step + 1} of ${task.id}'s plan: "${text}". Their reason: ${why}. Do not delegate, build or review that step; if an agent is on it now, let it stop there or tell it to. Carry on with the next open step.`;
509
+ if (!tellOracle(state, task, message)) return { notice: `step ${step + 1} skipped; the oracle reads why when the task carries on` };
510
+ return { notice: `step ${step + 1} skipped; the oracle has been told` };
511
+ }
512
+
449
513
  async function resolveTaskApproval(state: Runtime, taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }> {
450
514
  const task = mutableTask(state, taskId);
451
515
  if (!task.approvals.some((entry) => entry.id === approvalId && entry.status === "pending")) fail(409, "conflict", "approval is no longer pending");
@@ -894,12 +958,15 @@ export function createLobbyService(state: Runtime): LobbyService {
894
958
  refreshWorkspace: () => refreshWorkspace(state),
895
959
  sessionName: () => state.pi.getSessionName(),
896
960
  sessions: () => backgroundSessions().filter((session) => session.projectRoot === state.root),
961
+ renameSession: (key, name) => renameSession(state, key, name),
962
+ archiveSession: (key, archived) => archiveSession(state, key, archived),
897
963
  startSession: (start) => startSession(state, start),
898
964
  isAuto: (taskId) => autoFor(state, taskId),
899
965
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
900
966
  sendToTask: (taskId, text) => sendToTask(state, taskId, text),
901
967
  resumeTask: (taskId) => resumeTask(state, taskId),
902
968
  acceptTask: (taskId) => acceptTask(state, taskId),
969
+ skipTaskStep: (taskId, step, text, reason) => skipTaskStep(state, taskId, step, text, reason),
903
970
  forceCloseTask: (taskId, title) => forceCloseTask(state, taskId, title),
904
971
  canForceCloseTask: (taskId) => canForceCloseTask(state, taskId),
905
972
  resolveTaskApproval: (taskId, approvalId, decision, note) => resolveTaskApproval(state, taskId, approvalId, decision, note),
@@ -197,6 +197,13 @@ export class BackgroundSession {
197
197
  * Stop the session: its process ends (its task keeps its state and can be
198
198
  * resumed or claimed later). One that does not end within `graceMs` is killed.
199
199
  */
200
+ /** Give the session a new name, here and in its pi (its saved session takes it too). */
201
+ rename(name: string): void {
202
+ this.name = name;
203
+ if (this.alive) this.write({ type: "set_session_name", name });
204
+ this.onChange();
205
+ }
206
+
200
207
  stop(graceMs = STOP_GRACE_MS): void {
201
208
  if (!this.alive) return;
202
209
  try {
@@ -337,6 +344,19 @@ export const launchPi = (args: string[], cwd: string, overrides: NodeJS.ProcessE
337
344
 
338
345
  /** Ended background sessions kept in the list (with their feeds); older ones are let go. */
339
346
  export const MAX_ENDED = 6;
347
+ /** Archived sessions kept so an archive can be undone; older ones are let go. */
348
+ export const MAX_ARCHIVED = 20;
349
+ /** Longest session name the lobby sets. */
350
+ export const MAX_SESSION_NAME = 120;
351
+
352
+ /** A session name as the lobby keeps it: one line, spaces collapsed, not too long. */
353
+ export function cleanSessionName(name: string): string {
354
+ const clean = name.replace(/\s+/g, " ").trim();
355
+ if (!clean) throw new Error("a session needs a name");
356
+ if (clean.length > MAX_SESSION_NAME) throw new Error(`keep the name under ${MAX_SESSION_NAME} characters`);
357
+ return clean;
358
+ }
359
+
340
360
 
341
361
  /** Every background session this window started, oldest first (ended ones beyond the newest few let go). */
342
362
  export class SessionRegistry {
@@ -372,6 +392,21 @@ export class SessionRegistry {
372
392
  return this.sessions.find((session) => session.key === key);
373
393
  }
374
394
 
395
+ /** Ended sessions put away from the list, newest last; one can come back while this pi runs. */
396
+ readonly archived: BackgroundSession[] = [];
397
+
398
+ /** Put an ended session away (true), or bring an archived one back (false); false when there was no such session. */
399
+ archive(key: string, archived = true): boolean {
400
+ const from = archived ? this.sessions : this.archived;
401
+ const index = from.findIndex((session) => session.key === key && !session.alive);
402
+ if (index < 0) return false;
403
+ const [session] = from.splice(index, 1);
404
+ (archived ? this.archived : this.sessions).push(session!);
405
+ if (this.archived.length > MAX_ARCHIVED) this.archived.splice(0, this.archived.length - MAX_ARCHIVED);
406
+ this.onChange();
407
+ return true;
408
+ }
409
+
375
410
  /** The background session running a pi session id, if this window started it. */
376
411
  bySessionId(sessionId: string | undefined): BackgroundSession | undefined {
377
412
  return sessionId ? this.sessions.find((session) => session.sessionId === sessionId) : undefined;
@@ -1,5 +1,5 @@
1
1
  import { compact, CONFIG_DIR_NAME, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, type ExtensionAPI, type ExtensionContext, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
2
- import { contextMarks, freshContextOn } from "./fresh-context.ts";
2
+ import { contextMarks, freshContextOn, staleCompaction } from "./fresh-context.ts";
3
3
  import { AUTO_COMPACT_ENV, isSubagentProcess } from "./quiet.ts";
4
4
  import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
5
5
  import { describeShared, DIGEST_INSTRUCTION, parseDigest, shareDigest, type DigestItem } from "../knowledge/oracle-digest.ts";
@@ -34,8 +34,9 @@ export function compactionThreshold(window: number, reserve = DEFAULT_COMPACTION
34
34
  }
35
35
 
36
36
  /**
37
- * Compact at a settled tool boundary; ctx.compact() would abort the turn we need to continue. Each agent's settings
38
- * can turn it off. The oracle's checkpoint also lists what it learned that is worth keeping, tagged by agent, and
37
+ * Compact at a settled tool boundary, below pi's own threshold (pi 1.3 still sets that only per model in settings, and
38
+ * ctx.compact() would abort the turn we need to continue). Each agent's settings can turn it off. Compactions pi
39
+ * starts itself get the same checkpoint through `session_before_compact`. The oracle's checkpoint also lists what it learned that is worth keeping, tagged by agent, and
39
40
  * that goes into the knowledge base: its own, and the designer's, backend's and QA's where it concerns them.
40
41
  */
41
42
  export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compact = compact, options: AutoCompactionOptions = {}): void {
@@ -88,26 +89,52 @@ export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compa
88
89
  busy = true;
89
90
  ctx.ui.notify("Compacting agent context", "info");
90
91
  try {
91
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
92
- if (!auth.ok) throw new Error(auth.error);
93
- const headers = auth.headers ? Object.fromEntries(Object.entries(auth.headers).filter((entry): entry is [string, string] => entry[1] !== null)) : undefined;
94
- const retry = pi.getSettings().retry;
95
- const retryPolicy = { enabled: retry?.enabled ?? true, maxRetries: retry?.maxRetries ?? 3, baseDelayMs: retry?.baseDelayMs ?? 2_000, maxAgentDelayMs: retry?.maxAgentDelayMs };
96
- const result = await summarize({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model, auth.apiKey, headers, oracle ? `${CHECKPOINT} ${DIGEST_INSTRUCTION}` : CHECKPOINT, ctx.signal, undefined, undefined, auth.env, retryPolicy);
97
- ctx.signal?.throwIfAborted();
92
+ const result = await checkpoint({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, ctx, ctx.signal);
98
93
  ctx.ui.notify("Agent context compacted", "info");
99
- if (oracle) {
100
- try {
101
- const items = parseDigest(result.summary);
102
- const shared = items.length ? share(items, ctx) : "";
103
- if (shared) ctx.ui.notify(`Knowledge updated: ${shared}`, "info");
104
- } catch (error) {
105
- ctx.ui.notify(`Could not update knowledge: ${(error as Error).message}`, "warning");
106
- }
107
- }
94
+ learn(result.summary, ctx);
108
95
  return { entries: [...event.entries, { type: "compaction" as const, summary: result.summary, firstKeptEntryId, usage: result.usage, details: result.details }] };
109
96
  } catch (error) {
110
97
  ctx.ui.notify(`Context compaction failed: ${(error as Error).message}`, "warning");
111
98
  } finally { busy = false; }
112
99
  });
100
+ // pi's own compactions (its context threshold, overflow recovery, /compact) write the same checkpoint from pi's
101
+ // preparation, so every summary an agent continues from has the same shape and the oracle's still feeds knowledge.
102
+ pi.on("session_before_compact", async (event, ctx) => {
103
+ if (staleCompaction(event) || !ctx.model) return undefined;
104
+ try {
105
+ const compaction = await checkpoint(event.preparation, ctx, event.signal, event.customInstructions);
106
+ learn(compaction.summary, ctx);
107
+ return { compaction };
108
+ } catch (error) {
109
+ if (event.signal.aborted) throw error;
110
+ ctx.ui.notify(`Checkpoint failed, using pi's summary: ${(error as Error).message}`, "warning");
111
+ return undefined;
112
+ }
113
+ });
114
+
115
+ /** Summarize with the checkpoint prompt (and, for the oracle, its knowledge digest) on the session's model. */
116
+ async function checkpoint(preparation: Parameters<typeof compact>[0], ctx: ExtensionContext, signal: AbortSignal | undefined, extra?: string) {
117
+ const model = ctx.model!;
118
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
119
+ if (!auth.ok) throw new Error(auth.error);
120
+ const headers = auth.headers ? Object.fromEntries(Object.entries(auth.headers).filter((entry): entry is [string, string] => entry[1] !== null)) : undefined;
121
+ const retry = pi.getSettings().retry;
122
+ const retryPolicy = { enabled: retry?.enabled ?? true, maxRetries: retry?.maxRetries ?? 3, baseDelayMs: retry?.baseDelayMs ?? 2_000, maxAgentDelayMs: retry?.maxAgentDelayMs };
123
+ const instructions = [oracle ? `${CHECKPOINT} ${DIGEST_INSTRUCTION}` : CHECKPOINT, extra].filter(Boolean).join("\n\n");
124
+ const result = await summarize(preparation, auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model, auth.apiKey, headers, instructions, signal, undefined, undefined, auth.env, retryPolicy);
125
+ signal?.throwIfAborted();
126
+ return result;
127
+ }
128
+
129
+ /** The oracle's digest from a checkpoint, shared into the knowledge base. */
130
+ function learn(summary: string, ctx: ExtensionContext): void {
131
+ if (!oracle) return;
132
+ try {
133
+ const items = parseDigest(summary);
134
+ const shared = items.length ? share(items, ctx) : "";
135
+ if (shared) ctx.ui.notify(`Knowledge updated: ${shared}`, "info");
136
+ } catch (error) {
137
+ ctx.ui.notify(`Could not update knowledge: ${(error as Error).message}`, "warning");
138
+ }
139
+ }
113
140
  }
package/src/pi/events.ts CHANGED
@@ -19,6 +19,7 @@ import { isAutoMode } from "../state/auto.ts";
19
19
  import { triageContext } from "../classifier/triage.ts";
20
20
  import { qaStillDue, rosterWords } from "../workflow/track.ts";
21
21
  import { previousTaskNote } from "./fresh-context.ts";
22
+ import { skippedStepsNote } from "./plan-checklist.ts";
22
23
  import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
23
24
 
24
25
  /** Tools that wait on the user: the task's clock waits with them. */
@@ -52,6 +53,7 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
52
53
  `Request: ${truncate(task.approvedPlan && task.plan ? requestBesidePlan(taskRequest(task)) : taskRequest(task), 2000)}`,
53
54
  task.proposal ? `Current proposal:\n${truncate(task.proposal, 2000)}` : "",
54
55
  task.plan ? `Approved plan:\n${truncate(task.plan, 3000)}` : "",
56
+ skippedStepsNote(task.plan, task.skippedSteps),
55
57
  task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
56
58
  open.length > 0 ? `Open plan comments (from the lobby):\n${open.map((comment) => `- ${truncate(comment.text, 600)}`).join("\n")}` : "",
57
59
  task.approvedPlan ? `The user agreed this task's plan in the planning panel (${task.approvedPlan}), and it is the task's plan: delegate its steps without clarifying, scouting or proposing, and answer open questions from the plan.${task.track?.satOut?.length ? ` ${rosterWords(task.track.satOut)} sat out the panel: do not give ${task.track.satOut.length === 1 ? "it" : "them"} work${task.track.satOut.includes("qa") ? ", and there is no QA gate (complete once every step is done)" : ""}.` : ""}` : "",