@mjasnikovs/pi-task 0.5.0 → 0.6.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/dist/remote/bridge.d.ts +3 -1
- package/dist/remote/bridge.js +1 -0
- package/dist/remote/events.js +3 -0
- package/dist/remote/protocol.d.ts +12 -1
- package/dist/remote/register.js +22 -5
- package/dist/remote/server.js +3 -0
- package/dist/remote/tailscale.d.ts +45 -0
- package/dist/remote/tailscale.js +108 -0
- package/dist/remote/ui.js +124 -23
- package/dist/workers/pi-worker-core.js +1 -1
- package/package.json +1 -1
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type { PromptMessage, ServerMessage } from './protocol.js';
|
|
2
|
+
import type { ContextUsage, PromptMessage, ServerMessage } from './protocol.js';
|
|
3
3
|
export interface BridgeState {
|
|
4
4
|
/** promptId → resolver that settles the remote side of an ask() race. */
|
|
5
5
|
pending: Map<string, (value: string | undefined) => void>;
|
|
@@ -7,6 +7,8 @@ export interface BridgeState {
|
|
|
7
7
|
activePrompt: PromptMessage | null;
|
|
8
8
|
/** Last lines pushed per widget key (replayed to late joiners). */
|
|
9
9
|
activeWidgets: Map<string, string[]>;
|
|
10
|
+
/** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
|
|
11
|
+
lastContextUsage: ContextUsage | null;
|
|
10
12
|
nextId: number;
|
|
11
13
|
/** Command name → handler, populated as pi-task registers its commands. */
|
|
12
14
|
commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
|
package/dist/remote/bridge.js
CHANGED
package/dist/remote/events.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { setAgentIdle } from './state.js';
|
|
2
|
+
import { getBridge } from './bridge.js';
|
|
2
3
|
export function setupEvents(pi, history, broadcastFn) {
|
|
3
4
|
let currentText = '';
|
|
4
5
|
const currentTools = [];
|
|
@@ -67,6 +68,8 @@ export function setupEvents(pi, history, broadcastFn) {
|
|
|
67
68
|
pi.on('agent_end', (_event, ctx) => {
|
|
68
69
|
const contextUsage = ctx.getContextUsage();
|
|
69
70
|
setAgentIdle(true);
|
|
71
|
+
// Remember it so a browser that connects mid-session gets its bar seeded.
|
|
72
|
+
getBridge().lastContextUsage = contextUsage;
|
|
70
73
|
history.addAssistantTurn(currentText, [...currentTools]);
|
|
71
74
|
broadcastFn({ type: 'agent_end', contextUsage });
|
|
72
75
|
currentText = '';
|
|
@@ -24,10 +24,21 @@ export interface ViewerMessage {
|
|
|
24
24
|
title: string;
|
|
25
25
|
text: string;
|
|
26
26
|
}
|
|
27
|
+
export interface ContextUsage {
|
|
28
|
+
tokens?: number;
|
|
29
|
+
contextWindow?: number;
|
|
30
|
+
percent?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Seeds the context-usage bar for a freshly-connected client (the live value is
|
|
33
|
+
* otherwise only carried on agent_end). */
|
|
34
|
+
export interface ContextMessage {
|
|
35
|
+
type: 'context';
|
|
36
|
+
contextUsage: ContextUsage;
|
|
37
|
+
}
|
|
27
38
|
/** Server → browser messages added by the integration. The existing
|
|
28
39
|
* history / text_delta / tool_* / agent_* / client_count / user_message messages are
|
|
29
40
|
* emitted ad hoc by events.ts / server.ts and are not enumerated here. */
|
|
30
|
-
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage;
|
|
41
|
+
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage;
|
|
31
42
|
/** Browser → server messages. */
|
|
32
43
|
export interface ClientChatMessage {
|
|
33
44
|
type: 'message';
|
package/dist/remote/register.js
CHANGED
|
@@ -5,10 +5,11 @@ import { HistoryBuffer } from './history.js';
|
|
|
5
5
|
import { html } from './ui.js';
|
|
6
6
|
import { qrLines } from './qr.js';
|
|
7
7
|
import { startServer, formatAddresses } from './server.js';
|
|
8
|
+
import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls } from './tailscale.js';
|
|
8
9
|
import { isAgentIdle } from './state.js';
|
|
9
10
|
const _g = globalThis;
|
|
10
11
|
if (!_g.__piRemote)
|
|
11
|
-
_g.__piRemote = { server: null, send: null };
|
|
12
|
+
_g.__piRemote = { server: null, send: null, serveResult: null };
|
|
12
13
|
const S = _g.__piRemote;
|
|
13
14
|
export function registerRemote(pi) {
|
|
14
15
|
const history = new HistoryBuffer(20);
|
|
@@ -38,6 +39,9 @@ export function registerRemote(pi) {
|
|
|
38
39
|
}
|
|
39
40
|
});
|
|
40
41
|
}, wsUrl => html(wsUrl), () => history.getEntries());
|
|
42
|
+
// Hands-off HTTPS: point Tailscale serve at our port so phones get a
|
|
43
|
+
// secure context. Best-effort — any failure degrades to the http URL.
|
|
44
|
+
S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
|
|
41
45
|
return S.server;
|
|
42
46
|
}
|
|
43
47
|
pi.on('session_start', (_event, ctx) => {
|
|
@@ -50,7 +54,8 @@ export function registerRemote(pi) {
|
|
|
50
54
|
// session_start (it's updated via withSession or registerBridgeCommand,
|
|
51
55
|
// not replaced here).
|
|
52
56
|
const b = getBridge();
|
|
53
|
-
if (!b.currentCtx
|
|
57
|
+
if (!b.currentCtx
|
|
58
|
+
|| b.currentCtx['__piRemoteShimmed'] === true) {
|
|
54
59
|
b.currentCtx = makeShimmedCtx(ctx);
|
|
55
60
|
}
|
|
56
61
|
void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
|
|
@@ -58,8 +63,11 @@ export function registerRemote(pi) {
|
|
|
58
63
|
pi.on('session_shutdown', (event, _ctx) => {
|
|
59
64
|
if (event.reason === 'quit') {
|
|
60
65
|
if (S.server) {
|
|
66
|
+
const port = S.server.port;
|
|
61
67
|
S.server.stop();
|
|
62
68
|
S.server = null;
|
|
69
|
+
S.serveResult = null;
|
|
70
|
+
void teardownTailscaleServe(port).catch(() => { });
|
|
63
71
|
}
|
|
64
72
|
S.send = null;
|
|
65
73
|
}
|
|
@@ -69,8 +77,11 @@ export function registerRemote(pi) {
|
|
|
69
77
|
handler: async (args, ctx) => {
|
|
70
78
|
if (args.trim() === 'stop') {
|
|
71
79
|
if (S.server) {
|
|
80
|
+
const port = S.server.port;
|
|
72
81
|
S.server.stop();
|
|
73
82
|
S.server = null;
|
|
83
|
+
S.serveResult = null;
|
|
84
|
+
void teardownTailscaleServe(port).catch(() => { });
|
|
74
85
|
ctx.ui.notify('Remote server stopped', 'info');
|
|
75
86
|
}
|
|
76
87
|
else {
|
|
@@ -82,11 +93,17 @@ export function registerRemote(pi) {
|
|
|
82
93
|
getBridge().currentCtx = ctx;
|
|
83
94
|
try {
|
|
84
95
|
const server = await ensureServer();
|
|
85
|
-
const
|
|
96
|
+
const httpPrimary = `http://${server.ip}:${server.port}`;
|
|
97
|
+
const result = S.serveResult ?? { state: 'unavailable' };
|
|
98
|
+
const plan = planRemoteUrls(httpPrimary, result);
|
|
99
|
+
const primaryUrl = plan.primaryUrl;
|
|
86
100
|
const qr = await qrLines(primaryUrl);
|
|
87
|
-
const addrs = formatAddresses(server.ips, server.port);
|
|
101
|
+
const addrs = [...plan.urlLines, ...formatAddresses(server.ips, server.port)];
|
|
88
102
|
const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
|
|
89
|
-
const addrLines =
|
|
103
|
+
const addrLines = [
|
|
104
|
+
...addrs.map(a => (a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url)),
|
|
105
|
+
...plan.hintLines
|
|
106
|
+
];
|
|
90
107
|
const addrWidth = addrLines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
91
108
|
if (ctx.mode === 'tui') {
|
|
92
109
|
// eslint-disable-next-line no-control-regex -- strip ANSI SGR escapes to measure visible width
|
package/dist/remote/server.js
CHANGED
|
@@ -95,6 +95,9 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
95
95
|
}
|
|
96
96
|
if (bridge.activePrompt)
|
|
97
97
|
sendTo(ws, bridge.activePrompt);
|
|
98
|
+
if (bridge.lastContextUsage) {
|
|
99
|
+
sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
|
|
100
|
+
}
|
|
98
101
|
broadcast({ type: 'client_count', count: clientCount() });
|
|
99
102
|
ws.on('message', data => {
|
|
100
103
|
let msg;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Injectable command runner so serve orchestration is unit-testable without a real CLI. */
|
|
2
|
+
export interface Run {
|
|
3
|
+
(cmd: string, args: string[]): Promise<{
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
exitCode: number;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export type ServeResult = {
|
|
10
|
+
state: 'served';
|
|
11
|
+
url: string;
|
|
12
|
+
} | {
|
|
13
|
+
state: 'foreign-conflict';
|
|
14
|
+
host: string;
|
|
15
|
+
} | {
|
|
16
|
+
state: 'certs-disabled';
|
|
17
|
+
host: string;
|
|
18
|
+
} | {
|
|
19
|
+
state: 'unavailable';
|
|
20
|
+
};
|
|
21
|
+
export interface RemoteUrlPlan {
|
|
22
|
+
/** URL to encode in the QR and announce; the https one when serve is live. */
|
|
23
|
+
primaryUrl: string;
|
|
24
|
+
/** Labeled URL lines to add to the address list (e.g. the HTTPS URL when served). */
|
|
25
|
+
urlLines: {
|
|
26
|
+
label: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}[];
|
|
29
|
+
/** Extra lines to print under the URLs (empty when nothing to suggest). */
|
|
30
|
+
hintLines: string[];
|
|
31
|
+
}
|
|
32
|
+
/** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
|
|
33
|
+
* or undefined when there is no such handler / the output isn't valid serve JSON
|
|
34
|
+
* (e.g. the literal "No serve config"). */
|
|
35
|
+
export declare function serve443Target(json: string): string | undefined;
|
|
36
|
+
/** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
|
|
37
|
+
* when needed and never touches a serve config we didn't create. Best-effort:
|
|
38
|
+
* returns a non-served state rather than throwing. */
|
|
39
|
+
export declare function ensureTailscaleServe(port: number, run?: Run): Promise<ServeResult>;
|
|
40
|
+
/** Tear down the :443 serve handler ONLY if it currently points at our port.
|
|
41
|
+
* Best-effort; callers should additionally `.catch()` since it may run during
|
|
42
|
+
* shutdown. */
|
|
43
|
+
export declare function teardownTailscaleServe(port: number, run?: Run): Promise<void>;
|
|
44
|
+
/** Pure: pick the primary URL and any hint lines from a serve result. */
|
|
45
|
+
export declare function planRemoteUrls(httpPrimary: string, result: ServeResult): RemoteUrlPlan;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
const defaultRun = (cmd, args) => new Promise(resolve => {
|
|
3
|
+
execFile(cmd, args, { timeout: 5000 }, (err, stdout, stderr) => {
|
|
4
|
+
const exitCode = err && typeof err.code === 'number' ?
|
|
5
|
+
err.code
|
|
6
|
+
: err ? 1
|
|
7
|
+
: 0;
|
|
8
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
/** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
|
|
12
|
+
* or undefined when there is no such handler / the output isn't valid serve JSON
|
|
13
|
+
* (e.g. the literal "No serve config"). */
|
|
14
|
+
export function serve443Target(json) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(json);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const web = parsed
|
|
23
|
+
.Web;
|
|
24
|
+
if (!web)
|
|
25
|
+
return undefined;
|
|
26
|
+
for (const key of Object.keys(web)) {
|
|
27
|
+
if (key.endsWith(':443'))
|
|
28
|
+
return web[key]?.Handlers?.['/']?.Proxy;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Tailscale MagicDNS name (trailing dot stripped), or undefined if the daemon
|
|
33
|
+
* isn't reachable / has no name. */
|
|
34
|
+
async function tailscaleHost(run) {
|
|
35
|
+
const status = await run('tailscale', ['status', '--json']);
|
|
36
|
+
if (status.exitCode !== 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(status.stdout);
|
|
40
|
+
return (parsed.Self?.DNSName ?? '').replace(/\.$/, '') || undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
|
|
47
|
+
* when needed and never touches a serve config we didn't create. Best-effort:
|
|
48
|
+
* returns a non-served state rather than throwing. */
|
|
49
|
+
export async function ensureTailscaleServe(port, run = defaultRun) {
|
|
50
|
+
const host = await tailscaleHost(run);
|
|
51
|
+
if (!host)
|
|
52
|
+
return { state: 'unavailable' };
|
|
53
|
+
const ours = `http://127.0.0.1:${port}`;
|
|
54
|
+
const serve = await run('tailscale', ['serve', 'status', '--json']);
|
|
55
|
+
const target = serve443Target(serve.stdout);
|
|
56
|
+
if (target === ours)
|
|
57
|
+
return { state: 'served', url: `https://${host}` };
|
|
58
|
+
if (target !== undefined)
|
|
59
|
+
return { state: 'foreign-conflict', host };
|
|
60
|
+
// No :443 handler yet — check cert capability before trying to create one.
|
|
61
|
+
const cert = await run('tailscale', ['cert']);
|
|
62
|
+
if (/HTTPS cert support is not enabled/i.test(cert.stdout + cert.stderr)) {
|
|
63
|
+
return { state: 'certs-disabled', host };
|
|
64
|
+
}
|
|
65
|
+
const set = await run('tailscale', ['serve', '--bg', '--https=443', ours]);
|
|
66
|
+
if (set.exitCode === 0)
|
|
67
|
+
return { state: 'served', url: `https://${host}` };
|
|
68
|
+
return { state: 'certs-disabled', host };
|
|
69
|
+
}
|
|
70
|
+
/** Tear down the :443 serve handler ONLY if it currently points at our port.
|
|
71
|
+
* Best-effort; callers should additionally `.catch()` since it may run during
|
|
72
|
+
* shutdown. */
|
|
73
|
+
export async function teardownTailscaleServe(port, run = defaultRun) {
|
|
74
|
+
const serve = await run('tailscale', ['serve', 'status', '--json']);
|
|
75
|
+
if (serve443Target(serve.stdout) === `http://127.0.0.1:${port}`) {
|
|
76
|
+
await run('tailscale', ['serve', '--https=443', 'off']);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Pure: pick the primary URL and any hint lines from a serve result. */
|
|
80
|
+
export function planRemoteUrls(httpPrimary, result) {
|
|
81
|
+
switch (result.state) {
|
|
82
|
+
case 'served':
|
|
83
|
+
return {
|
|
84
|
+
primaryUrl: result.url,
|
|
85
|
+
urlLines: [{ label: 'HTTPS', url: result.url }],
|
|
86
|
+
hintLines: []
|
|
87
|
+
};
|
|
88
|
+
case 'foreign-conflict':
|
|
89
|
+
return {
|
|
90
|
+
primaryUrl: httpPrimary,
|
|
91
|
+
urlLines: [],
|
|
92
|
+
hintLines: [
|
|
93
|
+
'HTTPS: port 443 is already used by another tailscale serve config; not touching it.',
|
|
94
|
+
' Free it (tailscale serve --https=443 off) to enable phone notifications.'
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
case 'certs-disabled':
|
|
98
|
+
return {
|
|
99
|
+
primaryUrl: httpPrimary,
|
|
100
|
+
urlLines: [],
|
|
101
|
+
hintLines: [
|
|
102
|
+
'HTTPS (for phone notifications): enable HTTPS in the Tailscale admin console, then restart the remote.'
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
case 'unavailable':
|
|
106
|
+
return { primaryUrl: httpPrimary, urlLines: [], hintLines: [] };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/remote/ui.js
CHANGED
|
@@ -38,7 +38,7 @@ export function html(wsUrl) {
|
|
|
38
38
|
font-family: ui-monospace, monospace; height: 100dvh;
|
|
39
39
|
display: flex; flex-direction: column; overflow: hidden;
|
|
40
40
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
|
|
41
|
-
|
|
41
|
+
0px env(safe-area-inset-left, 0px);
|
|
42
42
|
}
|
|
43
43
|
#context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }
|
|
44
44
|
#context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }
|
|
@@ -47,8 +47,27 @@ export function html(wsUrl) {
|
|
|
47
47
|
display: flex; justify-content: space-between; align-items: center;
|
|
48
48
|
font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);
|
|
49
49
|
}
|
|
50
|
-
#header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
|
|
51
|
-
|
|
50
|
+
#header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
|
|
51
|
+
position: relative; animation: glitch 5s steps(1) infinite; }
|
|
52
|
+
@keyframes glitch {
|
|
53
|
+
0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }
|
|
54
|
+
90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }
|
|
55
|
+
92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }
|
|
56
|
+
94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }
|
|
57
|
+
96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }
|
|
58
|
+
}
|
|
59
|
+
@media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }
|
|
60
|
+
#header .status { color: var(--subtext0); font-size: 11px; display: inline-flex; align-items: center; gap: 5px; }
|
|
61
|
+
#header .cdot { color: var(--yellow); }
|
|
62
|
+
#header .cdot.up { color: var(--green); }
|
|
63
|
+
#header .cdot.down { color: var(--red); }
|
|
64
|
+
#header .hgroup { display: flex; align-items: center; gap: 10px; }
|
|
65
|
+
#bell {
|
|
66
|
+
background: none; border: none; color: var(--subtext1); cursor: pointer;
|
|
67
|
+
font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;
|
|
68
|
+
}
|
|
69
|
+
#bell:hover { color: var(--text); }
|
|
70
|
+
#bell.on { color: var(--mauve); }
|
|
52
71
|
#chat-log {
|
|
53
72
|
flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
|
|
54
73
|
display: flex; flex-direction: column; gap: 8px;
|
|
@@ -67,17 +86,11 @@ export function html(wsUrl) {
|
|
|
67
86
|
max-width: 100%; border: 1px solid var(--red); font-size: 12px;
|
|
68
87
|
}
|
|
69
88
|
.bubble.thinking {
|
|
70
|
-
display: flex; gap: 5px; align-items: center; padding:
|
|
89
|
+
display: flex; gap: 5px; align-items: center; padding: 10px 14px;
|
|
71
90
|
}
|
|
72
|
-
.bubble.thinking .
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
.bubble.thinking .dot:nth-child(1) { animation-delay: -0.24s; }
|
|
77
|
-
.bubble.thinking .dot:nth-child(2) { animation-delay: -0.12s; }
|
|
78
|
-
@keyframes thinking-bounce {
|
|
79
|
-
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
|
80
|
-
40% { transform: scale(1); opacity: 1; }
|
|
91
|
+
.bubble.thinking .spinner {
|
|
92
|
+
color: var(--mauve); font-size: 15px; line-height: 1;
|
|
93
|
+
font-family: ui-monospace, monospace;
|
|
81
94
|
}
|
|
82
95
|
.tool-call {
|
|
83
96
|
background: var(--crust); border-radius: 6px; align-self: flex-start;
|
|
@@ -86,6 +99,7 @@ export function html(wsUrl) {
|
|
|
86
99
|
.tool-call summary {
|
|
87
100
|
padding: 6px 10px; color: var(--subtext1); cursor: pointer;
|
|
88
101
|
user-select: none; list-style: none;
|
|
102
|
+
overflow-wrap: anywhere; word-break: break-word;
|
|
89
103
|
}
|
|
90
104
|
.tool-call summary::-webkit-details-marker { display: none; }
|
|
91
105
|
.tool-call summary::before { content: "▶ "; }
|
|
@@ -116,7 +130,7 @@ export function html(wsUrl) {
|
|
|
116
130
|
.hl-num { color: var(--blue); }
|
|
117
131
|
.hl-fn { color: var(--yellow); }
|
|
118
132
|
#input-bar {
|
|
119
|
-
background: var(--mantle); padding: 10px 16px;
|
|
133
|
+
background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
|
|
120
134
|
display: flex; gap: 8px; flex-shrink: 0;
|
|
121
135
|
border-top: 1px solid var(--surface0);
|
|
122
136
|
position: relative;
|
|
@@ -194,7 +208,8 @@ export function html(wsUrl) {
|
|
|
194
208
|
font-size: 12px; font-weight: 500; padding: 8px 10px; }
|
|
195
209
|
#prompt-card button.cancel:hover { color: var(--red); filter: none; }
|
|
196
210
|
#prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
|
|
197
|
-
.toast { position: fixed; top: 12px; right: 12px;
|
|
211
|
+
.toast { position: fixed; top: 12px; right: 12px; max-width: calc(100vw - 24px);
|
|
212
|
+
padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
|
|
198
213
|
background: var(--surface1); color: var(--text); z-index: 60; }
|
|
199
214
|
.toast.warning { background: var(--peach); color: var(--crust); }
|
|
200
215
|
.toast.error { background: var(--red); color: var(--crust); }
|
|
@@ -208,7 +223,10 @@ export function html(wsUrl) {
|
|
|
208
223
|
<div id="context-bar"><div id="context-bar-fill"></div></div>
|
|
209
224
|
<div id="header">
|
|
210
225
|
<span class="title">pi-task remote</span>
|
|
211
|
-
<
|
|
226
|
+
<div class="hgroup">
|
|
227
|
+
<span class="status" id="client-status"><span class="cdot" id="conn-dot">○</span><span id="client-label">connecting…</span></span>
|
|
228
|
+
<button id="bell" aria-label="Toggle notifications" title="Notifications">◯</button>
|
|
229
|
+
</div>
|
|
212
230
|
</div>
|
|
213
231
|
<div id="chat-log"></div>
|
|
214
232
|
<div id="status-panel"></div>
|
|
@@ -242,7 +260,17 @@ export function html(wsUrl) {
|
|
|
242
260
|
const inputEl = document.getElementById('input');
|
|
243
261
|
const sendBtn = document.getElementById('send');
|
|
244
262
|
const contextFill = document.getElementById('context-bar-fill');
|
|
245
|
-
|
|
263
|
+
function setContextBar(usage) {
|
|
264
|
+
if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
|
|
265
|
+
}
|
|
266
|
+
const connDot = document.getElementById('conn-dot');
|
|
267
|
+
const clientLabel = document.getElementById('client-label');
|
|
268
|
+
// state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
|
|
269
|
+
function setConn(state, label) {
|
|
270
|
+
connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
|
|
271
|
+
connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
|
|
272
|
+
if (label !== undefined) clientLabel.textContent = label;
|
|
273
|
+
}
|
|
246
274
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
247
275
|
const reconnectMsg = document.getElementById('reconnect-msg');
|
|
248
276
|
const cmdSuggestions = document.getElementById('cmd-suggestions');
|
|
@@ -444,16 +472,28 @@ export function html(wsUrl) {
|
|
|
444
472
|
}
|
|
445
473
|
|
|
446
474
|
let thinkingEl = null;
|
|
475
|
+
let spinTimer = null;
|
|
476
|
+
let spinIdx = 0;
|
|
477
|
+
const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
|
|
447
478
|
function showThinking() {
|
|
448
479
|
if (!thinkingEl) {
|
|
449
480
|
thinkingEl = document.createElement('div');
|
|
450
481
|
thinkingEl.className = 'bubble assistant thinking';
|
|
451
|
-
thinkingEl.innerHTML = '<span class="
|
|
482
|
+
thinkingEl.innerHTML = '<span class="spinner"></span>';
|
|
483
|
+
}
|
|
484
|
+
if (!spinTimer) {
|
|
485
|
+
var sp = thinkingEl.firstChild;
|
|
486
|
+
sp.textContent = SPIN[spinIdx % SPIN.length];
|
|
487
|
+
spinTimer = setInterval(function () {
|
|
488
|
+
spinIdx = (spinIdx + 1) % SPIN.length;
|
|
489
|
+
sp.textContent = SPIN[spinIdx];
|
|
490
|
+
}, 90);
|
|
452
491
|
}
|
|
453
492
|
chatLog.appendChild(thinkingEl); // append (or move) to bottom
|
|
454
493
|
scrollBottom();
|
|
455
494
|
}
|
|
456
495
|
function hideThinking() {
|
|
496
|
+
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
457
497
|
if (thinkingEl) thinkingEl.remove();
|
|
458
498
|
}
|
|
459
499
|
|
|
@@ -483,6 +523,61 @@ export function html(wsUrl) {
|
|
|
483
523
|
setTimeout(function () { t.remove(); }, 4000);
|
|
484
524
|
}
|
|
485
525
|
|
|
526
|
+
const bell = document.getElementById('bell');
|
|
527
|
+
const NOTIFY_KEY = 'piRemoteNotify';
|
|
528
|
+
|
|
529
|
+
function notifyEnabled() {
|
|
530
|
+
return localStorage.getItem(NOTIFY_KEY) === '1'
|
|
531
|
+
&& typeof Notification !== 'undefined'
|
|
532
|
+
&& Notification.permission === 'granted';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function updateBell() {
|
|
536
|
+
// ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
|
|
537
|
+
var on = notifyEnabled();
|
|
538
|
+
bell.textContent = on ? '\\u25C9' : '\\u25EF';
|
|
539
|
+
bell.classList.toggle('on', on);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Why notifications can't be enabled here, or null if they can.
|
|
543
|
+
function notifyEnvIssue() {
|
|
544
|
+
if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
|
|
545
|
+
if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
|
|
546
|
+
const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
|
|
547
|
+
const standalone = navigator.standalone === true
|
|
548
|
+
|| (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
|
|
549
|
+
if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
bell.addEventListener('click', function () {
|
|
554
|
+
// Turning OFF always works regardless of environment.
|
|
555
|
+
if (localStorage.getItem(NOTIFY_KEY) === '1') {
|
|
556
|
+
localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
|
|
557
|
+
}
|
|
558
|
+
const issue = notifyEnvIssue();
|
|
559
|
+
if (issue) { showToast(issue, 'warning'); return; }
|
|
560
|
+
Notification.requestPermission().then(function (perm) {
|
|
561
|
+
if (perm === 'granted') { localStorage.setItem(NOTIFY_KEY, '1'); }
|
|
562
|
+
else { showToast('Notifications blocked in browser settings.', 'warning'); }
|
|
563
|
+
updateBell();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Fire a browser notification, but only when armed, in a secure context,
|
|
568
|
+
// and the tab is backgrounded (foreground already has the in-page UI).
|
|
569
|
+
function notify(title, body, tag) {
|
|
570
|
+
if (!notifyEnabled()) return;
|
|
571
|
+
if (!window.isSecureContext) return;
|
|
572
|
+
if (!document.hidden) return;
|
|
573
|
+
try {
|
|
574
|
+
const n = new Notification(title, { body: body || '', tag: tag });
|
|
575
|
+
n.onclick = function () { window.focus(); n.close(); };
|
|
576
|
+
} catch (e) { /* constructor unsupported (e.g. iOS without SW) */ }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
updateBell();
|
|
580
|
+
|
|
486
581
|
function answer(value) {
|
|
487
582
|
if (activePromptId === null) return;
|
|
488
583
|
ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
|
|
@@ -663,6 +758,7 @@ export function html(wsUrl) {
|
|
|
663
758
|
streamText = '';
|
|
664
759
|
}
|
|
665
760
|
addBubble('error', msg.message || 'Error');
|
|
761
|
+
notify('Agent error', msg.message || 'Error', 'pi-error');
|
|
666
762
|
setEnabled(true);
|
|
667
763
|
break;
|
|
668
764
|
case 'agent_end':
|
|
@@ -670,15 +766,19 @@ export function html(wsUrl) {
|
|
|
670
766
|
currentBubble = null;
|
|
671
767
|
streamText = '';
|
|
672
768
|
setEnabled(true);
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
769
|
+
notify('Task finished', '', 'pi-end');
|
|
770
|
+
setContextBar(msg.contextUsage);
|
|
771
|
+
break;
|
|
772
|
+
case 'context':
|
|
773
|
+
// Seeds the bar for a client that joined mid-session.
|
|
774
|
+
setContextBar(msg.contextUsage);
|
|
676
775
|
break;
|
|
677
776
|
case 'client_count':
|
|
678
|
-
|
|
777
|
+
setConn('up', msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected');
|
|
679
778
|
break;
|
|
680
779
|
case 'prompt':
|
|
681
780
|
showPrompt(msg);
|
|
781
|
+
notify('pi needs your input', msg.question, 'pi-prompt');
|
|
682
782
|
break;
|
|
683
783
|
case 'prompt_resolved':
|
|
684
784
|
if (activePromptId === msg.id) closePrompt();
|
|
@@ -739,7 +839,7 @@ export function html(wsUrl) {
|
|
|
739
839
|
ws.addEventListener('open', () => {
|
|
740
840
|
reconnectOverlay.classList.remove('visible');
|
|
741
841
|
reconnectDelay = 1000;
|
|
742
|
-
|
|
842
|
+
setConn('up', 'connected');
|
|
743
843
|
setEnabled(true);
|
|
744
844
|
});
|
|
745
845
|
ws.addEventListener('message', (e) => {
|
|
@@ -747,6 +847,7 @@ export function html(wsUrl) {
|
|
|
747
847
|
});
|
|
748
848
|
ws.addEventListener('close', () => {
|
|
749
849
|
setEnabled(false);
|
|
850
|
+
setConn('down', 'disconnected');
|
|
750
851
|
reconnectOverlay.classList.add('visible');
|
|
751
852
|
reconnectMsg.textContent = 'reconnecting in ' + (reconnectDelay / 1000) + 's…';
|
|
752
853
|
setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
|
|
@@ -19,7 +19,7 @@ export async function runWorker(input) {
|
|
|
19
19
|
const result = await runChildDefault(invocation, input.cwd, input.signal, {
|
|
20
20
|
mode: 'json-events',
|
|
21
21
|
onFirstByte: () => (tFirstByte = Date.now()),
|
|
22
|
-
onToolCall:
|
|
22
|
+
onToolCall: call => loopDetector.record(call)
|
|
23
23
|
}, input.spawn);
|
|
24
24
|
const tEnd = Date.now();
|
|
25
25
|
const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|