@av-pi-studio/server 0.0.93 → 0.0.95
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/dist/agent/agent-manager.d.ts +8 -0
- package/dist/agent/agent-manager.js +11 -2
- package/dist/agent/agent-ui/agent-ui-rpc.d.ts +20 -0
- package/dist/agent/agent-ui/agent-ui-rpc.js +24 -0
- package/dist/agent/agent-ui/agent-ui-service.d.ts +55 -0
- package/dist/agent/agent-ui/agent-ui-service.js +237 -0
- package/dist/agent/mcp-server.d.ts +37 -6
- package/dist/agent/mcp-server.js +43 -6
- package/dist/agent/provider-contract.d.ts +29 -0
- package/dist/agent/providers/mock/mock-provider.d.ts +121 -2
- package/dist/agent/providers/mock/mock-provider.js +130 -1
- package/dist/agent/providers/mock/ui-script.d.ts +52 -0
- package/dist/agent/providers/mock/ui-script.js +224 -0
- package/dist/agent/providers/pi/agent.d.ts +1 -0
- package/dist/agent/providers/pi/agent.js +57 -9
- package/dist/daemon/bootstrap.js +18 -8
- package/dist/daemon/dev-bootstrap.d.ts +6 -0
- package/dist/daemon/dev-bootstrap.js +28 -14
- package/dist/daemon/index.d.ts +1 -0
- package/dist/daemon/index.js +4 -0
- package/dist/extensions/curated-packs.js +3 -0
- package/dist/terminal/screen-buffer.d.ts +13 -0
- package/dist/terminal/screen-buffer.js +38 -1
- package/dist/terminal/terminal-manager.d.ts +35 -6
- package/dist/terminal/terminal-manager.js +45 -11
- package/dist/terminal/terminal-rpc.d.ts +1 -1
- package/dist/terminal/terminal-rpc.js +43 -8
- package/package.json +5 -4
|
@@ -18,6 +18,7 @@ export const PI_CAPABILITIES = {
|
|
|
18
18
|
supportsReasoningStream: true,
|
|
19
19
|
supportsToolInvocations: true,
|
|
20
20
|
supportsSteering: true,
|
|
21
|
+
supportsExtensionUi: true,
|
|
21
22
|
};
|
|
22
23
|
/** Build Pi argv, appending (never replacing) the system prompt and optional `--mcp-config`. */
|
|
23
24
|
export function buildPiArgs(base, opts) {
|
|
@@ -41,12 +42,54 @@ function modelIdFrom(model) {
|
|
|
41
42
|
return rec.name;
|
|
42
43
|
return undefined;
|
|
43
44
|
}
|
|
45
|
+
/** Dialog methods block for a client answer; every other extension UI method is fire-and-forget
|
|
46
|
+
* (docs/rpc.md § Extension UI Protocol). The single place the blocking set is encoded — a future Pi
|
|
47
|
+
* release that adds a method only ever touches this constant. */
|
|
48
|
+
const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
49
|
+
/**
|
|
50
|
+
* Translate a raw Pi `extension_ui_request` event onto the provider-neutral UI channel
|
|
51
|
+
* (features/extension-ui-rpc.md § Provider contract extension). All Pi-specific knowledge — which
|
|
52
|
+
* methods block, surface-key namespacing, clear-by-omission, the `timeout` field name — lives here;
|
|
53
|
+
* nothing above this adapter learns Pi's vocabulary.
|
|
54
|
+
*
|
|
55
|
+
* Surface-key namespacing is mandatory, not cosmetic: Pi's own docs reuse the same key
|
|
56
|
+
* (`"my-ext"`) for both `statusKey` and `widgetKey` (rpc.md:1273,1289) because the natural pattern
|
|
57
|
+
* is an extension naming everything after itself. Un-namespaced, a status tick would silently
|
|
58
|
+
* delete that extension's own widget.
|
|
59
|
+
*/
|
|
60
|
+
function translateUiRequest(rec) {
|
|
61
|
+
const { type: _type, id, method, ...payload } = rec;
|
|
62
|
+
const methodName = method;
|
|
63
|
+
let surfaceKey;
|
|
64
|
+
let removed = false;
|
|
65
|
+
if (methodName === "setStatus") {
|
|
66
|
+
surfaceKey = `status:${rec.statusKey}`;
|
|
67
|
+
removed = rec.statusText === undefined;
|
|
68
|
+
}
|
|
69
|
+
else if (methodName === "setWidget") {
|
|
70
|
+
surfaceKey = `widget:${rec.widgetKey}`;
|
|
71
|
+
removed = rec.widgetLines === undefined;
|
|
72
|
+
}
|
|
73
|
+
else if (methodName === "setTitle") {
|
|
74
|
+
surfaceKey = "title";
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
requestId: id,
|
|
78
|
+
method: methodName,
|
|
79
|
+
expectsResponse: DIALOG_METHODS.has(methodName),
|
|
80
|
+
payload,
|
|
81
|
+
...(surfaceKey !== undefined ? { surfaceKey } : {}),
|
|
82
|
+
...(removed ? { removed: true } : {}),
|
|
83
|
+
...(typeof rec.timeout === "number" ? { timeoutMs: rec.timeout } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
44
86
|
class PiAgentSession {
|
|
45
87
|
transport;
|
|
46
88
|
provider;
|
|
47
89
|
id = randomUUID();
|
|
48
90
|
capabilities = PI_CAPABILITIES;
|
|
49
91
|
subscribers = new Set();
|
|
92
|
+
uiSubscribers = new Set();
|
|
50
93
|
history = [];
|
|
51
94
|
eventMapper = createPiEventMapper();
|
|
52
95
|
modes = [];
|
|
@@ -59,17 +102,13 @@ class PiAgentSession {
|
|
|
59
102
|
this.mode = opts.config.modeId ?? null;
|
|
60
103
|
this.sessionFile = opts.sessionFile;
|
|
61
104
|
transport.onEvent((raw) => {
|
|
62
|
-
// Auto-respond to extension UI dialogs so the agent never blocks waiting on a client that
|
|
63
|
-
// isn't wired for them (POC). Safe default: cancel (extension receives undefined/false).
|
|
64
105
|
const rec = raw;
|
|
65
106
|
if (rec?.type === "extension_ui_request") {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.transport.notify("extension_ui_response", { id: rec.id, cancelled: true });
|
|
72
|
-
}
|
|
107
|
+
// Forward to the daemon's UI channel — write no response here; the daemon answers, or
|
|
108
|
+
// nobody does (features/extension-ui-rpc.md § Behavior & algorithms).
|
|
109
|
+
const req = translateUiRequest(rec);
|
|
110
|
+
for (const cb of this.uiSubscribers)
|
|
111
|
+
cb(req);
|
|
73
112
|
return;
|
|
74
113
|
}
|
|
75
114
|
const event = this.eventMapper.map(raw);
|
|
@@ -238,6 +277,15 @@ class PiAgentSession {
|
|
|
238
277
|
this.transport.notify("respond_to_permission", { requestId, response });
|
|
239
278
|
return Promise.resolve();
|
|
240
279
|
}
|
|
280
|
+
onUiRequest(cb) {
|
|
281
|
+
this.uiSubscribers.add(cb);
|
|
282
|
+
return () => this.uiSubscribers.delete(cb);
|
|
283
|
+
}
|
|
284
|
+
/** Body spread first, `id` stamped last — a response containing an `id`/`type` key cannot
|
|
285
|
+
* redirect which dialog it resolves (features/extension-ui-rpc.md § Public contract). */
|
|
286
|
+
respondToUi(providerRequestId, response) {
|
|
287
|
+
this.transport.notify("extension_ui_response", { ...response, id: providerRequestId });
|
|
288
|
+
}
|
|
241
289
|
describePersistence() {
|
|
242
290
|
return {
|
|
243
291
|
provider: this.provider,
|
package/dist/daemon/bootstrap.js
CHANGED
|
@@ -25,6 +25,8 @@ import { createPasswordAuth, resolvePasswordHash } from "../auth/password-auth.j
|
|
|
25
25
|
import { expandHome } from "../files/resolve-path.js";
|
|
26
26
|
import { loadConfig } from "../config/daemon-config.js";
|
|
27
27
|
import { createDaemonLogger } from "../logging/logger.js";
|
|
28
|
+
import { registerAgentUiHandlers } from "../agent/agent-ui/agent-ui-rpc.js";
|
|
29
|
+
import { AgentUiService } from "../agent/agent-ui/agent-ui-service.js";
|
|
28
30
|
import { AgentManager } from "../agent/agent-manager.js";
|
|
29
31
|
import { AgentService, getTimeline } from "../agent/agent-service.js";
|
|
30
32
|
import { SessionOperationsService } from "../agent/session-operations.js";
|
|
@@ -177,12 +179,6 @@ export function startDaemon(opts) {
|
|
|
177
179
|
});
|
|
178
180
|
// ── Real provider resolution (pi spawns `pi --mode rpc`; mock is opt-in) ─────
|
|
179
181
|
const resolveClient = (provider) => resolveProviderClient(provider, config, { logger });
|
|
180
|
-
// ── Disk-persisted agent manager (recovers agents on boot) ───────────────────
|
|
181
|
-
const manager = new AgentManager({
|
|
182
|
-
home,
|
|
183
|
-
saveAgent: (record) => saveAgent(home, record),
|
|
184
|
-
loadAllAgents: () => loadAllAgents(home),
|
|
185
|
-
});
|
|
186
182
|
// ── Broadcast helper ─────────────────────────────────────────────────────────
|
|
187
183
|
// See `wrapSessionEnvelope` above for the full rationale.
|
|
188
184
|
const broadcast = (sessions, message) => {
|
|
@@ -200,13 +196,26 @@ export function startDaemon(opts) {
|
|
|
200
196
|
const relayCapabilityStore = createInMemoryCapabilityStore();
|
|
201
197
|
const sessionsHolder = { sessions: [] };
|
|
202
198
|
const getActiveSessions = () => [...sessionsHolder.sessions, ...relaySessions];
|
|
199
|
+
// ── Extension UI bridge (features/extension-ui-rpc.md) — constructed before the manager so
|
|
200
|
+
// `onSessionAttached` below can close over it.
|
|
201
|
+
const agentUiService = new AgentUiService({ broadcast, getActiveSessions, logger });
|
|
202
|
+
// ── Disk-persisted agent manager (recovers agents on boot) ───────────────────
|
|
203
|
+
const manager = new AgentManager({
|
|
204
|
+
home,
|
|
205
|
+
saveAgent: (record) => saveAgent(home, record),
|
|
206
|
+
loadAllAgents: () => loadAllAgents(home),
|
|
207
|
+
onSessionAttached: (agentId, session) => agentUiService.attach(agentId, session),
|
|
208
|
+
logger,
|
|
209
|
+
});
|
|
203
210
|
// Forward archive/delete lifecycle events to every connected client (multi-tab/multi-client
|
|
204
211
|
// sync) — `agent_update` for status changes is already broadcast per-call-site; these two are
|
|
205
|
-
// manager-internal and only reach clients via this subscription.
|
|
212
|
+
// manager-internal and only reach clients via this subscription. Also sweeps the extension-UI
|
|
213
|
+
// bridge: every pending dialog and retained surface for the agent is cancelled/dropped.
|
|
206
214
|
manager.subscribe((event) => {
|
|
207
215
|
if (event.type === "agent_archived" || event.type === "agent_deleted") {
|
|
208
216
|
logger.info({ agentId: event.agentId }, event.type.replace("_", " "));
|
|
209
217
|
broadcast(getActiveSessions(), { type: "session", message: event });
|
|
218
|
+
agentUiService.sweep(event.agentId, "aborted");
|
|
210
219
|
}
|
|
211
220
|
});
|
|
212
221
|
const registry = new HandlerRegistry(logger);
|
|
@@ -230,6 +239,7 @@ export function startDaemon(opts) {
|
|
|
230
239
|
registerTimelineHandler(registry, { manager, resolveClient });
|
|
231
240
|
const permissionService = new PermissionService({ manager, broadcast });
|
|
232
241
|
permissionService.registerHandlers(registry, getActiveSessions);
|
|
242
|
+
registerAgentUiHandlers(registry, { service: agentUiService, logger });
|
|
233
243
|
// ── Directory listing (agents) ────────────────────────────────────────────────
|
|
234
244
|
registry.register("list_agents_request", (ctx) => ({
|
|
235
245
|
type: "list_agents_response",
|
|
@@ -472,7 +482,7 @@ export function startDaemon(opts) {
|
|
|
472
482
|
restoreModesEnabled: true,
|
|
473
483
|
projectConfigPath: (cwd) => join(cwd, "pi-studio.json"),
|
|
474
484
|
}, getActiveSessions);
|
|
475
|
-
const terminalBinaryHandler = makeTerminalBinaryHandler(terminalManager);
|
|
485
|
+
const terminalBinaryHandler = makeTerminalBinaryHandler(terminalManager, broadcast, getActiveSessions);
|
|
476
486
|
// ── Orchestration: schedules / chat / loops (real, disk-backed) ───────────────
|
|
477
487
|
const scheduleExecutor = {
|
|
478
488
|
async createAndPrompt(agentConfig, prompt) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Server as HttpServer } from "node:http";
|
|
2
|
+
import { AgentManager } from "../agent/agent-manager.js";
|
|
2
3
|
import { type Logger } from "../logging/logger.js";
|
|
3
4
|
export interface DevBootstrapOptions {
|
|
4
5
|
host: string;
|
|
@@ -19,6 +20,11 @@ export interface DevBootstrapHandle {
|
|
|
19
20
|
httpServer: HttpServer;
|
|
20
21
|
serverId: string;
|
|
21
22
|
logger: Logger;
|
|
23
|
+
/** Exposed for tests only (e.g. sprint-066/task-004's daemon-level extension-UI test): lets a
|
|
24
|
+
* test reach a created agent's live `MockAgentSession` to script a UI dialog via
|
|
25
|
+
* `emitUiRequest` — there is no WS RPC for that, by design (a real Pi process is what would
|
|
26
|
+
* normally emit these). Not part of the RPC surface. */
|
|
27
|
+
manager: AgentManager;
|
|
22
28
|
close(): Promise<void>;
|
|
23
29
|
}
|
|
24
30
|
export declare function startDevDaemon(opts: DevBootstrapOptions): DevBootstrapHandle;
|
|
@@ -13,6 +13,8 @@ import { expandHome } from "../files/resolve-path.js";
|
|
|
13
13
|
import { createHttpServer } from "../http/http-server.js";
|
|
14
14
|
import { createWebSocketServer } from "../ws/ws-server.js";
|
|
15
15
|
import { HandlerRegistry, routeTextFrame } from "../ws/router.js";
|
|
16
|
+
import { registerAgentUiHandlers } from "../agent/agent-ui/agent-ui-rpc.js";
|
|
17
|
+
import { AgentUiService } from "../agent/agent-ui/agent-ui-service.js";
|
|
16
18
|
import { AgentManager } from "../agent/agent-manager.js";
|
|
17
19
|
import { AgentService } from "../agent/agent-service.js";
|
|
18
20
|
import { SessionOperationsService } from "../agent/session-operations.js";
|
|
@@ -28,19 +30,6 @@ import { wrapSessionEnvelope } from "./bootstrap.js";
|
|
|
28
30
|
export function startDevDaemon(opts) {
|
|
29
31
|
const serverId = opts.serverId ?? randomUUID();
|
|
30
32
|
const logger = opts.logger ?? createDaemonLogger(undefined);
|
|
31
|
-
// ── In-memory agent manager (no disk persistence in dev mode) ──────────────
|
|
32
|
-
const agentsById = new Map();
|
|
33
|
-
const manager = new AgentManager({
|
|
34
|
-
home: "/tmp/pi-studio-dev",
|
|
35
|
-
saveAgent: async (record) => {
|
|
36
|
-
agentsById.set(record.id, record);
|
|
37
|
-
},
|
|
38
|
-
loadAllAgents: async () => [...agentsById.values()],
|
|
39
|
-
deleteAgent: async (_cwd, id) => agentsById.delete(id),
|
|
40
|
-
});
|
|
41
|
-
// ── Provider resolution: mock only in dev ───────────────────────────────────
|
|
42
|
-
const mockClient = createMockClient({ turnDelayMs: opts.mockTurnDelayMs });
|
|
43
|
-
const resolveClient = (_provider) => mockClient;
|
|
44
33
|
// ── Broadcast helper ─────────────────────────────────────────────────────────
|
|
45
34
|
// See `bootstrap.ts`'s `wrapSessionEnvelope` for the full rationale.
|
|
46
35
|
const broadcast = (sessions, message) => {
|
|
@@ -58,11 +47,34 @@ export function startDevDaemon(opts) {
|
|
|
58
47
|
// mutable holder so they can be registered before the WS server exists.
|
|
59
48
|
const sessionsHolder = { sessions: [] };
|
|
60
49
|
const getActiveSessions = () => sessionsHolder.sessions;
|
|
50
|
+
// ── Extension UI bridge (features/extension-ui-rpc.md) — constructed before the manager so
|
|
51
|
+
// `onSessionAttached` below can close over it. Registered here (unlike production-only
|
|
52
|
+
// `provider_auth`/`file_watch`): the mock provider is this family's designated producer, and the
|
|
53
|
+
// dev daemon is mock-only, so a UI family unexercisable here would be untestable exactly where a
|
|
54
|
+
// sibling UI scope needs to develop against it.
|
|
55
|
+
const agentUiService = new AgentUiService({ broadcast, getActiveSessions, logger });
|
|
56
|
+
// ── In-memory agent manager (no disk persistence in dev mode) ──────────────
|
|
57
|
+
const agentsById = new Map();
|
|
58
|
+
const manager = new AgentManager({
|
|
59
|
+
home: "/tmp/pi-studio-dev",
|
|
60
|
+
saveAgent: async (record) => {
|
|
61
|
+
agentsById.set(record.id, record);
|
|
62
|
+
},
|
|
63
|
+
loadAllAgents: async () => [...agentsById.values()],
|
|
64
|
+
deleteAgent: async (_cwd, id) => agentsById.delete(id),
|
|
65
|
+
onSessionAttached: (agentId, session) => agentUiService.attach(agentId, session),
|
|
66
|
+
logger,
|
|
67
|
+
});
|
|
68
|
+
// ── Provider resolution: mock only in dev ───────────────────────────────────
|
|
69
|
+
const mockClient = createMockClient({ turnDelayMs: opts.mockTurnDelayMs });
|
|
70
|
+
const resolveClient = (_provider) => mockClient;
|
|
61
71
|
// Forward archive/delete lifecycle events to every connected client (see bootstrap.ts's
|
|
62
|
-
// production twin for the full rationale).
|
|
72
|
+
// production twin for the full rationale). Also sweeps the extension-UI bridge: every pending
|
|
73
|
+
// dialog and retained surface for the agent is cancelled/dropped on archive or delete.
|
|
63
74
|
manager.subscribe((event) => {
|
|
64
75
|
if (event.type === "agent_archived" || event.type === "agent_deleted") {
|
|
65
76
|
broadcast(getActiveSessions(), { type: "session", message: event });
|
|
77
|
+
agentUiService.sweep(event.agentId, "aborted");
|
|
66
78
|
}
|
|
67
79
|
});
|
|
68
80
|
// ── Handler registry ────────────────────────────────────────────────────────
|
|
@@ -86,6 +98,7 @@ export function startDevDaemon(opts) {
|
|
|
86
98
|
registerTimelineHandler(registry, { manager, resolveClient });
|
|
87
99
|
const permissionService = new PermissionService({ manager, broadcast });
|
|
88
100
|
permissionService.registerHandlers(registry, getActiveSessions);
|
|
101
|
+
registerAgentUiHandlers(registry, { service: agentUiService, logger });
|
|
89
102
|
// ── list_agents_request: minimal directory listing (not in scope elsewhere) ─
|
|
90
103
|
registry.register("list_agents_request", (ctx) => {
|
|
91
104
|
const agents = manager.list().map((m) => ({
|
|
@@ -289,6 +302,7 @@ export function startDevDaemon(opts) {
|
|
|
289
302
|
httpServer,
|
|
290
303
|
serverId,
|
|
291
304
|
logger,
|
|
305
|
+
manager,
|
|
292
306
|
close: async () => {
|
|
293
307
|
logger.info("dev daemon shutting down");
|
|
294
308
|
await wsHandle.close();
|
package/dist/daemon/index.d.ts
CHANGED
package/dist/daemon/index.js
CHANGED
|
@@ -20,4 +20,8 @@ export function createDaemonRuntimeInfo(input = {}) {
|
|
|
20
20
|
}
|
|
21
21
|
// Production daemon bootstrap: startDaemon(), DaemonOptions, DaemonHandle, wrapSessionEnvelope().
|
|
22
22
|
export * from "./bootstrap.js";
|
|
23
|
+
// Dev daemon bootstrap (mock provider, in-memory): reachable from outside the package so
|
|
24
|
+
// cross-package E2E (e.g. packages/cli's extension-UI SDK test, sprint-067/task-004) can host a
|
|
25
|
+
// real dev daemon without a second, duplicated bootstrap.
|
|
26
|
+
export * from "./dev-bootstrap.js";
|
|
23
27
|
//# sourceMappingURL=index.js.map
|
|
@@ -14,6 +14,9 @@ export const CURATED_PACKS = {
|
|
|
14
14
|
{ source: "npm:pi-web-access", addedIn: "0.0.73" },
|
|
15
15
|
// Powerline-style status bar.
|
|
16
16
|
{ source: "npm:pi-powerline-footer", addedIn: "0.0.73" },
|
|
17
|
+
// Structured questionnaire tool: typed multiple-choice/free-text prompts instead of the
|
|
18
|
+
// model guessing when it lacks information it should ask for.
|
|
19
|
+
{ source: "npm:@juicesharp/rpiv-ask-user-question", addedIn: "0.0.93" },
|
|
17
20
|
],
|
|
18
21
|
},
|
|
19
22
|
};
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export declare class ScreenBuffer {
|
|
11
11
|
private readonly term;
|
|
12
|
+
private readonly serializeAddon;
|
|
12
13
|
constructor(cols: number, rows: number, scrollback?: number);
|
|
13
14
|
write(data: Uint8Array): void;
|
|
14
15
|
resize(cols: number, rows: number): void;
|
|
@@ -16,6 +17,18 @@ export declare class ScreenBuffer {
|
|
|
16
17
|
flush(): Promise<void>;
|
|
17
18
|
/** The visible viewport as plain text, with trailing blank lines trimmed. */
|
|
18
19
|
snapshotText(): string;
|
|
20
|
+
/**
|
|
21
|
+
* A reflowable redraw of the current screen — SGR colours/attributes and cursor position, not
|
|
22
|
+
* just text (`terminals.md` § Restore / snapshot, tier 2: the daemon's raw byte ring is
|
|
23
|
+
* approximate at a different width; this is the payload sent instead when both ends support
|
|
24
|
+
* it). Computed on demand, not maintained continuously, so an idle terminal costs nothing extra
|
|
25
|
+
* beyond what `capture`/`snapshotText` already require. Bounded to
|
|
26
|
+
* `RESTORE_SCROLLBACK_LINES` — verified empirically against `@xterm/addon-serialize@0.14.0`
|
|
27
|
+
* paired with `@xterm/headless@6.0.0` (no published peer range covers this pairing yet; the
|
|
28
|
+
* addon's actual API — reading `buffer.active` cells/modes — has been runtime-compatible across
|
|
29
|
+
* this xterm major since it predates the `@xterm/*` scoped rename).
|
|
30
|
+
*/
|
|
31
|
+
serialize(): string;
|
|
19
32
|
dispose(): void;
|
|
20
33
|
}
|
|
21
34
|
//# sourceMappingURL=screen-buffer.d.ts.map
|
|
@@ -2,9 +2,23 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import stripAnsi from "strip-ansi";
|
|
3
3
|
// `@xterm/headless` ships a UMD bundle whose `module.exports` Node's ESM loader cannot statically
|
|
4
4
|
// read, so a named `import { Terminal }` resolves at type-check time but throws at runtime. Load it
|
|
5
|
-
// through `createRequire` (CJS) to get the real `Terminal` constructor.
|
|
5
|
+
// through `createRequire` (CJS) to get the real `Terminal` constructor. `@xterm/addon-serialize`
|
|
6
|
+
// ships the same way (also a UMD bundle, no `exports` map in its `package.json`) and needs the
|
|
7
|
+
// identical treatment (sprint-053/task-004) — expected, not a surprise discovered at runtime. Both
|
|
8
|
+
// `import type`s above are erased at compile time (no runtime `import`, so no UMD-load failure);
|
|
9
|
+
// they only give the two `require(...)` results below a real type instead of `any`.
|
|
6
10
|
const require = createRequire(import.meta.url);
|
|
7
11
|
const { Terminal } = require("@xterm/headless");
|
|
12
|
+
const { SerializeAddon, } = require("@xterm/addon-serialize");
|
|
13
|
+
/**
|
|
14
|
+
* Lines of scrollback history a reflowable `Restore` payload includes, on top of the viewport
|
|
15
|
+
* itself (`terminals.md` § Restore / snapshot, tier 2; `feature-panels-ui.md` § Reconnect/restore:
|
|
16
|
+
* "a visible-snapshot restore (bounded scrollback)"). A redraw needs the current screen, not the
|
|
17
|
+
* terminal's whole retained history (`ScreenBuffer`'s own `scrollback` constructor default is
|
|
18
|
+
* 1000 lines) replayed on every reattach — bounding this is what keeps the payload size
|
|
19
|
+
* predictable regardless of how long the terminal has been running.
|
|
20
|
+
*/
|
|
21
|
+
const RESTORE_SCROLLBACK_LINES = 200;
|
|
8
22
|
/**
|
|
9
23
|
* Server-side terminal screen model backed by `@xterm/headless` (features/terminals.md § capture).
|
|
10
24
|
*
|
|
@@ -16,8 +30,17 @@ const { Terminal } = require("@xterm/headless");
|
|
|
16
30
|
*/
|
|
17
31
|
export class ScreenBuffer {
|
|
18
32
|
term;
|
|
33
|
+
serializeAddon;
|
|
19
34
|
constructor(cols, rows, scrollback = 1000) {
|
|
20
35
|
this.term = new Terminal({ cols, rows, scrollback, allowProposedApi: true });
|
|
36
|
+
this.serializeAddon = new SerializeAddon();
|
|
37
|
+
// `@xterm/addon-serialize`'s published types declare `activate(terminal: Terminal)` against
|
|
38
|
+
// `@xterm/xterm` (the browser package) specifically, so it is not structurally assignable to
|
|
39
|
+
// headless's own `ITerminalAddon` (which wants its OWN `Terminal` type) — even though the
|
|
40
|
+
// addon's real implementation only reads `buffer`/`cols`/`rows`, fields both `Terminal` types
|
|
41
|
+
// share, and works correctly headless (verified empirically: colours, cursor position, and
|
|
42
|
+
// text all round-trip — see `serialize()` below and its tests).
|
|
43
|
+
this.term.loadAddon(this.serializeAddon);
|
|
21
44
|
}
|
|
22
45
|
write(data) {
|
|
23
46
|
this.term.write(Buffer.from(data));
|
|
@@ -48,6 +71,20 @@ export class ScreenBuffer {
|
|
|
48
71
|
// translateToString already yields plain text; strip-ansi defends against any passthrough.
|
|
49
72
|
return stripAnsi(lines.join("\n"));
|
|
50
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A reflowable redraw of the current screen — SGR colours/attributes and cursor position, not
|
|
76
|
+
* just text (`terminals.md` § Restore / snapshot, tier 2: the daemon's raw byte ring is
|
|
77
|
+
* approximate at a different width; this is the payload sent instead when both ends support
|
|
78
|
+
* it). Computed on demand, not maintained continuously, so an idle terminal costs nothing extra
|
|
79
|
+
* beyond what `capture`/`snapshotText` already require. Bounded to
|
|
80
|
+
* `RESTORE_SCROLLBACK_LINES` — verified empirically against `@xterm/addon-serialize@0.14.0`
|
|
81
|
+
* paired with `@xterm/headless@6.0.0` (no published peer range covers this pairing yet; the
|
|
82
|
+
* addon's actual API — reading `buffer.active` cells/modes — has been runtime-compatible across
|
|
83
|
+
* this xterm major since it predates the `@xterm/*` scoped rename).
|
|
84
|
+
*/
|
|
85
|
+
serialize() {
|
|
86
|
+
return this.serializeAddon.serialize({ scrollback: RESTORE_SCROLLBACK_LINES });
|
|
87
|
+
}
|
|
51
88
|
dispose() {
|
|
52
89
|
this.term.dispose();
|
|
53
90
|
}
|
|
@@ -17,6 +17,13 @@ import { type PtyBackend } from "./pty-backend.js";
|
|
|
17
17
|
*/
|
|
18
18
|
/** A subscriber sink receives fully-encoded binary terminal frames. */
|
|
19
19
|
export type TerminalFrameSink = (frame: Uint8Array) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Tier negotiated per subscription (`terminals.md` § Restore / snapshot). `"basic"` is the raw
|
|
22
|
+
* byte-ring `Snapshot` (always available); `"reflowable"` is a `Restore` frame carrying
|
|
23
|
+
* `ScreenBuffer.serialize()` — correct at any client width, gated on both sides supporting it
|
|
24
|
+
* (`terminal-rpc.ts`'s negotiation). Exactly one of the two is ever sent per subscribe.
|
|
25
|
+
*/
|
|
26
|
+
export type RestoreMode = "basic" | "reflowable";
|
|
20
27
|
export interface TerminalRuntimeEntry {
|
|
21
28
|
slot: number;
|
|
22
29
|
workspaceId: string;
|
|
@@ -76,10 +83,22 @@ export declare class TerminalManager {
|
|
|
76
83
|
private readonly logger?;
|
|
77
84
|
/** Rotating hand-out point in the one-byte slot space (see `nextFreeSlot`). */
|
|
78
85
|
private slotCursor;
|
|
86
|
+
/** Exit listeners (`onTerminalExit`) — fired once per terminal, covering both `kill()` and a
|
|
87
|
+
* PTY exiting on its own (`exit`, a crash). See `onExit` below for the single call site. */
|
|
88
|
+
private readonly exitListeners;
|
|
79
89
|
constructor(options?: TerminalManagerOptions);
|
|
80
90
|
/** All live terminal runtime entries. */
|
|
81
91
|
list(): TerminalRuntimeEntry[];
|
|
82
92
|
get(slot: number): TerminalRuntimeEntry | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Subscribe to every terminal exit — self-exit (the `exit` command, a crash) or an explicit
|
|
95
|
+
* `kill()` — regardless of which session, if any, triggered it. Fires exactly once per
|
|
96
|
+
* terminal, after its entry has already been removed from `list()`. The daemon has no
|
|
97
|
+
* dedicated close opcode on the binary terminal stream (`onExit` below); this is the seam a
|
|
98
|
+
* caller uses to relay the fact out-of-band, e.g. `registerTerminalHandlers` broadcasting
|
|
99
|
+
* `terminals_update`. Returns an unsubscribe fn.
|
|
100
|
+
*/
|
|
101
|
+
onTerminalExit(listener: (slot: number) => void): () => void;
|
|
83
102
|
/**
|
|
84
103
|
* Spawn a PTY in the backend, assign a slot, and track the runtime entry.
|
|
85
104
|
*
|
|
@@ -90,10 +109,15 @@ export declare class TerminalManager {
|
|
|
90
109
|
*/
|
|
91
110
|
createTerminal(options: CreateTerminalOptions): TerminalRuntimeEntry;
|
|
92
111
|
/**
|
|
93
|
-
* Subscribe to a slot: emit
|
|
94
|
-
*
|
|
112
|
+
* Subscribe to a slot: emit exactly one restore frame (current screen) immediately, then live
|
|
113
|
+
* Output frames. `restoreMode: "reflowable"` (default `"basic"`) sends a `Restore` frame
|
|
114
|
+
* carrying `ScreenBuffer.serialize()` instead of the raw byte-ring `Snapshot` — computed here,
|
|
115
|
+
* on subscribe, not maintained continuously (an idle terminal must cost nothing extra). Does
|
|
116
|
+
* NOT resize the PTY (passive attach must not claim size). Returns an unsubscribe fn.
|
|
95
117
|
*/
|
|
96
|
-
subscribe(slot: number, sink: TerminalFrameSink
|
|
118
|
+
subscribe(slot: number, sink: TerminalFrameSink, opts?: {
|
|
119
|
+
restoreMode?: RestoreMode;
|
|
120
|
+
}): () => void;
|
|
97
121
|
rename(slot: number, name: string): boolean;
|
|
98
122
|
/** Forward input bytes to the PTY. */
|
|
99
123
|
input(slot: number, bytes: Uint8Array): boolean;
|
|
@@ -127,9 +151,14 @@ export declare class TerminalManager {
|
|
|
127
151
|
* whether `from` lands inside a sequence depends on where that sequence began, which may be
|
|
128
152
|
* before `from` — the exact case a raw byte-offset cut produces.
|
|
129
153
|
*
|
|
130
|
-
*
|
|
131
|
-
* `buffer.length
|
|
132
|
-
*
|
|
154
|
+
* Fallback (sprint-053/task-007): if the sequence straddling `from` never terminates before
|
|
155
|
+
* `buffer.length`, no position from `from` onward is provably safe — but returning `buffer.length`
|
|
156
|
+
* (dropping the entire retained region) is needlessly pessimistic for what is usually a few stray
|
|
157
|
+
* bytes, e.g. `cat` on a binary file leaving one unterminated DCS. Falls back to the naive cut
|
|
158
|
+
* (`from` itself) instead: the emulator eats whatever garbage remains of that one sequence on
|
|
159
|
+
* replay (bounded — at most one sequence's worth), which is strictly more readable than an empty
|
|
160
|
+
* snapshot. This fallback only applies when no safe boundary exists at all; a legitimate
|
|
161
|
+
* mid-sequence cut with a real boundary later in the buffer still returns that boundary unchanged.
|
|
133
162
|
*/
|
|
134
163
|
export declare function safeReplayStart(buffer: Uint8Array, from: number): number;
|
|
135
164
|
//# sourceMappingURL=terminal-manager.d.ts.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { encodeTerminalFrame, nextFreeSlot, SLOT_SPACE } from "@av-pi-studio/protocol";
|
|
2
2
|
import { createDefaultPtyBackend, resolveExecutable, } from "./pty-backend.js";
|
|
3
3
|
import { ScreenBuffer } from "./screen-buffer.js";
|
|
4
|
+
const textEncoder = new TextEncoder();
|
|
4
5
|
/**
|
|
5
6
|
* Grid bounds for any client-supplied size (`terminals.md` § PTY size ownership: "the daemon MUST
|
|
6
7
|
* validate every requested size, whatever path it arrives on").
|
|
@@ -44,6 +45,9 @@ export class TerminalManager {
|
|
|
44
45
|
logger;
|
|
45
46
|
/** Rotating hand-out point in the one-byte slot space (see `nextFreeSlot`). */
|
|
46
47
|
slotCursor = 1;
|
|
48
|
+
/** Exit listeners (`onTerminalExit`) — fired once per terminal, covering both `kill()` and a
|
|
49
|
+
* PTY exiting on its own (`exit`, a crash). See `onExit` below for the single call site. */
|
|
50
|
+
exitListeners = new Set();
|
|
47
51
|
constructor(options = {}) {
|
|
48
52
|
this.backend = options.backend ?? createDefaultPtyBackend();
|
|
49
53
|
this.coalesceMs = options.coalesceMs ?? 4;
|
|
@@ -58,6 +62,18 @@ export class TerminalManager {
|
|
|
58
62
|
get(slot) {
|
|
59
63
|
return this.terminals.get(slot)?.entry;
|
|
60
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Subscribe to every terminal exit — self-exit (the `exit` command, a crash) or an explicit
|
|
67
|
+
* `kill()` — regardless of which session, if any, triggered it. Fires exactly once per
|
|
68
|
+
* terminal, after its entry has already been removed from `list()`. The daemon has no
|
|
69
|
+
* dedicated close opcode on the binary terminal stream (`onExit` below); this is the seam a
|
|
70
|
+
* caller uses to relay the fact out-of-band, e.g. `registerTerminalHandlers` broadcasting
|
|
71
|
+
* `terminals_update`. Returns an unsubscribe fn.
|
|
72
|
+
*/
|
|
73
|
+
onTerminalExit(listener) {
|
|
74
|
+
this.exitListeners.add(listener);
|
|
75
|
+
return () => this.exitListeners.delete(listener);
|
|
76
|
+
}
|
|
61
77
|
/**
|
|
62
78
|
* Spawn a PTY in the backend, assign a slot, and track the runtime entry.
|
|
63
79
|
*
|
|
@@ -132,15 +148,24 @@ export class TerminalManager {
|
|
|
132
148
|
return entry;
|
|
133
149
|
}
|
|
134
150
|
/**
|
|
135
|
-
* Subscribe to a slot: emit
|
|
136
|
-
*
|
|
151
|
+
* Subscribe to a slot: emit exactly one restore frame (current screen) immediately, then live
|
|
152
|
+
* Output frames. `restoreMode: "reflowable"` (default `"basic"`) sends a `Restore` frame
|
|
153
|
+
* carrying `ScreenBuffer.serialize()` instead of the raw byte-ring `Snapshot` — computed here,
|
|
154
|
+
* on subscribe, not maintained continuously (an idle terminal must cost nothing extra). Does
|
|
155
|
+
* NOT resize the PTY (passive attach must not claim size). Returns an unsubscribe fn.
|
|
137
156
|
*/
|
|
138
|
-
subscribe(slot, sink) {
|
|
157
|
+
subscribe(slot, sink, opts) {
|
|
139
158
|
const managed = this.terminals.get(slot);
|
|
140
159
|
if (!managed)
|
|
141
160
|
throw new Error(`no terminal in slot ${slot}`);
|
|
142
|
-
//
|
|
143
|
-
|
|
161
|
+
// Exactly one restore-tier frame first (rebuilds screen state), then live output.
|
|
162
|
+
if (opts?.restoreMode === "reflowable") {
|
|
163
|
+
const data = textEncoder.encode(managed.screenModel.serialize());
|
|
164
|
+
sink(encodeTerminalFrame({ opcode: "Restore", slot, data }));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
sink(encodeTerminalFrame({ opcode: "Snapshot", slot, data: managed.screen.bytes() }));
|
|
168
|
+
}
|
|
144
169
|
managed.subscribers.add(sink);
|
|
145
170
|
return () => {
|
|
146
171
|
managed.subscribers.delete(sink);
|
|
@@ -246,6 +271,8 @@ export class TerminalManager {
|
|
|
246
271
|
// Notify subscribers the terminal closed (empty Output then drop). Clients treat an exited
|
|
247
272
|
// terminal as closed; no dedicated close opcode exists in the binary protocol.
|
|
248
273
|
managed.subscribers.clear();
|
|
274
|
+
for (const listener of this.exitListeners)
|
|
275
|
+
listener(managed.entry.slot);
|
|
249
276
|
}
|
|
250
277
|
}
|
|
251
278
|
/**
|
|
@@ -254,7 +281,8 @@ export class TerminalManager {
|
|
|
254
281
|
* the sequence's tail — parameter digits, an SGR/cursor final byte, an OSC payload — which the
|
|
255
282
|
* emulator on replay consumes as garbage input instead of the printable text it actually is,
|
|
256
283
|
* corrupting everything after it. `safeReplayStart` finds the nearest safe boundary at or after the
|
|
257
|
-
* naive cut instead; `SnapshotRing.compact`
|
|
284
|
+
* naive cut instead; `SnapshotRing.compact` AND the oversized-single-chunk path in
|
|
285
|
+
* `SnapshotRing.append` both call it.
|
|
258
286
|
*/
|
|
259
287
|
const ESC = 0x1b;
|
|
260
288
|
const BEL = 0x07;
|
|
@@ -275,9 +303,14 @@ function startsStringSequence(byte) {
|
|
|
275
303
|
* whether `from` lands inside a sequence depends on where that sequence began, which may be
|
|
276
304
|
* before `from` — the exact case a raw byte-offset cut produces.
|
|
277
305
|
*
|
|
278
|
-
*
|
|
279
|
-
* `buffer.length
|
|
280
|
-
*
|
|
306
|
+
* Fallback (sprint-053/task-007): if the sequence straddling `from` never terminates before
|
|
307
|
+
* `buffer.length`, no position from `from` onward is provably safe — but returning `buffer.length`
|
|
308
|
+
* (dropping the entire retained region) is needlessly pessimistic for what is usually a few stray
|
|
309
|
+
* bytes, e.g. `cat` on a binary file leaving one unterminated DCS. Falls back to the naive cut
|
|
310
|
+
* (`from` itself) instead: the emulator eats whatever garbage remains of that one sequence on
|
|
311
|
+
* replay (bounded — at most one sequence's worth), which is strictly more readable than an empty
|
|
312
|
+
* snapshot. This fallback only applies when no safe boundary exists at all; a legitimate
|
|
313
|
+
* mid-sequence cut with a real boundary later in the buffer still returns that boundary unchanged.
|
|
281
314
|
*/
|
|
282
315
|
export function safeReplayStart(buffer, from) {
|
|
283
316
|
if (from <= 0)
|
|
@@ -333,8 +366,9 @@ export function safeReplayStart(buffer, from) {
|
|
|
333
366
|
}
|
|
334
367
|
}
|
|
335
368
|
// Ran off the end still inside an unterminated sequence (or a run of continuation bytes with no
|
|
336
|
-
// following lead byte) —
|
|
337
|
-
|
|
369
|
+
// following lead byte) — no position is provably safe. Fall back to the naive cut rather than
|
|
370
|
+
// dropping everything (see the function doc comment's "Fallback" paragraph).
|
|
371
|
+
return from;
|
|
338
372
|
}
|
|
339
373
|
/**
|
|
340
374
|
* Fraction of the cap the ring keeps when it compacts. The reclaimed headroom (the remaining
|
|
@@ -16,5 +16,5 @@ export interface TerminalRpcDeps {
|
|
|
16
16
|
}
|
|
17
17
|
export declare function registerTerminalHandlers(registry: HandlerRegistry, deps: TerminalRpcDeps, getActiveSessions: () => Iterable<Session>): void;
|
|
18
18
|
/** Binary terminal-input frame handler for the frame dispatcher (Input/Resize opcodes). */
|
|
19
|
-
export declare function makeTerminalBinaryHandler(manager: TerminalManager): BinaryHandler;
|
|
19
|
+
export declare function makeTerminalBinaryHandler(manager: TerminalManager, broadcast: TerminalRpcDeps["broadcast"], getActiveSessions: () => Iterable<Session>): BinaryHandler;
|
|
20
20
|
//# sourceMappingURL=terminal-rpc.d.ts.map
|