@tshu-w/pi-agents 0.1.1 → 0.2.0
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 +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +48 -36
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +7 -5
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +24 -4
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- 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/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- 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/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +48 -20
package/src/agents/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** The Agents loaded in this process, shared across trees: who sees whom, how targets resolve, and how messages are addressed. */
|
|
2
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { Agents } from "./agents.ts";
|
|
4
5
|
import { TreeScheduler } from "./scheduler.ts";
|
|
@@ -16,12 +17,14 @@ export interface AgentNode {
|
|
|
16
17
|
/** The node whose tree this node sees: its root, or the Program its Agents belong to. */
|
|
17
18
|
scopeId: string;
|
|
18
19
|
ownerId?: string;
|
|
19
|
-
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the
|
|
20
|
+
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the task viewer. */
|
|
20
21
|
program?: boolean;
|
|
21
22
|
/** Whether a Program runs in the background. */
|
|
22
23
|
background?: boolean;
|
|
23
24
|
/** When a Program started. */
|
|
24
25
|
createdAt?: string;
|
|
26
|
+
/** What the replies of a Program's Agents and the Agents under them have cost since it started. */
|
|
27
|
+
spent?: Spend;
|
|
25
28
|
name(): string | undefined;
|
|
26
29
|
cwd(): string;
|
|
27
30
|
busy(): boolean;
|
|
@@ -29,6 +32,8 @@ export interface AgentNode {
|
|
|
29
32
|
agents: Agents;
|
|
30
33
|
/** Delivers a message to this node; used only for root Agents. */
|
|
31
34
|
receive(text: string, delivery: Delivery, messageId?: string): void;
|
|
35
|
+
/** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
|
|
36
|
+
stopped?: boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface Entry {
|
|
@@ -86,6 +91,26 @@ export function trackTreeWork(rootId: string): () => void {
|
|
|
86
91
|
};
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
/** What replies cost: dollars, input and output tokens, and whether a reply had no price. */
|
|
95
|
+
export interface Spend {
|
|
96
|
+
cost: number;
|
|
97
|
+
tokens: number;
|
|
98
|
+
unpriced: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function addSpend(spend: Spend, usage: Usage): void {
|
|
102
|
+
spend.cost += usage.cost.total;
|
|
103
|
+
spend.tokens += usage.input + usage.output;
|
|
104
|
+
if (usage.cost.total === 0 && usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0) spend.unpriced = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Adds a reply of an Agent owned by `ownerId` to the Programs above it. */
|
|
108
|
+
export function addProgramSpend(ownerId: string, usage: Usage): void {
|
|
109
|
+
for (let node = shared.nodes.get(ownerId); node; node = node.ownerId === undefined ? undefined : shared.nodes.get(node.ownerId)) {
|
|
110
|
+
if (node.spent) addSpend(node.spent, usage);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
/** The Programs a node runs, oldest first. */
|
|
90
115
|
export function programsOf(node: AgentNode): AgentNode[] {
|
|
91
116
|
return [...shared.nodes.values()]
|
|
@@ -169,7 +194,9 @@ export function treeMetadata(ctx: ExtensionContext): TreeMetadata | undefined {
|
|
|
169
194
|
export function treeEntries(scopeId: string): Entry[] {
|
|
170
195
|
const root = shared.nodes.get(scopeId);
|
|
171
196
|
if (!root) return [];
|
|
172
|
-
|
|
197
|
+
// A Program's Agent heads its own scope; it stays owned, so messages to it go through its owner.
|
|
198
|
+
const owned = root.ownerId === undefined ? undefined : shared.nodes.get(root.ownerId)?.agents.owned().find((agent) => agent.id === root.id);
|
|
199
|
+
const entries: Entry[] = root.program ? [] : [owned ? { ...owned, ownerId: root.ownerId } : {
|
|
173
200
|
id: root.id,
|
|
174
201
|
name: root.name(),
|
|
175
202
|
cwd: root.cwd(),
|
|
@@ -236,7 +263,7 @@ export function messageText(from: { id: string; name?: string }, fromOwner: bool
|
|
|
236
263
|
|
|
237
264
|
/**
|
|
238
265
|
* `messageId` marks a message from a root Agent in another process, so it is added once; `user`
|
|
239
|
-
* marks the user's input from the
|
|
266
|
+
* marks the user's input from the task viewer.
|
|
240
267
|
*/
|
|
241
268
|
export function customMessage(text: string, delivery: Delivery, messageId?: string, user?: boolean) {
|
|
242
269
|
return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}) } };
|
package/src/agents/search.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { entryLine, MESSAGE_TAG, MESSAGE_TYPE, type Entry } from "./registry.ts"
|
|
|
4
4
|
interface SearchText {
|
|
5
5
|
time: number;
|
|
6
6
|
text: string;
|
|
7
|
+
/** A user message or an input, not a summary, write, or notification. */
|
|
8
|
+
request?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/** Parsed texts by Session file, reused while the file is unchanged. */
|
|
@@ -32,9 +34,10 @@ function sessionTexts(sessionFile: string | undefined): SearchText[] {
|
|
|
32
34
|
const text = typeof content === "string"
|
|
33
35
|
? content
|
|
34
36
|
: Array.isArray(content) ? content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n") : "";
|
|
35
|
-
if (text) texts.push({ time, text });
|
|
37
|
+
if (text) texts.push({ time, text, request: true });
|
|
36
38
|
} else if (entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
37
|
-
|
|
39
|
+
const request = entry.details?.delivery !== "write" && (entry.content.startsWith(`<${MESSAGE_TAG} `) || entry.details?.user === true);
|
|
40
|
+
texts.push({ time, text: entry.content, request });
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
cache.set(sessionFile, { stamp, texts });
|
|
@@ -85,9 +88,23 @@ export function searchEntries(entries: Entry[], query: string | undefined): List
|
|
|
85
88
|
return items.sort((left, right) => right.time - left.time).map(({ time: _time, ...item }) => item);
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** An entry with its latest matching excerpt, else its latest request, and its time. */
|
|
88
92
|
export function listLine(item: ListItem, ids: Iterable<string>): string {
|
|
89
93
|
const line = entryLine(item.entry, ids);
|
|
90
|
-
|
|
94
|
+
const detail = item.match ?? latestRequest(item.entry.sessionFile);
|
|
95
|
+
return detail ? `${line}\n ${formatTime(detail.time)} ${detail.excerpt}` : line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function latestRequest(sessionFile: string | undefined): { time: number; excerpt: string } | undefined {
|
|
99
|
+
const text = sessionTexts(sessionFile).findLast((text) => text.request);
|
|
100
|
+
if (!text) return undefined;
|
|
101
|
+
const flat = messageBody(text.text).replace(/\s+/g, " ").trim();
|
|
102
|
+
return { time: text.time, excerpt: flat.length > 120 ? `${flat.slice(0, 120)}…` : flat };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A message's body without its `agent-message` element. */
|
|
106
|
+
function messageBody(content: string): string {
|
|
107
|
+
return content.startsWith(`<${MESSAGE_TAG} `) ? content.slice(content.indexOf("\n") + 1, content.lastIndexOf("\n")) : content;
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
/**
|
|
@@ -103,7 +120,7 @@ export function latestActivity(sessionFile: string | undefined): string | undefi
|
|
|
103
120
|
let entry: Record<string, any>;
|
|
104
121
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
105
122
|
if (input === undefined && entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
106
|
-
input =
|
|
123
|
+
input = messageBody(entry.content);
|
|
107
124
|
}
|
|
108
125
|
if (input !== undefined && entry.type === "message" && entry.message?.role === "assistant" && Array.isArray(entry.message.content)) {
|
|
109
126
|
activity = replyActivity(entry.message.content) ?? activity;
|
package/src/agents/tool.ts
CHANGED
|
@@ -4,9 +4,12 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { boundBlocks, boundText } from "../output.ts";
|
|
5
5
|
import { rejectFields } from "../params.ts";
|
|
6
6
|
import { 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,
|
|
@@ -34,9 +37,10 @@ function resultDetails({ agent, input, history }: WaitResult, result?: string) {
|
|
|
34
37
|
|
|
35
38
|
/** Renders wait results; returns the text and the results the caller has now read. */
|
|
36
39
|
function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
|
|
37
|
-
const { results, pending } = outcome;
|
|
38
|
-
const pendingLine = pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}` : "";
|
|
39
|
-
|
|
40
|
+
const { results, pending, interrupted } = outcome;
|
|
41
|
+
const pendingLine = pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
|
|
42
|
+
const flag = interrupted ? { interrupted } : {};
|
|
43
|
+
if (results.length === 0) return { text: pendingLine || "No results.", read: [], details: { results: [], pending: pending.map((agent) => agent.id), ...flag } };
|
|
40
44
|
const only = results[0]!;
|
|
41
45
|
if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
|
|
42
46
|
const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
|
|
@@ -56,7 +60,7 @@ function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read:
|
|
|
56
60
|
const omitted = results.slice(bounded.shown);
|
|
57
61
|
if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((result) => label(result.agent, ids)).join(", ")}. Use wait with fewer targets.]`;
|
|
58
62
|
if (pendingLine) text += `\n\n${pendingLine}`;
|
|
59
|
-
return { text, read, details: { results: details, pending: pending.map((agent) => agent.id), ...bounded.details } };
|
|
63
|
+
return { text, read, details: { results: details, pending: pending.map((agent) => agent.id), ...flag, ...bounded.details } };
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/** Registers the `agent` tool. */
|
|
@@ -168,7 +172,8 @@ export function registerAgentTool(
|
|
|
168
172
|
const selected = targets?.map((target) => agents.ownedTarget(target));
|
|
169
173
|
// An empty partial result lets the row show the elapsed time.
|
|
170
174
|
onUpdate?.({ content: [], details: undefined });
|
|
171
|
-
const
|
|
175
|
+
const session = agentSession(current.sessionManager);
|
|
176
|
+
const outcome = await agents.wait(selected, params.history ?? 0, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
|
|
172
177
|
const rendered = renderWait(outcome, ids());
|
|
173
178
|
agents.markRead(rendered.read);
|
|
174
179
|
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
|
@@ -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' })));
|
|
@@ -50,6 +50,19 @@ export async function listHosts(paths) {
|
|
|
50
50
|
return hosts;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Running hosts once `found` matches one or no host `starting` remains, waiting at most `timeoutMs`:
|
|
55
|
+
* a host whose Pi has not reported its Session yet may be starting the Session a caller looks for.
|
|
56
|
+
*/
|
|
57
|
+
export async function settledHosts(paths, found, { starting = host => !host.session, timeoutMs = START_TIMEOUT_MS } = {}) {
|
|
58
|
+
const deadline = performance.now() + timeoutMs;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const hosts = await listHosts(paths);
|
|
61
|
+
if (hosts.some(found) || !hosts.some(starting) || performance.now() > deadline) return hosts;
|
|
62
|
+
await delay(100);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
/**
|
|
54
67
|
* Starts a detached host running `pi args` in `cwd` and resolves to its socket once it accepts connections.
|
|
55
68
|
* With `waitForTerminal`, Pi starts when the first terminal attaches.
|
|
@@ -88,9 +101,10 @@ export async function startHost(paths, { pi, args, cwd, cols = 120, rows = 40, e
|
|
|
88
101
|
/**
|
|
89
102
|
* Attaches this process's terminal to a host until the host detaches it or Pi exits.
|
|
90
103
|
* The detach key detaches here, before Pi sees it, so it works whatever Pi shows.
|
|
104
|
+
* `onAttach` runs once the host has attached the terminal and told Pi.
|
|
91
105
|
* Resolves to { reason: 'detach' | 'takeover' | 'exited' | 'closed', session }.
|
|
92
106
|
*/
|
|
93
|
-
export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = process.stdout }) {
|
|
107
|
+
export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin, stdout = process.stdout }) {
|
|
94
108
|
return new Promise((resolve, reject) => {
|
|
95
109
|
const socket = net.connect(socketPath);
|
|
96
110
|
const size = () => ({ cols: stdout.columns, rows: stdout.rows });
|
|
@@ -104,7 +118,7 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
104
118
|
if (at > 0) socket.write(frame(FRAME.data, Buffer.from(keys.slice(0, at).join(''), encoding)));
|
|
105
119
|
socket.write(frame(FRAME.detached));
|
|
106
120
|
};
|
|
107
|
-
let result, connected = false, altScreen = false;
|
|
121
|
+
let result, connected = false, altScreen = false, attached = false;
|
|
108
122
|
function finish() {
|
|
109
123
|
stdout.off('resize', onResize);
|
|
110
124
|
stdin.off('data', onInput);
|
|
@@ -113,7 +127,8 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
113
127
|
stdin.pause();
|
|
114
128
|
// An exiting Pi restores the terminal itself.
|
|
115
129
|
// Leaving the alternate screen returns the cursor to the line below the command; otherwise it ends a line.
|
|
116
|
-
|
|
130
|
+
// Terminals keep a keyboard mode stack per screen, so the reset comes before leaving it.
|
|
131
|
+
if (result?.reason !== 'exited') stdout.write(altScreen ? `${RESET}\x1b[?1049l` : `${RESET}\r\n`);
|
|
117
132
|
}
|
|
118
133
|
resolve(result ?? { reason: 'closed' });
|
|
119
134
|
}
|
|
@@ -129,6 +144,11 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
129
144
|
if (type === FRAME.data) {
|
|
130
145
|
for (const [, mode] of body.toString('latin1').matchAll(ALT_SCREEN)) altScreen = mode === 'h';
|
|
131
146
|
stdout.write(body);
|
|
147
|
+
// The host sends the screen only after it has told Pi that a terminal is attached.
|
|
148
|
+
if (!attached) {
|
|
149
|
+
attached = true;
|
|
150
|
+
onAttach?.();
|
|
151
|
+
}
|
|
132
152
|
}
|
|
133
153
|
else if (type === FRAME.detached) result = { ...json(body) };
|
|
134
154
|
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
|
}
|