@mjasnikovs/pi-task 0.2.2 → 0.4.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.
@@ -0,0 +1,149 @@
1
+ import { broadcast } from './broadcast.js';
2
+ import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession } from './bridge.js';
3
+ import { setupEvents } from './events.js';
4
+ import { HistoryBuffer } from './history.js';
5
+ import { html } from './ui.js';
6
+ import { qrLines } from './qr.js';
7
+ import { startServer, formatAddresses } from './server.js';
8
+ import { isAgentIdle } from './state.js';
9
+ const _g = globalThis;
10
+ if (!_g.__piRemote)
11
+ _g.__piRemote = { server: null, send: null };
12
+ const S = _g.__piRemote;
13
+ export function registerRemote(pi) {
14
+ const history = new HistoryBuffer(20);
15
+ /** Start the always-on remote server once; later calls return the live handle.
16
+ * Plain chat works immediately via S.send; remote slash commands and /new
17
+ * bind once any command supplies a command-capable ctx (see dispatchRemoteLine). */
18
+ async function ensureServer() {
19
+ if (S.server)
20
+ return S.server;
21
+ S.server = await startServer(text => {
22
+ if (text === '/new') {
23
+ // Route through the bridge's freshest command ctx with the same
24
+ // crash-safe guards as remote slash commands. The withSession
25
+ // rebind keeps S.send pointed at the new session's ctx.
26
+ dispatchRemoteNewSession(newCtx => {
27
+ // newCtx.sendUserMessage bypasses the stale runtime check
28
+ // (returns a Promise we deliberately discard).
29
+ S.send = (msg, opts) => {
30
+ void (opts ?
31
+ newCtx.sendUserMessage(msg, opts)
32
+ : newCtx.sendUserMessage(msg));
33
+ };
34
+ });
35
+ return;
36
+ }
37
+ // Slash commands route to the bridge's command registry;
38
+ // plain lines fall through to onPlain and go to the agent.
39
+ dispatchRemoteLine(text, {
40
+ onPlain: plain => {
41
+ // Persist remote-typed messages: they arrive via
42
+ // sendUserMessage with source "extension", which the
43
+ // interactive input handler skips, so record them
44
+ // here for reconnect/history.
45
+ history.addUserMessage(plain);
46
+ if (isAgentIdle()) {
47
+ S.send?.(plain);
48
+ }
49
+ else {
50
+ S.send?.(plain, { deliverAs: 'followUp' });
51
+ }
52
+ }
53
+ });
54
+ }, wsUrl => html(wsUrl), () => history.getEntries());
55
+ return S.server;
56
+ }
57
+ pi.on('session_start', (_event, ctx) => {
58
+ // Update shared send with fresh pi on each session (survives /new re-evaluation)
59
+ S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
60
+ setupEvents(pi, history, broadcast);
61
+ // Always-on: bring the remote server up automatically so no /remote is
62
+ // needed. Plain chat flows immediately via S.send; the local widget shows
63
+ // the reachable URLs. Run /remote to view the QR or fully enable remote
64
+ // slash commands.
65
+ // NOTE: we deliberately do NOT seed bridge.currentCtx here. The event ctx
66
+ // is the base ExtensionContext (no waitForIdle/newSession), so storing it
67
+ // would make a later remote /task throw. currentCtx is only ever set from
68
+ // a real command ctx: the /remote handler, registerBridgeCommand, and the
69
+ // remote-/new withSession rebind. See dispatchRemoteLine's capability guard.
70
+ void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
71
+ });
72
+ pi.on('session_shutdown', (event, _ctx) => {
73
+ if (event.reason === 'quit') {
74
+ if (S.server) {
75
+ S.server.stop();
76
+ S.server = null;
77
+ }
78
+ S.send = null;
79
+ }
80
+ });
81
+ pi.registerCommand('remote', {
82
+ description: 'Show the remote QR code & URLs (the server is always running)',
83
+ handler: async (args, ctx) => {
84
+ if (args.trim() === 'stop') {
85
+ if (S.server) {
86
+ S.server.stop();
87
+ S.server = null;
88
+ ctx.ui.notify('Remote server stopped', 'info');
89
+ }
90
+ else {
91
+ ctx.ui.notify('Remote server is not running', 'warning');
92
+ }
93
+ return;
94
+ }
95
+ // Capture this command-capable ctx so remote slash commands and /new
96
+ // work (the always-on auto-start can only bind plain chat).
97
+ getBridge().currentCtx = ctx;
98
+ try {
99
+ const server = await ensureServer();
100
+ // QR keeps encoding only the primary (Tailscale-preferred) URL.
101
+ const primaryUrl = `http://${server.ip}:${server.port}`;
102
+ const qr = await qrLines(primaryUrl);
103
+ // Labeled, block-aligned address lines: Tailscale + LAN when present.
104
+ const addrs = formatAddresses(server.ips, server.port);
105
+ const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
106
+ const addrLines = addrs.map(a => a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url);
107
+ const addrWidth = addrLines.reduce((m, l) => Math.max(m, l.length), 0);
108
+ if (ctx.mode === 'tui') {
109
+ // eslint-disable-next-line no-control-regex -- strip ANSI SGR escapes to measure visible width
110
+ const stripAnsi = (s) => s.replace(/\x1b\[[^m]*m/g, '');
111
+ const visWidth = qr.reduce((max, l) => Math.max(max, stripAnsi(l).length), 0);
112
+ const overlayWidth = Math.max(visWidth, addrWidth + 4, 36);
113
+ // Fire-and-forget: don't await so the command handler returns immediately
114
+ // (avoids "Working" lock if user runs /new before dismissing)
115
+ ctx.ui
116
+ .custom((_tui, _theme, _kb, done) => ({
117
+ focused: false,
118
+ render: w => {
119
+ const c = (s, len) => ' '.repeat(Math.max(0, Math.floor((w - len) / 2))) + s;
120
+ return [
121
+ '',
122
+ ...qr.map(l => c(l, visWidth)),
123
+ '',
124
+ ...addrLines.map(l => c(l, addrWidth)),
125
+ '',
126
+ c('Waiting for connection…', 23),
127
+ c('(any key to dismiss)', 20)
128
+ ];
129
+ },
130
+ handleInput: () => done(undefined),
131
+ invalidate: () => { },
132
+ dispose: () => done(undefined)
133
+ }), {
134
+ overlay: true,
135
+ overlayOptions: { width: overlayWidth },
136
+ onHandle: h => {
137
+ server.onFirstConnect = () => h.hide();
138
+ }
139
+ })
140
+ .catch(() => { });
141
+ }
142
+ ctx.ui.notify(`Remote running at ${primaryUrl}`, 'info');
143
+ }
144
+ catch (err) {
145
+ ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error');
146
+ }
147
+ }
148
+ });
149
+ }
@@ -0,0 +1,31 @@
1
+ import type { Turn } from './history.js';
2
+ export interface LocalIPs {
3
+ /** Tailscale (tailscale0) IPv4 address, if the interface is up. */
4
+ tailscale?: string;
5
+ /** First non-internal, non-Tailscale IPv4 address (the LAN address). */
6
+ lan?: string;
7
+ /** Address used for the QR code / primary URL: Tailscale, else LAN, else loopback. */
8
+ primary: string;
9
+ }
10
+ export interface AddressLine {
11
+ /** Network label (e.g. 'Tailscale', 'LAN'); empty for the loopback fallback. */
12
+ label: string;
13
+ /** Full http:// URL for that address. */
14
+ url: string;
15
+ }
16
+ export interface ServerHandle {
17
+ port: number;
18
+ /** Primary address (Tailscale-preferred); the one the QR encodes. */
19
+ ip: string;
20
+ /** All resolved addresses, for displaying both Tailscale and LAN URLs. */
21
+ ips: LocalIPs;
22
+ stop(): void;
23
+ onFirstConnect: (() => void) | null;
24
+ }
25
+ type MessageCallback = (text: string) => void;
26
+ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>): LocalIPs;
27
+ /** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
28
+ * when present; a single unlabeled primary URL when neither resolves. */
29
+ export declare function formatAddresses(ips: LocalIPs, port: number): AddressLine[];
30
+ export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, getHistory: () => Turn[]): Promise<ServerHandle>;
31
+ export {};
@@ -0,0 +1,126 @@
1
+ import { createServer } from 'node:http';
2
+ import { networkInterfaces } from 'node:os';
3
+ import { WebSocketServer } from 'ws';
4
+ import { addClient, removeClient, clientCount, broadcast, sendTo } from './broadcast.js';
5
+ import { getBridge, answerPrompt } from './bridge.js';
6
+ import { isClientMessage } from './protocol.js';
7
+ export function getLocalIPs(nets = networkInterfaces()) {
8
+ // Prefer Tailscale when available.
9
+ let tailscale;
10
+ for (const net of nets['tailscale0'] ?? []) {
11
+ if (net.family === 'IPv4') {
12
+ tailscale = net.address;
13
+ break;
14
+ }
15
+ }
16
+ // LAN = first non-internal IPv4 that isn't the Tailscale interface.
17
+ let lan;
18
+ for (const [name, iface] of Object.entries(nets)) {
19
+ if (!iface || name === 'tailscale0')
20
+ continue;
21
+ for (const net of iface) {
22
+ if (net.family === 'IPv4' && !net.internal) {
23
+ lan = net.address;
24
+ break;
25
+ }
26
+ }
27
+ if (lan)
28
+ break;
29
+ }
30
+ return { tailscale, lan, primary: tailscale ?? lan ?? '127.0.0.1' };
31
+ }
32
+ /** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
33
+ * when present; a single unlabeled primary URL when neither resolves. */
34
+ export function formatAddresses(ips, port) {
35
+ const out = [];
36
+ if (ips.tailscale)
37
+ out.push({ label: 'Tailscale', url: `http://${ips.tailscale}:${port}` });
38
+ if (ips.lan)
39
+ out.push({ label: 'LAN', url: `http://${ips.lan}:${port}` });
40
+ if (out.length === 0)
41
+ out.push({ label: '', url: `http://${ips.primary}:${port}` });
42
+ return out;
43
+ }
44
+ async function tryBind(port) {
45
+ return new Promise(resolve => {
46
+ const s = createServer();
47
+ s.listen(port, '0.0.0.0', () => {
48
+ s.close(() => resolve(true));
49
+ });
50
+ s.on('error', () => resolve(false));
51
+ });
52
+ }
53
+ async function findPort(start, max) {
54
+ for (let p = start; p < start + max; p++) {
55
+ if (await tryBind(p))
56
+ return p;
57
+ }
58
+ throw new Error(`No free port found in range ${start}–${start + max - 1}`);
59
+ }
60
+ export async function startServer(onMessage, getHtml, getHistory) {
61
+ const port = await findPort(8800, 100);
62
+ const ips = getLocalIPs();
63
+ const ip = ips.primary;
64
+ const wsUrl = `ws://${ip}:${port}/ws`;
65
+ const httpServer = createServer((req, res) => {
66
+ if (req.method === 'GET' && (req.url === '/' || req.url === '')) {
67
+ const body = getHtml(wsUrl);
68
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
69
+ res.end(body);
70
+ }
71
+ else {
72
+ res.writeHead(404);
73
+ res.end('Not found');
74
+ }
75
+ });
76
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
77
+ const handle = {
78
+ port,
79
+ ip,
80
+ ips,
81
+ onFirstConnect: null,
82
+ stop() {
83
+ wss.close();
84
+ httpServer.close();
85
+ }
86
+ };
87
+ wss.on('connection', ws => {
88
+ addClient(ws);
89
+ handle.onFirstConnect?.();
90
+ handle.onFirstConnect = null;
91
+ sendTo(ws, { type: 'history', turns: getHistory() });
92
+ const bridge = getBridge();
93
+ for (const [key, lines] of bridge.activeWidgets) {
94
+ sendTo(ws, { type: 'widget', key, lines });
95
+ }
96
+ if (bridge.activePrompt)
97
+ sendTo(ws, bridge.activePrompt);
98
+ broadcast({ type: 'client_count', count: clientCount() });
99
+ ws.on('message', data => {
100
+ let msg;
101
+ try {
102
+ msg = JSON.parse(data.toString());
103
+ }
104
+ catch {
105
+ return; // ignore malformed JSON
106
+ }
107
+ if (!isClientMessage(msg))
108
+ return;
109
+ if (msg.type === 'prompt_answer') {
110
+ answerPrompt(msg.id, msg.value);
111
+ return;
112
+ }
113
+ // type === 'message': ignore while a prompt is pending (composer is
114
+ // disabled in the browser; this is the server-side guard).
115
+ if (getBridge().activePrompt)
116
+ return;
117
+ onMessage(msg.text);
118
+ });
119
+ ws.on('close', () => {
120
+ removeClient(ws);
121
+ broadcast({ type: 'client_count', count: clientCount() });
122
+ });
123
+ });
124
+ await new Promise(resolve => httpServer.listen(port, '0.0.0.0', resolve));
125
+ return handle;
126
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isAgentIdle(): boolean;
2
+ export declare function setAgentIdle(idle: boolean): void;
@@ -0,0 +1,7 @@
1
+ let _isAgentIdle = true;
2
+ export function isAgentIdle() {
3
+ return _isAgentIdle;
4
+ }
5
+ export function setAgentIdle(idle) {
6
+ _isAgentIdle = idle;
7
+ }
@@ -0,0 +1 @@
1
+ export declare function html(wsUrl: string): string;