@tshu-w/pi-agents 0.1.1 → 0.2.1
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 +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +87 -42
- package/docs/HOSTS.md +111 -0
- package/docs/PROGRAM.md +39 -114
- package/docs/SPEC.md +109 -161
- package/package.json +7 -5
- package/src/agents/agents.ts +129 -55
- package/src/agents/registry.ts +62 -10
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +41 -13
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +30 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +380 -0
- package/src/index.ts +44 -16
- package/src/output.ts +5 -1
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/loadout.ts +18 -3
- package/src/programs/programs.ts +128 -20
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +15 -7
- package/src/programs/tool.ts +172 -16
- package/src/render-call.ts +113 -15
- package/src/roots/guard.mjs +8 -2
- package/src/roots/paths.mjs +4 -4
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/message.ts +24 -0
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +47 -0
- package/src/ui/viewer.ts +182 -38
- package/src/workbench.mjs +91 -51
- package/docs/WORKBENCH.md +0 -167
package/src/agents/tool.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { boundBlocks, boundText } from "../output.ts";
|
|
4
|
+
import { boundBlocks, boundText, type Truncation } from "../output.ts";
|
|
5
5
|
import { rejectFields } from "../params.ts";
|
|
6
|
-
import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
|
|
6
|
+
import { renderResultList, renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
|
|
7
|
+
import { agentSession } from "../roots/rpc.ts";
|
|
7
8
|
import type { RootRuntime } from "../roots/runtime.ts";
|
|
8
9
|
import {
|
|
10
|
+
steerQueued,
|
|
9
11
|
THINKING_LEVELS,
|
|
12
|
+
WAIT_INTERRUPTED,
|
|
10
13
|
type Limits,
|
|
11
14
|
type WaitOutcome,
|
|
12
15
|
type WaitResult,
|
|
@@ -32,11 +35,25 @@ function resultDetails({ agent, input, history }: WaitResult, result?: string) {
|
|
|
32
35
|
return { id: agent.id, name: agent.name, state: input.state, history, ...(result === undefined ? {} : { result }) };
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
type Named = { id: string; name?: string };
|
|
39
|
+
|
|
40
|
+
/** The line naming the results left out to keep within Pi's output limits. */
|
|
41
|
+
function omittedLine(omitted: Named[], ids: string[]): string {
|
|
42
|
+
return omitted.length > 0 ? `[Results omitted: ${omitted.map((agent) => label(agent, ids)).join(", ")}. Use wait with fewer targets.]` : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The line naming the Agents still pending, and that a steer ended the wait if one did. */
|
|
46
|
+
function pendingLine(pending: Named[], interrupted: boolean | undefined, ids: string[]): string {
|
|
47
|
+
return pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
/** Renders wait results; returns the text and the results the caller has now read. */
|
|
36
51
|
function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
|
|
37
|
-
const { results, pending } = outcome;
|
|
38
|
-
const
|
|
39
|
-
|
|
52
|
+
const { results, pending, interrupted } = outcome;
|
|
53
|
+
const pendingText = pendingLine(pending, interrupted, ids);
|
|
54
|
+
const flag = interrupted ? { interrupted } : {};
|
|
55
|
+
const named = pending.map(({ id, name }) => ({ id, name }));
|
|
56
|
+
if (results.length === 0) return { text: pendingText || "No results.", read: [], details: { results: [], pending: named, ...flag } };
|
|
40
57
|
const only = results[0]!;
|
|
41
58
|
if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
|
|
42
59
|
const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
|
|
@@ -53,10 +70,10 @@ function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read:
|
|
|
53
70
|
});
|
|
54
71
|
let text = bounded.text;
|
|
55
72
|
const read = results.slice(0, bounded.shown);
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
if (
|
|
59
|
-
return { text, read, details: { results: details, pending:
|
|
73
|
+
const omittedText = omittedLine(results.slice(bounded.shown).map((result) => result.agent), ids);
|
|
74
|
+
if (omittedText) text += `\n\n${omittedText}`;
|
|
75
|
+
if (pendingText) text += `\n\n${pendingText}`;
|
|
76
|
+
return { text, read, details: { results: details, pending: named, ...flag, ...bounded.details } };
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/** Registers the `agent` tool. */
|
|
@@ -85,8 +102,18 @@ export function registerAgentTool(
|
|
|
85
102
|
startDuration(context.state as DurationState, args.action === "wait", context.executionStarted);
|
|
86
103
|
return renderToolCall("agent", args, theme, context.lastComponent);
|
|
87
104
|
},
|
|
88
|
-
// `wait` shows how long it has waited.
|
|
89
|
-
renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context)
|
|
105
|
+
// `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its body.
|
|
106
|
+
renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context, (context.args as { action?: string }).action === "wait"
|
|
107
|
+
? () => {
|
|
108
|
+
const { results = [], pending = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ReturnType<typeof resultDetails>[]; pending?: Named[]; interrupted?: boolean } & Partial<Truncation>;
|
|
109
|
+
const only = results[0];
|
|
110
|
+
// As `renderWait` returns them, no results, or a single completed result alone, show as they are.
|
|
111
|
+
if (!only || (results.length === 1 && pending.length === 0 && !only.history && only.state === "completed")) return undefined;
|
|
112
|
+
const ids = [...results.map((entry) => entry.id), ...pending.map((agent) => agent.id)];
|
|
113
|
+
const notes = [omittedLine(results.filter((entry) => entry.result === undefined), ids), pendingLine(pending, interrupted, ids)];
|
|
114
|
+
return renderResultList(results.flatMap((entry) => entry.result === undefined ? [] : [{ line: `${label(entry, ids)} ${entry.state}`, body: entry.result }]), notes, truncated, theme);
|
|
115
|
+
}
|
|
116
|
+
: undefined),
|
|
90
117
|
description: `Create, message, and coordinate Agents. \`spawn\` creates an owned Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents and returns unread results; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input while its owner is not waiting for it, the owner receives a notification naming the Agent and outcome; \`wait\` returns the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
|
|
91
118
|
promptSnippet: "Create, message, and coordinate Agents",
|
|
92
119
|
promptGuidelines: [
|
|
@@ -101,7 +128,7 @@ export function registerAgentTool(
|
|
|
101
128
|
cwd: Type.Optional(Type.String({ description: "Working directory for `spawn` (default: the caller's current working directory)." })),
|
|
102
129
|
context: Type.Optional(StringEnum(["fresh", "fork"] as const, { description: "Context for `spawn` (default: fresh). fresh starts without the caller's conversation; fork snapshots it." })),
|
|
103
130
|
model: Type.Optional(Type.String({ description: "Model for `spawn` as provider/modelId (default: the caller's model)." })),
|
|
104
|
-
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, or
|
|
131
|
+
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, xhigh, or max (default: the caller's level)." })),
|
|
105
132
|
target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Agent ID, name, or unique ID prefix for `send`, `wait`, or `abort`. `wait` and `send` with `deliverAs='write'` also accept an array. When omitted for `wait`, selects all owned Agents with pending or unread results." })),
|
|
106
133
|
deliverAs: Type.Optional(StringEnum(["followUp", "steer", "write"] as const, { description: "Delivery for `send` (default: followUp). followUp waits until the recipient's current turn ends; steer delivers after its current tool calls, before the next model call; write delivers like steer but never starts a turn and has no result." })),
|
|
107
134
|
history: Type.Optional(Type.Integer({ minimum: 0, description: "Number of most recent read results to return again per selected Agent for `wait` (default: 0)." })),
|
|
@@ -168,7 +195,8 @@ export function registerAgentTool(
|
|
|
168
195
|
const selected = targets?.map((target) => agents.ownedTarget(target));
|
|
169
196
|
// An empty partial result lets the row show the elapsed time.
|
|
170
197
|
onUpdate?.({ content: [], details: undefined });
|
|
171
|
-
const
|
|
198
|
+
const session = agentSession(current.sessionManager);
|
|
199
|
+
const outcome = await agents.wait(selected, params.history ?? 0, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
|
|
172
200
|
const rendered = renderWait(outcome, ids());
|
|
173
201
|
agents.markRead(rendered.read);
|
|
174
202
|
return { content: [{ type: "text", text: rendered.text }], details: rendered.details };
|
package/src/daemon/client.mjs
CHANGED
|
@@ -42,13 +42,16 @@ export async function ensureDaemon(paths, { sessionDir, signal }) {
|
|
|
42
42
|
if (code !== 0) launchError = new Error(`Daemon exited (${code}). See ${paths.daemonLog}`);
|
|
43
43
|
});
|
|
44
44
|
child.unref();
|
|
45
|
+
// The new daemon sets its socket's mode just after binding; a mode that stays wrong is an error.
|
|
46
|
+
let modeWait = 0;
|
|
45
47
|
for (;;) {
|
|
46
48
|
signal?.throwIfAborted();
|
|
47
49
|
if (launchError) throw launchError;
|
|
48
50
|
try {
|
|
49
51
|
return await request(paths.daemon, { action: 'status' }, { signal, timeoutMs: 1000 });
|
|
50
52
|
} catch (failure) {
|
|
51
|
-
if (
|
|
53
|
+
if (failure.code === 'SOCKET_MODE' && ++modeWait > 20) throw failure;
|
|
54
|
+
if (!['ENOENT', 'ECONNREFUSED', 'SOCKET_MODE', ...RETRY].includes(failure.code)) throw failure;
|
|
52
55
|
// Another daemon holds the lock; it answers once its socket is up.
|
|
53
56
|
if (child.exitCode === 0) break;
|
|
54
57
|
}
|
package/src/host/client.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { open, readdir, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import net from 'node:net';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
8
|
import { FRAME, frame, json, readFrames } from './frames.mjs';
|
|
@@ -16,7 +16,7 @@ const ALT_SCREEN = /\x1b\[\?1049([hl])/g;
|
|
|
16
16
|
// Splits terminal input into key sequences: CSI and SS3 sequences, Alt+key, or a single character.
|
|
17
17
|
const KEYS = /\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b?[\s\S]/g;
|
|
18
18
|
|
|
19
|
-
function hostStatus(path) {
|
|
19
|
+
export function hostStatus(path) {
|
|
20
20
|
return new Promise((resolve, reject) => {
|
|
21
21
|
const socket = net.connect(path);
|
|
22
22
|
socket.setTimeout(1000, () => socket.destroy(Object.assign(new Error('Host status timed out'), { code: 'ETIMEDOUT' })));
|
|
@@ -27,6 +27,11 @@ function hostStatus(path) {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The file of the Session a host runs; Pi keeps a relative session directory relative, so it resolves against the host's cwd. */
|
|
31
|
+
export function hostSessionFile(host) {
|
|
32
|
+
return host.session?.file && resolve(host.cwd, host.session.file);
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
export function alive(pid) {
|
|
31
36
|
try { process.kill(pid, 0); return true; }
|
|
32
37
|
catch (error) { return error.code !== 'ESRCH'; }
|
|
@@ -50,6 +55,19 @@ export async function listHosts(paths) {
|
|
|
50
55
|
return hosts;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Running hosts once `found` matches one or no host `starting` remains, waiting at most `timeoutMs`:
|
|
60
|
+
* a host whose Pi has not reported its Session yet may be starting the Session a caller looks for.
|
|
61
|
+
*/
|
|
62
|
+
export async function settledHosts(paths, found, { starting = host => !host.session, timeoutMs = START_TIMEOUT_MS } = {}) {
|
|
63
|
+
const deadline = performance.now() + timeoutMs;
|
|
64
|
+
for (;;) {
|
|
65
|
+
const hosts = await listHosts(paths);
|
|
66
|
+
if (hosts.some(found) || !hosts.some(starting) || performance.now() > deadline) return hosts;
|
|
67
|
+
await delay(100);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
/**
|
|
54
72
|
* Starts a detached host running `pi args` in `cwd` and resolves to its socket once it accepts connections.
|
|
55
73
|
* With `waitForTerminal`, Pi starts when the first terminal attaches.
|
|
@@ -88,9 +106,10 @@ export async function startHost(paths, { pi, args, cwd, cols = 120, rows = 40, e
|
|
|
88
106
|
/**
|
|
89
107
|
* Attaches this process's terminal to a host until the host detaches it or Pi exits.
|
|
90
108
|
* The detach key detaches here, before Pi sees it, so it works whatever Pi shows.
|
|
109
|
+
* `onAttach` runs once the host has attached the terminal and told Pi.
|
|
91
110
|
* Resolves to { reason: 'detach' | 'takeover' | 'exited' | 'closed', session }.
|
|
92
111
|
*/
|
|
93
|
-
export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = process.stdout }) {
|
|
112
|
+
export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin, stdout = process.stdout }) {
|
|
94
113
|
return new Promise((resolve, reject) => {
|
|
95
114
|
const socket = net.connect(socketPath);
|
|
96
115
|
const size = () => ({ cols: stdout.columns, rows: stdout.rows });
|
|
@@ -104,7 +123,7 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
104
123
|
if (at > 0) socket.write(frame(FRAME.data, Buffer.from(keys.slice(0, at).join(''), encoding)));
|
|
105
124
|
socket.write(frame(FRAME.detached));
|
|
106
125
|
};
|
|
107
|
-
let result, connected = false, altScreen = false;
|
|
126
|
+
let result, connected = false, altScreen = false, attached = false;
|
|
108
127
|
function finish() {
|
|
109
128
|
stdout.off('resize', onResize);
|
|
110
129
|
stdin.off('data', onInput);
|
|
@@ -113,7 +132,8 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
113
132
|
stdin.pause();
|
|
114
133
|
// An exiting Pi restores the terminal itself.
|
|
115
134
|
// Leaving the alternate screen returns the cursor to the line below the command; otherwise it ends a line.
|
|
116
|
-
|
|
135
|
+
// Terminals keep a keyboard mode stack per screen, so the reset comes before leaving it.
|
|
136
|
+
if (result?.reason !== 'exited') stdout.write(altScreen ? `${RESET}\x1b[?1049l` : `${RESET}\r\n`);
|
|
117
137
|
}
|
|
118
138
|
resolve(result ?? { reason: 'closed' });
|
|
119
139
|
}
|
|
@@ -129,6 +149,11 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
129
149
|
if (type === FRAME.data) {
|
|
130
150
|
for (const [, mode] of body.toString('latin1').matchAll(ALT_SCREEN)) altScreen = mode === 'h';
|
|
131
151
|
stdout.write(body);
|
|
152
|
+
// The host sends the screen only after it has told Pi that a terminal is attached.
|
|
153
|
+
if (!attached) {
|
|
154
|
+
attached = true;
|
|
155
|
+
onAttach?.();
|
|
156
|
+
}
|
|
132
157
|
}
|
|
133
158
|
else if (type === FRAME.detached) result = { ...json(body) };
|
|
134
159
|
else if (type === FRAME.exited) result = { reason: 'exited', ...json(body) };
|
package/src/host/extension.ts
CHANGED
|
@@ -3,7 +3,7 @@ import net from "node:net";
|
|
|
3
3
|
import { treeMetadata } from "../agents/registry.ts";
|
|
4
4
|
import { FRAME, frame, json, readFrames } from "./frames.mjs";
|
|
5
5
|
|
|
6
|
-
const IDLE_EXIT_MS =
|
|
6
|
+
const IDLE_EXIT_MS = 10 * 60_000;
|
|
7
7
|
|
|
8
8
|
// Only the host's own child runs in it; children of Pi inherit the variables but have another parent.
|
|
9
9
|
// They stay in the environment so a Pi that replaces itself with execve finds its host again.
|
|
@@ -12,20 +12,21 @@ const hostSocket = process.env.PI_AGENTS_HOST && process.env.PI_AGENTS_HOST_PID
|
|
|
12
12
|
export interface Host {
|
|
13
13
|
socket: string;
|
|
14
14
|
attached(): boolean;
|
|
15
|
-
onChange(listener: () => void): void;
|
|
15
|
+
onChange(listener: () => void): () => void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Connects Pi to the host it runs in: reports the current Session, detaches on request,
|
|
20
|
-
* disables suspend, and exits after the tree has been idle and detached for a while.
|
|
20
|
+
* disables suspend, and exits after the tree has been idle and detached, with no RPC client, for a while.
|
|
21
21
|
* Returns the host, if Pi runs in one.
|
|
22
22
|
*/
|
|
23
|
-
export function hostExtension(pi: ExtensionAPI, { treeIdle }: { treeIdle: () => boolean }): Host | undefined {
|
|
23
|
+
export function hostExtension(pi: ExtensionAPI, { treeIdle, rpcClients }: { treeIdle: () => boolean; rpcClients: () => number }): Host | undefined {
|
|
24
24
|
if (!hostSocket) return;
|
|
25
25
|
const listeners: Array<() => void> = [];
|
|
26
26
|
|
|
27
27
|
let ctx: ExtensionContext | undefined;
|
|
28
|
-
|
|
28
|
+
// Whether a terminal was attached when Pi started, until the host reports a change.
|
|
29
|
+
let attached = process.env.PI_AGENTS_HOST_ATTACHED !== "0";
|
|
29
30
|
let idleSince: number | undefined;
|
|
30
31
|
let socket: net.Socket | undefined;
|
|
31
32
|
const connect = (path: string): net.Socket => {
|
|
@@ -43,32 +44,55 @@ export function hostExtension(pi: ExtensionAPI, { treeIdle }: { treeIdle: () =>
|
|
|
43
44
|
const report = () => {
|
|
44
45
|
if (!ctx) return;
|
|
45
46
|
const file = ctx.sessionManager.getSessionFile();
|
|
46
|
-
send({ type: "session", session: { id: ctx.sessionManager.getSessionId(), name: pi.getSessionName(), file } });
|
|
47
|
+
send({ type: "session", session: { id: ctx.sessionManager.getSessionId(), name: pi.getSessionName(), file, cwd: ctx.cwd } });
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
// Pi suspends by stopping its process group; a host has no shell to resume it, so resume at once.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!ctx || attached || !ctx.isIdle() || ctx.hasPendingMessages() || !treeIdle()) {
|
|
51
|
+
const resume = () => process.kill(process.pid, "SIGCONT");
|
|
52
|
+
const checkIdle = () => {
|
|
53
|
+
if (!ctx || attached || !ctx.isIdle() || ctx.hasPendingMessages() || !treeIdle() || rpcClients() > 0) {
|
|
54
54
|
idleSince = undefined;
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
idleSince ??= Date.now();
|
|
58
58
|
if (Date.now() - idleSince >= IDLE_EXIT_MS) ctx.shutdown();
|
|
59
|
-
}
|
|
60
|
-
timer
|
|
59
|
+
};
|
|
60
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
61
61
|
|
|
62
62
|
pi.on("session_start", (_event, current) => {
|
|
63
63
|
// Owned Agents run in the same process; only the root speaks for the host.
|
|
64
64
|
if (treeMetadata(current)) return;
|
|
65
65
|
ctx = current;
|
|
66
66
|
socket ??= connect(hostSocket);
|
|
67
|
+
if (!timer) {
|
|
68
|
+
process.on("SIGTSTP", resume);
|
|
69
|
+
timer = setInterval(checkIdle, 1000);
|
|
70
|
+
timer.unref();
|
|
71
|
+
}
|
|
67
72
|
report();
|
|
68
73
|
});
|
|
69
74
|
pi.on("session_info_changed", () => report());
|
|
70
|
-
|
|
75
|
+
// A reload or another Session loads this extension again, which takes over.
|
|
76
|
+
pi.on("session_shutdown", () => {
|
|
77
|
+
ctx = undefined;
|
|
78
|
+
idleSince = undefined;
|
|
79
|
+
clearInterval(timer);
|
|
80
|
+
timer = undefined;
|
|
81
|
+
process.off("SIGTSTP", resume);
|
|
82
|
+
socket?.destroy();
|
|
83
|
+
socket = undefined;
|
|
84
|
+
});
|
|
71
85
|
|
|
72
86
|
pi.registerCommand("detach", { description: "Detach this terminal; Pi keeps running", handler: async () => send({ type: "detach" }) });
|
|
73
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
socket: hostSocket,
|
|
89
|
+
attached: () => attached,
|
|
90
|
+
onChange: (listener) => {
|
|
91
|
+
listeners.push(listener);
|
|
92
|
+
return () => {
|
|
93
|
+
const at = listeners.indexOf(listener);
|
|
94
|
+
if (at >= 0) listeners.splice(at, 1);
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
74
98
|
}
|