@oratis/lisa 0.9.0 → 0.11.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 +123 -16
- package/README.zh-CN.md +101 -15
- package/completions/_lisa +56 -0
- package/completions/lisa.bash +35 -1
- package/completions/lisa.fish +18 -1
- package/dist/advisor/engine.d.ts +7 -0
- package/dist/advisor/engine.d.ts.map +1 -1
- package/dist/advisor/engine.js +12 -0
- package/dist/advisor/engine.js.map +1 -1
- package/dist/agent.d.ts +14 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +58 -7
- package/dist/agent.js.map +1 -1
- package/dist/agents/managed.d.ts +108 -0
- package/dist/agents/managed.d.ts.map +1 -0
- package/dist/agents/managed.js +241 -0
- package/dist/agents/managed.js.map +1 -0
- package/dist/agents/pty.d.ts +155 -0
- package/dist/agents/pty.d.ts.map +1 -0
- package/dist/agents/pty.js +299 -0
- package/dist/agents/pty.js.map +1 -0
- package/dist/approval.d.ts +13 -0
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +25 -2
- package/dist/approval.js.map +1 -1
- package/dist/autonomy/runs.d.ts +33 -0
- package/dist/autonomy/runs.d.ts.map +1 -0
- package/dist/autonomy/runs.js +106 -0
- package/dist/autonomy/runs.js.map +1 -0
- package/dist/channels/feishu.d.ts.map +1 -1
- package/dist/channels/feishu.js +65 -0
- package/dist/channels/feishu.js.map +1 -1
- package/dist/channels/router.d.ts +7 -0
- package/dist/channels/router.d.ts.map +1 -1
- package/dist/channels/router.js +1 -1
- package/dist/channels/router.js.map +1 -1
- package/dist/cli/agents-pty.d.ts +18 -0
- package/dist/cli/agents-pty.d.ts.map +1 -0
- package/dist/cli/agents-pty.js +182 -0
- package/dist/cli/agents-pty.js.map +1 -0
- package/dist/cli/agents.d.ts +2 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +65 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/consent.d.ts +2 -0
- package/dist/cli/consent.d.ts.map +1 -0
- package/dist/cli/consent.js +57 -0
- package/dist/cli/consent.js.map +1 -0
- package/dist/cli/model.d.ts +2 -0
- package/dist/cli/model.d.ts.map +1 -0
- package/dist/cli/model.js +121 -0
- package/dist/cli/model.js.map +1 -0
- package/dist/cli/sense.d.ts +2 -0
- package/dist/cli/sense.d.ts.map +1 -0
- package/dist/cli/sense.js +36 -0
- package/dist/cli/sense.js.map +1 -0
- package/dist/cli.js +117 -12
- package/dist/cli.js.map +1 -1
- package/dist/consent/blacklist.d.ts +28 -0
- package/dist/consent/blacklist.d.ts.map +1 -0
- package/dist/consent/blacklist.js +78 -0
- package/dist/consent/blacklist.js.map +1 -0
- package/dist/consent/store.d.ts +45 -0
- package/dist/consent/store.d.ts.map +1 -0
- package/dist/consent/store.js +126 -0
- package/dist/consent/store.js.map +1 -0
- package/dist/control/policy.d.ts +13 -0
- package/dist/control/policy.d.ts.map +1 -0
- package/dist/control/policy.js +57 -0
- package/dist/control/policy.js.map +1 -0
- package/dist/heartbeat/runner.d.ts.map +1 -1
- package/dist/heartbeat/runner.js +70 -14
- package/dist/heartbeat/runner.js.map +1 -1
- package/dist/idle/runner.d.ts.map +1 -1
- package/dist/idle/runner.js +87 -17
- package/dist/idle/runner.js.map +1 -1
- package/dist/integrations/aider/observer.d.ts +21 -1
- package/dist/integrations/aider/observer.d.ts.map +1 -1
- package/dist/integrations/aider/observer.js +110 -1
- package/dist/integrations/aider/observer.js.map +1 -1
- package/dist/integrations/claude-code/liveness.d.ts +10 -0
- package/dist/integrations/claude-code/liveness.d.ts.map +1 -0
- package/dist/integrations/claude-code/liveness.js +62 -0
- package/dist/integrations/claude-code/liveness.js.map +1 -0
- package/dist/integrations/claude-code/watcher.d.ts +39 -4
- package/dist/integrations/claude-code/watcher.d.ts.map +1 -1
- package/dist/integrations/claude-code/watcher.js +94 -57
- package/dist/integrations/claude-code/watcher.js.map +1 -1
- package/dist/integrations/codex/observer.d.ts +19 -6
- package/dist/integrations/codex/observer.d.ts.map +1 -1
- package/dist/integrations/codex/observer.js +228 -4
- package/dist/integrations/codex/observer.js.map +1 -1
- package/dist/integrations/dispatch-ledger.d.ts +30 -1
- package/dist/integrations/dispatch-ledger.d.ts.map +1 -1
- package/dist/integrations/dispatch-ledger.js +75 -9
- package/dist/integrations/dispatch-ledger.js.map +1 -1
- package/dist/integrations/git/branch.d.ts +11 -0
- package/dist/integrations/git/branch.d.ts.map +1 -0
- package/dist/integrations/git/branch.js +49 -0
- package/dist/integrations/git/branch.js.map +1 -0
- package/dist/integrations/git/observer.d.ts +39 -0
- package/dist/integrations/git/observer.d.ts.map +1 -0
- package/dist/integrations/git/observer.js +252 -0
- package/dist/integrations/git/observer.js.map +1 -0
- package/dist/integrations/github-pr/observer.d.ts.map +1 -1
- package/dist/integrations/github-pr/observer.js +7 -2
- package/dist/integrations/github-pr/observer.js.map +1 -1
- package/dist/integrations/hub.d.ts +8 -0
- package/dist/integrations/hub.d.ts.map +1 -1
- package/dist/integrations/hub.js +39 -1
- package/dist/integrations/hub.js.map +1 -1
- package/dist/integrations/managed/observer.d.ts +17 -0
- package/dist/integrations/managed/observer.d.ts.map +1 -0
- package/dist/integrations/managed/observer.js +46 -0
- package/dist/integrations/managed/observer.js.map +1 -0
- package/dist/integrations/opencode/observer.d.ts +53 -5
- package/dist/integrations/opencode/observer.d.ts.map +1 -1
- package/dist/integrations/opencode/observer.js +242 -20
- package/dist/integrations/opencode/observer.js.map +1 -1
- package/dist/integrations/pty/observer.d.ts +25 -0
- package/dist/integrations/pty/observer.d.ts.map +1 -0
- package/dist/integrations/pty/observer.js +48 -0
- package/dist/integrations/pty/observer.js.map +1 -0
- package/dist/integrations/registry.d.ts.map +1 -1
- package/dist/integrations/registry.js +5 -0
- package/dist/integrations/registry.js.map +1 -1
- package/dist/integrations/shell/observer.d.ts +33 -0
- package/dist/integrations/shell/observer.d.ts.map +1 -0
- package/dist/integrations/shell/observer.js +223 -0
- package/dist/integrations/shell/observer.js.map +1 -0
- package/dist/integrations/takoapi/a2a.d.ts +38 -0
- package/dist/integrations/takoapi/a2a.d.ts.map +1 -0
- package/dist/integrations/takoapi/a2a.js +70 -0
- package/dist/integrations/takoapi/a2a.js.map +1 -0
- package/dist/integrations/takoapi/ledger.d.ts +32 -0
- package/dist/integrations/takoapi/ledger.d.ts.map +1 -0
- package/dist/integrations/takoapi/ledger.js +96 -0
- package/dist/integrations/takoapi/ledger.js.map +1 -0
- package/dist/integrations/takoapi/observer.d.ts +39 -0
- package/dist/integrations/takoapi/observer.d.ts.map +1 -0
- package/dist/integrations/takoapi/observer.js +102 -0
- package/dist/integrations/takoapi/observer.js.map +1 -0
- package/dist/integrations/types.d.ts +17 -0
- package/dist/integrations/types.d.ts.map +1 -1
- package/dist/mcp/client.d.ts +5 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/client.js.map +1 -1
- package/dist/memory/embedding.d.ts +45 -0
- package/dist/memory/embedding.d.ts.map +1 -0
- package/dist/memory/embedding.js +139 -0
- package/dist/memory/embedding.js.map +1 -0
- package/dist/memory/search_tool.d.ts.map +1 -1
- package/dist/memory/search_tool.js +6 -2
- package/dist/memory/search_tool.js.map +1 -1
- package/dist/memory/vector.d.ts +14 -3
- package/dist/memory/vector.d.ts.map +1 -1
- package/dist/memory/vector.js +50 -0
- package/dist/memory/vector.js.map +1 -1
- package/dist/model/local.d.ts +43 -0
- package/dist/model/local.d.ts.map +1 -0
- package/dist/model/local.js +118 -0
- package/dist/model/local.js.map +1 -0
- package/dist/model/plan-usage.d.ts +41 -0
- package/dist/model/plan-usage.d.ts.map +1 -0
- package/dist/model/plan-usage.js +168 -0
- package/dist/model/plan-usage.js.map +1 -0
- package/dist/model/plans.d.ts +73 -0
- package/dist/model/plans.d.ts.map +1 -0
- package/dist/model/plans.js +246 -0
- package/dist/model/plans.js.map +1 -0
- package/dist/orchestrator/recent-recap.d.ts +2 -0
- package/dist/orchestrator/recent-recap.d.ts.map +1 -0
- package/dist/orchestrator/recent-recap.js +20 -0
- package/dist/orchestrator/recent-recap.js.map +1 -0
- package/dist/providers/anthropic.d.ts +1 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +10 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/fallback.d.ts +23 -0
- package/dist/providers/fallback.d.ts.map +1 -0
- package/dist/providers/fallback.js +29 -0
- package/dist/providers/fallback.js.map +1 -0
- package/dist/providers/gemini.d.ts +2 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +24 -10
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +4 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/registry.d.ts +18 -2
- package/dist/providers/registry.d.ts.map +1 -1
- package/dist/providers/registry.js +56 -9
- package/dist/providers/registry.js.map +1 -1
- package/dist/reflect.d.ts +55 -0
- package/dist/reflect.d.ts.map +1 -1
- package/dist/reflect.js +148 -54
- package/dist/reflect.js.map +1 -1
- package/dist/sense/log.d.ts +9 -0
- package/dist/sense/log.d.ts.map +1 -0
- package/dist/sense/log.js +63 -0
- package/dist/sense/log.js.map +1 -0
- package/dist/sense/screen.d.ts +43 -0
- package/dist/sense/screen.d.ts.map +1 -0
- package/dist/sense/screen.js +107 -0
- package/dist/sense/screen.js.map +1 -0
- package/dist/sense/service.d.ts +16 -0
- package/dist/sense/service.d.ts.map +1 -0
- package/dist/sense/service.js +33 -0
- package/dist/sense/service.js.map +1 -0
- package/dist/sense/types.d.ts +35 -0
- package/dist/sense/types.d.ts.map +1 -0
- package/dist/sense/types.js +11 -0
- package/dist/sense/types.js.map +1 -0
- package/dist/sense/voice.d.ts +28 -0
- package/dist/sense/voice.d.ts.map +1 -0
- package/dist/sense/voice.js +64 -0
- package/dist/sense/voice.js.map +1 -0
- package/dist/soul/git.d.ts.map +1 -1
- package/dist/soul/git.js +35 -16
- package/dist/soul/git.js.map +1 -1
- package/dist/soul/store.d.ts +22 -0
- package/dist/soul/store.d.ts.map +1 -1
- package/dist/soul/store.js +69 -14
- package/dist/soul/store.js.map +1 -1
- package/dist/soul/summary.d.ts +10 -0
- package/dist/soul/summary.d.ts.map +1 -0
- package/dist/soul/summary.js +57 -0
- package/dist/soul/summary.js.map +1 -0
- package/dist/soul/tools.d.ts.map +1 -1
- package/dist/soul/tools.js +13 -24
- package/dist/soul/tools.js.map +1 -1
- package/dist/soul/types.d.ts +9 -0
- package/dist/soul/types.d.ts.map +1 -1
- package/dist/soul/types.js.map +1 -1
- package/dist/subagent.d.ts +7 -0
- package/dist/subagent.d.ts.map +1 -1
- package/dist/subagent.js +3 -1
- package/dist/subagent.js.map +1 -1
- package/dist/tools/dispatch_agent.d.ts +13 -1
- package/dist/tools/dispatch_agent.d.ts.map +1 -1
- package/dist/tools/dispatch_agent.js +56 -12
- package/dist/tools/dispatch_agent.js.map +1 -1
- package/dist/tools/dispatch_status.d.ts +22 -0
- package/dist/tools/dispatch_status.d.ts.map +1 -0
- package/dist/tools/dispatch_status.js +57 -0
- package/dist/tools/dispatch_status.js.map +1 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +67 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/run_on_plan.d.ts +46 -0
- package/dist/tools/run_on_plan.d.ts.map +1 -0
- package/dist/tools/run_on_plan.js +89 -0
- package/dist/tools/run_on_plan.js.map +1 -0
- package/dist/tools/takoapi.d.ts +60 -0
- package/dist/tools/takoapi.d.ts.map +1 -0
- package/dist/tools/takoapi.js +181 -0
- package/dist/tools/takoapi.js.map +1 -0
- package/dist/tools/validate.d.ts +22 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +50 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/web/agent-roster.d.ts +56 -0
- package/dist/web/agent-roster.d.ts.map +1 -0
- package/dist/web/agent-roster.js +109 -0
- package/dist/web/agent-roster.js.map +1 -0
- package/dist/web/devices.d.ts +27 -0
- package/dist/web/devices.d.ts.map +1 -0
- package/dist/web/devices.js +113 -0
- package/dist/web/devices.js.map +1 -0
- package/dist/web/island.d.ts +5 -1
- package/dist/web/island.d.ts.map +1 -1
- package/dist/web/island.js +410 -63
- package/dist/web/island.js.map +1 -1
- package/dist/web/lisa-client.d.ts +20 -0
- package/dist/web/lisa-client.d.ts.map +1 -0
- package/dist/web/lisa-client.js +1323 -0
- package/dist/web/lisa-client.js.map +1 -0
- package/dist/web/lisa-css.d.ts +13 -0
- package/dist/web/lisa-css.d.ts.map +1 -0
- package/dist/web/lisa-css.js +1163 -0
- package/dist/web/lisa-css.js.map +1 -0
- package/dist/web/lisa-html.d.ts +1 -1
- package/dist/web/lisa-html.d.ts.map +1 -1
- package/dist/web/lisa-html.js +15 -2136
- package/dist/web/lisa-html.js.map +1 -1
- package/dist/web/push.d.ts +76 -0
- package/dist/web/push.d.ts.map +1 -0
- package/dist/web/push.js +163 -0
- package/dist/web/push.js.map +1 -0
- package/dist/web/server.d.ts +19 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +878 -64
- package/dist/web/server.js.map +1 -1
- package/package.json +5 -2
package/dist/web/server.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import crypto from "node:crypto";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { runAgent } from "../agent.js";
|
|
7
|
+
import { fireHooks } from "../hooks/runner.js";
|
|
6
8
|
import { saveConfigEnv } from "../env.js";
|
|
9
|
+
import { detectPlans, selectedPlan, parsePlanRef, planMark, PLAN_IDS } from "../model/plans.js";
|
|
10
|
+
import { planUsage, formatUsage } from "../model/plan-usage.js";
|
|
7
11
|
import { runIdleOnce } from "../idle/runner.js";
|
|
8
12
|
import { getIdleWatcher } from "../idle/watcher.js";
|
|
9
13
|
import { moodBus } from "../mood-bus.js";
|
|
@@ -18,17 +22,74 @@ import { ISLAND_HTML } from "./island.js";
|
|
|
18
22
|
import { MAIN_HTML } from "./lisa-html.js";
|
|
19
23
|
import { OrchestratorHub, loadOrchestratorConfig } from "../integrations/hub.js";
|
|
20
24
|
import { setCurrentHub } from "../integrations/current-hub.js";
|
|
21
|
-
import { advise, formatDigest } from "../advisor/engine.js";
|
|
25
|
+
import { advise, dismissSuggestion, formatDigest } from "../advisor/engine.js";
|
|
22
26
|
import { recordEvent } from "../orchestrator/journal.js";
|
|
23
27
|
import { buildRecap, formatRecap } from "../orchestrator/recap.js";
|
|
24
28
|
import { captureScreenshot, captureSupported } from "../vision/capture.js";
|
|
25
29
|
import { transcribeAudio } from "../voice/transcribe.js";
|
|
26
30
|
import { polishDictation } from "../voice/dictation.js";
|
|
31
|
+
import { listGrants, grant, revoke, revokeAll, isGranted, SENSE_SIGNALS, SIGNAL_DESCRIPTIONS } from "../consent/store.js";
|
|
32
|
+
import { signalAgentTool } from "../tools/signal_agent.js";
|
|
33
|
+
import { managedRegistry } from "../agents/managed.js";
|
|
34
|
+
import { ptyRegistry, ptyEnabled } from "../agents/pty.js";
|
|
35
|
+
import { liveClaudeSessionIds } from "../integrations/claude-code/liveness.js";
|
|
36
|
+
import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js";
|
|
37
|
+
import { loadControlPolicy, saveControlPolicy } from "../control/policy.js";
|
|
38
|
+
import { mintDevice, verifyDeviceToken, touchDevice, listDevices, revokeDevice } from "./devices.js";
|
|
39
|
+
import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs } from "./push.js";
|
|
40
|
+
import { SenseService } from "../sense/service.js";
|
|
41
|
+
import { ScreenSource } from "../sense/screen.js";
|
|
42
|
+
import { VoiceSource } from "../sense/voice.js";
|
|
43
|
+
import { appendSenseEvent, readSenseEvents } from "../sense/log.js";
|
|
27
44
|
import { loadScreenAdvisorConfig, saveScreenAdvisorConfig, normalizeConfig, analyzeScreenshot, } from "../screen_advisor/engine.js";
|
|
28
45
|
import os from "node:os";
|
|
29
46
|
import { LISA_HOME } from "../paths.js";
|
|
30
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
48
|
const ASSETS_DIR = path.join(__dirname, "assets");
|
|
49
|
+
/** True for loopback peer/bind addresses (v4, v6, and v4-mapped-v6 forms). */
|
|
50
|
+
export function isLoopbackAddress(addr) {
|
|
51
|
+
const a = addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
|
|
52
|
+
return a === "::1" || a === "localhost" || a.startsWith("127.");
|
|
53
|
+
}
|
|
54
|
+
function timingSafeEqualStr(a, b) {
|
|
55
|
+
// Hash both sides so the comparison is constant-time regardless of length.
|
|
56
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
57
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
58
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The web auth decision (the unit the red-team script + tests target): a request
|
|
62
|
+
* is authorized iff it's loopback (the local user) OR it presents the correct
|
|
63
|
+
* LISA_WEB_TOKEN. No token configured ⇒ no non-loopback request can pass. Pure.
|
|
64
|
+
*/
|
|
65
|
+
export function isRequestAuthorized(remoteAddr, webToken, presented) {
|
|
66
|
+
if (isLoopbackAddress(remoteAddr))
|
|
67
|
+
return true;
|
|
68
|
+
return !!webToken && !!presented && timingSafeEqualStr(presented, webToken);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract a presented web token from Authorization header, lisa_token cookie,
|
|
72
|
+
* or ?token= query param (the query form exists so a phone can bootstrap the
|
|
73
|
+
* cookie by opening http://host:5757/?token=...).
|
|
74
|
+
*/
|
|
75
|
+
function presentedToken(req, url) {
|
|
76
|
+
const auth = req.headers.authorization;
|
|
77
|
+
if (auth?.startsWith("Bearer "))
|
|
78
|
+
return auth.slice("Bearer ".length).trim();
|
|
79
|
+
const cookies = req.headers.cookie ?? "";
|
|
80
|
+
const m = /(?:^|;\s*)lisa_token=([^;]+)/.exec(cookies);
|
|
81
|
+
if (m)
|
|
82
|
+
return decodeURIComponent(m[1]);
|
|
83
|
+
try {
|
|
84
|
+
const q = new URL(url, "http://localhost").searchParams.get("token");
|
|
85
|
+
if (q)
|
|
86
|
+
return q;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* fall through */
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
32
93
|
async function resumeOrCreateWebSession(model) {
|
|
33
94
|
const lastId = await readActiveWebSession();
|
|
34
95
|
if (lastId) {
|
|
@@ -62,6 +123,14 @@ async function resumeOrCreateWebSession(model) {
|
|
|
62
123
|
return s;
|
|
63
124
|
}
|
|
64
125
|
export async function startWebServer(opts) {
|
|
126
|
+
const host = opts.host ?? "127.0.0.1";
|
|
127
|
+
const webToken = process.env.LISA_WEB_TOKEN?.trim() || null;
|
|
128
|
+
if (!isLoopbackAddress(host) && !webToken) {
|
|
129
|
+
throw new Error(`refusing to bind ${host} without LISA_WEB_TOKEN — every endpoint drives a ` +
|
|
130
|
+
`full-tool agent on this machine. Set LISA_WEB_TOKEN (e.g. ` +
|
|
131
|
+
`\`LISA_WEB_TOKEN=$(openssl rand -hex 24)\` in ~/.lisa/config.env), then open ` +
|
|
132
|
+
`http://<host>:${opts.port}/?token=<value> from the remote device.`);
|
|
133
|
+
}
|
|
65
134
|
const snapshot = await buildSystemPromptSnapshot();
|
|
66
135
|
const initialFingerprint = await getPromptFingerprint();
|
|
67
136
|
// Per-process hot-reload cache for the web server: same shape as cli's
|
|
@@ -100,6 +169,13 @@ export async function startWebServer(opts) {
|
|
|
100
169
|
const { messages: savedMessages } = await session.readMessagePage(0, 9999);
|
|
101
170
|
const history = [...savedMessages];
|
|
102
171
|
const abort = new AbortController();
|
|
172
|
+
const hooks = opts.hooks ?? [];
|
|
173
|
+
// Serialize chat turns. The whole process shares ONE history array and ONE
|
|
174
|
+
// session file — two tabs (or a tab + the island) POSTing /chat concurrently
|
|
175
|
+
// would run two agents against the same history reference and clobber each
|
|
176
|
+
// other's `history.length = 0; history.push(...)` at the end. Same model as
|
|
177
|
+
// the channels router's per-thread busy+queue, with a promise chain.
|
|
178
|
+
let chatChain = Promise.resolve();
|
|
103
179
|
// ── Persistent /events SSE subscribers (mood + idle broadcasts) ─────
|
|
104
180
|
const eventClients = new Set();
|
|
105
181
|
const broadcast = (event) => {
|
|
@@ -127,11 +203,15 @@ export async function startWebServer(opts) {
|
|
|
127
203
|
const hub = new OrchestratorHub(orchestratorCfg, {
|
|
128
204
|
log: (msg) => console.error(msg),
|
|
129
205
|
});
|
|
206
|
+
// Operational push: notify subscribed phones on agent done/error/permission +
|
|
207
|
+
// Reve idle messages. Opt-in, ntfy by default (apns is a stub). See push.ts.
|
|
208
|
+
const pushBridge = new PushBridge({ log: (m) => console.error(m) });
|
|
130
209
|
hub.on("update", (session) => {
|
|
131
210
|
// L6 — record the transition in the orchestrator journal so the
|
|
132
211
|
// cross-agent recap can answer "what happened while I was away?" even for
|
|
133
212
|
// sessions that have since ended. (Dedups consecutive same-state events.)
|
|
134
213
|
recordEvent(session);
|
|
214
|
+
pushBridge.onAgentUpdate(session);
|
|
135
215
|
// New generalized event for the multi-agent UI.
|
|
136
216
|
broadcast({ type: "agent_session_update", ...session });
|
|
137
217
|
// Back-compat: the island still listens for claude_session_update as a
|
|
@@ -159,6 +239,7 @@ export async function startWebServer(opts) {
|
|
|
159
239
|
// card — pull-friendly, not an interrupt. lastIdleMessage is assigned
|
|
160
240
|
// inside the async callback (runs after all locals init; no TDZ issue).
|
|
161
241
|
const ADVISE_INTERVAL_MS = 5 * 60_000;
|
|
242
|
+
let lastAdvisorSuggestions = null;
|
|
162
243
|
const adviseTimer = setInterval(() => {
|
|
163
244
|
void (async () => {
|
|
164
245
|
try {
|
|
@@ -172,6 +253,19 @@ export async function startWebServer(opts) {
|
|
|
172
253
|
const at = new Date().toISOString();
|
|
173
254
|
lastIdleMessage = { text, at };
|
|
174
255
|
broadcast({ type: "idle_message", text, at, source: "advisor" });
|
|
256
|
+
pushBridge.onIdleMessage(text);
|
|
257
|
+
// Structured twin of the digest: same suggestions with id / urgency /
|
|
258
|
+
// action attached so the island can render per-suggestion buttons
|
|
259
|
+
// (act → prefill chat, ✕ → dismiss feeds the learning loop).
|
|
260
|
+
const suggestions = surface.map((s) => ({
|
|
261
|
+
id: s.id,
|
|
262
|
+
category: s.category,
|
|
263
|
+
urgency: s.urgency,
|
|
264
|
+
text: s.text,
|
|
265
|
+
action: s.action ?? null,
|
|
266
|
+
}));
|
|
267
|
+
lastAdvisorSuggestions = { suggestions, at };
|
|
268
|
+
broadcast({ type: "advisor_suggestions", suggestions, at });
|
|
175
269
|
console.error(`[advisor] surfaced ${surface.length} suggestion(s)`);
|
|
176
270
|
}
|
|
177
271
|
catch (err) {
|
|
@@ -193,7 +287,11 @@ export async function startWebServer(opts) {
|
|
|
193
287
|
let screenTimer = null;
|
|
194
288
|
let screenTickRunning = false;
|
|
195
289
|
const screenTick = async () => {
|
|
196
|
-
|
|
290
|
+
// S2: a screenshot → model IS screen capture, so it requires the `screen`
|
|
291
|
+
// consent grant (re-checked each tick so revoke-all stops it within one
|
|
292
|
+
// interval) IN ADDITION to the advisor's own enabled flag. This unifies all
|
|
293
|
+
// screen capture under the consent framework (FOUNDATIONS §1).
|
|
294
|
+
if (screenTickRunning || !screenCfg.enabled || !captureSupported() || !isGranted("screen"))
|
|
197
295
|
return;
|
|
198
296
|
screenTickRunning = true;
|
|
199
297
|
try {
|
|
@@ -237,7 +335,25 @@ export async function startWebServer(opts) {
|
|
|
237
335
|
restartScreenTimer();
|
|
238
336
|
if (screenCfg.enabled) {
|
|
239
337
|
console.error(`[screen-advisor] enabled — every ${screenCfg.intervalMinutes}m`);
|
|
338
|
+
if (!isGranted("screen")) {
|
|
339
|
+
console.error("[screen-advisor] waiting on `screen` consent — grant it (`lisa consent grant screen`) to start capturing.");
|
|
340
|
+
}
|
|
240
341
|
}
|
|
342
|
+
// ── Sense ambient sources (S2) ──────────────────────────────────────
|
|
343
|
+
// The resident loop owns consent-gated ambient sources. ScreenSource ticks
|
|
344
|
+
// continuously but captures NOTHING until `screen` is granted (it re-checks
|
|
345
|
+
// each tick), so this is a no-op by default. On-change foreground-app events
|
|
346
|
+
// are distilled to the bounded sense log; nothing raw is persisted.
|
|
347
|
+
const senseService = new SenseService();
|
|
348
|
+
// VoiceSource is event-driven (fed by the transcribe endpoint when `voice` is
|
|
349
|
+
// granted); ScreenSource polls. Both no-op until their signal is granted.
|
|
350
|
+
const voiceSource = new VoiceSource();
|
|
351
|
+
senseService.register(new ScreenSource());
|
|
352
|
+
senseService.register(voiceSource);
|
|
353
|
+
void senseService.start((e) => {
|
|
354
|
+
appendSenseEvent(e);
|
|
355
|
+
broadcast({ type: "sense_event", ...e });
|
|
356
|
+
});
|
|
241
357
|
// ── Island unread tracking (Phase 1 of MAC_ISLAND_PLAN) ─────────────
|
|
242
358
|
// The island widget caches "last idle_message" so a fresh tab opening
|
|
243
359
|
// mid-conversation knows there's something to read. Cleared via
|
|
@@ -280,6 +396,7 @@ export async function startWebServer(opts) {
|
|
|
280
396
|
});
|
|
281
397
|
lastIdleMessage = { text: result.text, at: startedAt };
|
|
282
398
|
broadcast({ type: "idle_message", text: result.text, at: startedAt });
|
|
399
|
+
pushBridge.onIdleMessage(result.text);
|
|
283
400
|
}
|
|
284
401
|
}
|
|
285
402
|
catch (err) {
|
|
@@ -296,14 +413,65 @@ export async function startWebServer(opts) {
|
|
|
296
413
|
}
|
|
297
414
|
const server = http.createServer(async (req, res) => {
|
|
298
415
|
const url = req.url ?? "/";
|
|
299
|
-
|
|
416
|
+
// ── Auth gate ────────────────────────────────────────────────────────
|
|
417
|
+
// Loopback callers are the local user — no token needed (the default
|
|
418
|
+
// 127.0.0.1 bind means this is the only case unless the user opted in
|
|
419
|
+
// via --host). Anything else must present LISA_WEB_TOKEN; /chat drives a
|
|
420
|
+
// full-tool agent and /api/vision/capture grabs the screen, so an
|
|
421
|
+
// unauthenticated non-loopback request is a remote-code-execution hole.
|
|
422
|
+
const remoteAddr = req.socket.remoteAddress ?? "";
|
|
423
|
+
if (!isLoopbackAddress(remoteAddr)) {
|
|
424
|
+
const presented = webToken ? presentedToken(req, url) : null;
|
|
425
|
+
// Authorized by the global LISA_WEB_TOKEN OR a non-revoked per-device token.
|
|
426
|
+
let authed = isRequestAuthorized(remoteAddr, webToken, presented);
|
|
427
|
+
if (!authed && presented) {
|
|
428
|
+
const device = verifyDeviceToken(presented);
|
|
429
|
+
if (device) {
|
|
430
|
+
authed = true;
|
|
431
|
+
touchDevice(device.id);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (!authed) {
|
|
435
|
+
res.writeHead(401, { "content-type": "text/plain" });
|
|
436
|
+
res.end("unauthorized");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// Token arrived via query param (first open from a phone): pin it as a
|
|
440
|
+
// cookie so subsequent same-origin fetch/EventSource calls authenticate.
|
|
441
|
+
// presented is non-null here — isRequestAuthorized returned true for a
|
|
442
|
+
// non-loopback caller, which requires a matching token.
|
|
443
|
+
if (presented && !req.headers.cookie?.includes("lisa_token=")) {
|
|
444
|
+
res.setHeader("set-cookie", `lisa_token=${encodeURIComponent(presented)}; HttpOnly; SameSite=Strict; Path=/`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Per-request gate for high-risk control actions from REMOTE callers. The Mac
|
|
448
|
+
// owner (loopback) is never gated; a remote (token) device may take only what
|
|
449
|
+
// the Mac-side policy permits. denyRemote() writes the 403 and returns true
|
|
450
|
+
// when blocked, so call sites read `if (denyRemote(...)) return;`. See
|
|
451
|
+
// src/control/policy.ts (default: control own agents yes, adopt external no).
|
|
452
|
+
const remoteControlAllowed = (need) => {
|
|
453
|
+
if (isLoopbackAddress(remoteAddr))
|
|
454
|
+
return true;
|
|
455
|
+
const pol = loadControlPolicy();
|
|
456
|
+
return need === "adoptExternal" ? pol.remoteAdoptExternal : pol.remoteControl;
|
|
457
|
+
};
|
|
458
|
+
const denyRemote = (need) => {
|
|
459
|
+
if (remoteControlAllowed(need))
|
|
460
|
+
return false;
|
|
461
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
462
|
+
res.end(need === "adoptExternal"
|
|
463
|
+
? "adopting external sessions from a remote device is disabled — enable remoteAdoptExternal on the Mac (POST /api/control/policy from localhost)"
|
|
464
|
+
: "remote control is disabled — enable remoteControl on the Mac (POST /api/control/policy from localhost)");
|
|
465
|
+
return true;
|
|
466
|
+
};
|
|
467
|
+
if (req.method === "GET" && (url === "/" || url.startsWith("/?"))) {
|
|
300
468
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
301
469
|
res.end(MAIN_HTML);
|
|
302
470
|
return;
|
|
303
471
|
}
|
|
304
472
|
// Island widget — designed to be opened in a tiny browser window
|
|
305
473
|
// (Arc, Vivaldi PWA, Safari split). See docs/MAC_ISLAND_PLAN.md.
|
|
306
|
-
if (req.method === "GET" && url === "/island") {
|
|
474
|
+
if (req.method === "GET" && (url === "/island" || url.startsWith("/island?"))) {
|
|
307
475
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
308
476
|
res.end(ISLAND_HTML);
|
|
309
477
|
return;
|
|
@@ -454,6 +622,10 @@ export async function startWebServer(opts) {
|
|
|
454
622
|
await fs.writeFile(tmp, Buffer.from(payload.data, "base64"));
|
|
455
623
|
console.error(`[voice] transcribing ${Buffer.from(payload.data, "base64").length} bytes (${ext})`);
|
|
456
624
|
const transcript = await transcribeAudio({ audioPath: tmp });
|
|
625
|
+
// S2-voice: when `voice` is granted, distill this push-to-talk transcript
|
|
626
|
+
// into the ambient sense log (PII-redacted, no audio). No-op otherwise,
|
|
627
|
+
// so dictation works unchanged when voice consent is off.
|
|
628
|
+
voiceSource.ingest(transcript);
|
|
457
629
|
// Dictation mode (Typeless-equivalent): polish the raw transcript into
|
|
458
630
|
// the clean text the speaker intended (filler/repetition removed,
|
|
459
631
|
// self-corrections applied, punctuation + formatting), to drop into the
|
|
@@ -490,13 +662,549 @@ export async function startWebServer(opts) {
|
|
|
490
662
|
res.end(JSON.stringify({ ok: true }));
|
|
491
663
|
return;
|
|
492
664
|
}
|
|
665
|
+
// Latest advisor suggestions, for a freshly opened island.
|
|
666
|
+
if (req.method === "GET" && url === "/api/advisor/latest") {
|
|
667
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
668
|
+
res.end(JSON.stringify(lastAdvisorSuggestions ?? { suggestions: [], at: null }));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// ✕ on an advisor suggestion: persist the dismissal so the category
|
|
672
|
+
// down-weights over time ("learns to shut up"), and drop it from the
|
|
673
|
+
// cached card so a refreshed island doesn't resurrect it.
|
|
674
|
+
if (req.method === "POST" && url === "/api/advisor/dismiss") {
|
|
675
|
+
let dBody = "";
|
|
676
|
+
for await (const chunk of req)
|
|
677
|
+
dBody += chunk.toString("utf8");
|
|
678
|
+
let payload;
|
|
679
|
+
try {
|
|
680
|
+
payload = JSON.parse(dBody || "{}");
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
684
|
+
res.end("bad json");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (typeof payload.id !== "string" || typeof payload.category !== "string") {
|
|
688
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
689
|
+
res.end("id and category required");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
await dismissSuggestion(payload.id, payload.category);
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
697
|
+
res.end(err.message);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (lastAdvisorSuggestions) {
|
|
701
|
+
lastAdvisorSuggestions = {
|
|
702
|
+
...lastAdvisorSuggestions,
|
|
703
|
+
suggestions: lastAdvisorSuggestions.suggestions.filter((s) => s.id !== payload.id),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
707
|
+
res.end(JSON.stringify({ ok: true }));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// ── Consent (FOUNDATIONS §1) ────────────────────────────────────────
|
|
711
|
+
// The island's "always visible + one-tap stop" surface. GET lists every
|
|
712
|
+
// sense signal + status; the POSTs grant/revoke/stop-all. Sources gate on
|
|
713
|
+
// isGranted() server-side regardless — this is control, not the gate.
|
|
714
|
+
if (req.method === "GET" && url === "/api/consent") {
|
|
715
|
+
const grants = listGrants().map((g) => ({
|
|
716
|
+
...g,
|
|
717
|
+
description: SIGNAL_DESCRIPTIONS[g.signal] ?? "",
|
|
718
|
+
}));
|
|
719
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
720
|
+
res.end(JSON.stringify({ grants }));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
// D4b — list/cancel LISA's OWN dispatched agents over HTTP. Behind the auth
|
|
724
|
+
// gate like every endpoint (loopback or token). Reuses signal_agent, so it
|
|
725
|
+
// can ONLY touch dispatch-ledger pids — never an arbitrary process.
|
|
726
|
+
if (req.method === "POST" && url === "/api/agent/signal") {
|
|
727
|
+
let sigBody = "";
|
|
728
|
+
for await (const chunk of req)
|
|
729
|
+
sigBody += chunk.toString("utf8");
|
|
730
|
+
let payload;
|
|
731
|
+
try {
|
|
732
|
+
payload = JSON.parse(sigBody || "{}");
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
736
|
+
res.end("bad json");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const action = payload.action === "list" ? "list" : payload.action === "cancel" ? "cancel" : null;
|
|
740
|
+
if (!action) {
|
|
741
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
742
|
+
res.end("action must be 'list' or 'cancel'");
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (action === "cancel" && denyRemote("control"))
|
|
746
|
+
return;
|
|
747
|
+
const result = await signalAgentTool.execute({
|
|
748
|
+
action,
|
|
749
|
+
target: typeof payload.target === "string" ? payload.target : undefined,
|
|
750
|
+
force: payload.force === true,
|
|
751
|
+
}, { cwd: process.cwd(), signal: new AbortController().signal, log: () => { } });
|
|
752
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
753
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// ── Managed agents (control plane) ──────────────────────────────────
|
|
757
|
+
// LISA's own controllable agents: start a task, send follow-ups, cancel,
|
|
758
|
+
// and approve/deny each mutating tool. Behind the auth gate like every
|
|
759
|
+
// endpoint. They appear in the roster via the "managed" hub observer.
|
|
760
|
+
// Device pairing: mint a per-device token (loopback-only — the Mac owner
|
|
761
|
+
// pairs a phone by showing the returned token as a QR). The raw token is
|
|
762
|
+
// returned ONCE; only its hash is stored. The phone then authenticates with
|
|
763
|
+
// it like the global token (Bearer / ?token= / cookie).
|
|
764
|
+
if (req.method === "POST" && url === "/api/pair/start") {
|
|
765
|
+
if (!isLoopbackAddress(remoteAddr)) {
|
|
766
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
767
|
+
res.end("pairing can only be started from the Mac (localhost)");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
let prBody = "";
|
|
771
|
+
for await (const chunk of req)
|
|
772
|
+
prBody += chunk.toString("utf8");
|
|
773
|
+
let payload = {};
|
|
774
|
+
try {
|
|
775
|
+
payload = prBody ? JSON.parse(prBody) : {};
|
|
776
|
+
}
|
|
777
|
+
catch { /* tolerate */ }
|
|
778
|
+
const name = typeof payload.name === "string" ? payload.name : "device";
|
|
779
|
+
const platform = typeof payload.platform === "string" ? payload.platform : "ios";
|
|
780
|
+
const { id, token, device } = mintDevice(name, platform);
|
|
781
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
782
|
+
res.end(JSON.stringify({ ok: true, id, token, port: opts.port, device }));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (req.method === "GET" && url === "/api/devices") {
|
|
786
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
787
|
+
res.end(JSON.stringify({ devices: listDevices() }));
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (req.method === "POST" && url === "/api/devices/revoke") {
|
|
791
|
+
if (!isLoopbackAddress(remoteAddr)) {
|
|
792
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
793
|
+
res.end("device revocation is a Mac-owner action (localhost only)");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
let rvBody = "";
|
|
797
|
+
for await (const chunk of req)
|
|
798
|
+
rvBody += chunk.toString("utf8");
|
|
799
|
+
let payload = {};
|
|
800
|
+
try {
|
|
801
|
+
payload = rvBody ? JSON.parse(rvBody) : {};
|
|
802
|
+
}
|
|
803
|
+
catch { /* tolerate */ }
|
|
804
|
+
const removed = revokeDevice(typeof payload.id === "string" ? payload.id : "");
|
|
805
|
+
res.writeHead(removed ? 200 : 404, { "content-type": "application/json" });
|
|
806
|
+
res.end(JSON.stringify({ ok: removed }));
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
// Push registration: a phone registers its own delivery target (ntfy topic or
|
|
810
|
+
// APNs token) + prefs — authed (the device does this remotely). Low-sensitivity
|
|
811
|
+
// metadata only; see push.ts.
|
|
812
|
+
if (req.method === "POST" && url === "/api/push/register") {
|
|
813
|
+
let puBody = "";
|
|
814
|
+
for await (const chunk of req)
|
|
815
|
+
puBody += chunk.toString("utf8");
|
|
816
|
+
let payload = {};
|
|
817
|
+
try {
|
|
818
|
+
payload = puBody ? JSON.parse(puBody) : {};
|
|
819
|
+
}
|
|
820
|
+
catch { /* tolerate */ }
|
|
821
|
+
if (typeof payload.target !== "string" || !payload.target.trim()) {
|
|
822
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
823
|
+
res.end("target required (ntfy topic or apns token)");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const sub = registerPush({
|
|
827
|
+
kind: typeof payload.kind === "string" ? payload.kind : "ntfy",
|
|
828
|
+
target: payload.target,
|
|
829
|
+
server: typeof payload.server === "string" ? payload.server : undefined,
|
|
830
|
+
prefs: payload.prefs,
|
|
831
|
+
});
|
|
832
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
833
|
+
res.end(JSON.stringify({ ok: true, subscription: sub }));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (req.method === "POST" && url === "/api/push/unregister") {
|
|
837
|
+
let puBody = "";
|
|
838
|
+
for await (const chunk of req)
|
|
839
|
+
puBody += chunk.toString("utf8");
|
|
840
|
+
let payload = {};
|
|
841
|
+
try {
|
|
842
|
+
payload = puBody ? JSON.parse(puBody) : {};
|
|
843
|
+
}
|
|
844
|
+
catch { /* tolerate */ }
|
|
845
|
+
const key = typeof payload.id === "string" ? payload.id : typeof payload.target === "string" ? payload.target : "";
|
|
846
|
+
const removed = unregisterPush(key);
|
|
847
|
+
res.writeHead(removed ? 200 : 404, { "content-type": "application/json" });
|
|
848
|
+
res.end(JSON.stringify({ ok: removed }));
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (req.method === "GET" && url === "/api/push/list") {
|
|
852
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
853
|
+
res.end(JSON.stringify({ subscriptions: listPush() }));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (req.method === "POST" && url === "/api/push/prefs") {
|
|
857
|
+
let puBody = "";
|
|
858
|
+
for await (const chunk of req)
|
|
859
|
+
puBody += chunk.toString("utf8");
|
|
860
|
+
let payload = {};
|
|
861
|
+
try {
|
|
862
|
+
payload = puBody ? JSON.parse(puBody) : {};
|
|
863
|
+
}
|
|
864
|
+
catch { /* tolerate */ }
|
|
865
|
+
const sub = typeof payload.id === "string" ? setPushPrefs(payload.id, payload.prefs ?? {}) : null;
|
|
866
|
+
res.writeHead(sub ? 200 : 404, { "content-type": "application/json" });
|
|
867
|
+
res.end(JSON.stringify(sub ? { ok: true, subscription: sub } : { ok: false }));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// Remote-control policy: GET (any authed caller) reports it; POST sets it,
|
|
871
|
+
// but only from localhost (the Mac owner) — like the API-key save.
|
|
872
|
+
if (req.method === "GET" && url === "/api/control/policy") {
|
|
873
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
874
|
+
res.end(JSON.stringify(loadControlPolicy()));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (req.method === "POST" && url === "/api/control/policy") {
|
|
878
|
+
if (!isLoopbackAddress(remoteAddr)) {
|
|
879
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
880
|
+
res.end("control policy can only be changed from the Mac (localhost)");
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
let cpBody = "";
|
|
884
|
+
for await (const chunk of req)
|
|
885
|
+
cpBody += chunk.toString("utf8");
|
|
886
|
+
let payload;
|
|
887
|
+
try {
|
|
888
|
+
payload = JSON.parse(cpBody || "{}");
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
892
|
+
res.end("bad json");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
const saved = saveControlPolicy({ ...loadControlPolicy(), ...payload });
|
|
897
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
898
|
+
res.end(JSON.stringify({ ok: true, ...saved }));
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
902
|
+
res.end(err.message);
|
|
903
|
+
}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// Structured view of the agents LISA dispatched fire-and-forget (the ledger).
|
|
907
|
+
// Complements /api/agent/signal's action:"list" (which returns prose): this is
|
|
908
|
+
// JSON for clients (the iOS roster). Structural only — never the captured log.
|
|
909
|
+
if (req.method === "GET" && url === "/api/dispatch/list") {
|
|
910
|
+
const dispatches = listRecentDispatches().map((e) => toDispatchView(e, isAlive(e.pid)));
|
|
911
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
912
|
+
res.end(JSON.stringify({ dispatches }));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Status + captured-output tail of ONE dispatched agent. The tail is raw
|
|
916
|
+
// stdout (potentially sensitive), so it's behind the remote-control gate
|
|
917
|
+
// (loopback always; remote per policy). ?id=<dispatch id>.
|
|
918
|
+
if (req.method === "GET" && url.startsWith("/api/dispatch/status")) {
|
|
919
|
+
if (denyRemote("control"))
|
|
920
|
+
return;
|
|
921
|
+
const id = new URL(url, "http://localhost").searchParams.get("id") ?? "";
|
|
922
|
+
const entry = id ? listRecentDispatches().find((e) => e.id === id) : undefined;
|
|
923
|
+
if (!entry) {
|
|
924
|
+
res.writeHead(id ? 404 : 400, { "content-type": "application/json" });
|
|
925
|
+
res.end(JSON.stringify({ ok: false, error: id ? "no such dispatch" : "id required" }));
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
929
|
+
res.end(JSON.stringify({
|
|
930
|
+
ok: true,
|
|
931
|
+
...toDispatchView(entry, isAlive(entry.pid)),
|
|
932
|
+
tail: readDispatchOutput(entry, 4000),
|
|
933
|
+
}));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (req.method === "POST" && url === "/api/agents/managed/start") {
|
|
937
|
+
if (denyRemote("control"))
|
|
938
|
+
return;
|
|
939
|
+
let mBody = "";
|
|
940
|
+
for await (const chunk of req)
|
|
941
|
+
mBody += chunk.toString("utf8");
|
|
942
|
+
let payload;
|
|
943
|
+
try {
|
|
944
|
+
payload = JSON.parse(mBody || "{}");
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
948
|
+
res.end("bad json");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (typeof payload.task !== "string" || !payload.task.trim()) {
|
|
952
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
953
|
+
res.end("task required");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd.startsWith("/") ? payload.cwd : process.cwd();
|
|
957
|
+
// A managed agent doesn't control other agents — drop dispatch/signal.
|
|
958
|
+
const tools = opts.tools.filter((t) => t.name !== "dispatch_agent" && t.name !== "signal_agent");
|
|
959
|
+
const systemPrompt = `You are a delegated agent working in ${cwd}, launched by the user through Lisa. ` +
|
|
960
|
+
`Complete the user's task using the available tools, then report what you did concisely. ` +
|
|
961
|
+
`Mutating actions (writes, shell, etc.) pause for the user's approval — keep going after each decision.`;
|
|
962
|
+
const view = managedRegistry.start({
|
|
963
|
+
task: payload.task,
|
|
964
|
+
cwd,
|
|
965
|
+
systemPrompt,
|
|
966
|
+
tools,
|
|
967
|
+
model: typeof payload.model === "string" ? payload.model : opts.model,
|
|
968
|
+
});
|
|
969
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
970
|
+
res.end(JSON.stringify({ ok: true, agent: view }));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (req.method === "POST" && url.startsWith("/api/agents/managed/")) {
|
|
974
|
+
if (denyRemote("control"))
|
|
975
|
+
return;
|
|
976
|
+
const rest = url.slice("/api/agents/managed/".length);
|
|
977
|
+
const slash = rest.indexOf("/");
|
|
978
|
+
const id = slash >= 0 ? rest.slice(0, slash) : rest;
|
|
979
|
+
const action = slash >= 0 ? rest.slice(slash + 1) : "";
|
|
980
|
+
let mBody = "";
|
|
981
|
+
for await (const chunk of req)
|
|
982
|
+
mBody += chunk.toString("utf8");
|
|
983
|
+
let payload = {};
|
|
984
|
+
try {
|
|
985
|
+
payload = mBody ? JSON.parse(mBody) : {};
|
|
986
|
+
}
|
|
987
|
+
catch { /* tolerate empty/none */ }
|
|
988
|
+
let ok = false;
|
|
989
|
+
if (action === "send" && typeof payload.text === "string")
|
|
990
|
+
ok = managedRegistry.send(id, payload.text);
|
|
991
|
+
else if (action === "cancel")
|
|
992
|
+
ok = managedRegistry.cancel(id);
|
|
993
|
+
else if (action === "approve")
|
|
994
|
+
ok = managedRegistry.decide(id, payload.allow !== false);
|
|
995
|
+
else {
|
|
996
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
997
|
+
res.end("action must be send|cancel|approve");
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
res.writeHead(ok ? 200 : 404, { "content-type": "application/json" });
|
|
1001
|
+
res.end(JSON.stringify({ ok }));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
// ── PTY agents (Stage C spike, off unless LISA_PTY_AGENTS=1) ─────────
|
|
1005
|
+
// Real `claude`/`codex` CLIs LISA spawns under a pseudo-terminal: it types
|
|
1006
|
+
// your task + follow-ups and can read the terminal tail. Behind the same
|
|
1007
|
+
// auth gate; 503 when the spike flag is off / node-pty is absent.
|
|
1008
|
+
if (req.method === "POST" && url === "/api/agents/pty/start") {
|
|
1009
|
+
let pBody = "";
|
|
1010
|
+
for await (const chunk of req)
|
|
1011
|
+
pBody += chunk.toString("utf8");
|
|
1012
|
+
let payload;
|
|
1013
|
+
try {
|
|
1014
|
+
payload = JSON.parse(pBody || "{}");
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1018
|
+
res.end("bad json");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (!ptyEnabled()) {
|
|
1022
|
+
res.writeHead(503, { "content-type": "text/plain" });
|
|
1023
|
+
res.end("PTY agents are disabled — set LISA_PTY_AGENTS=1 to enable this spike");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const agent = typeof payload.agent === "string" && payload.agent.trim() ? payload.agent : "claude";
|
|
1027
|
+
const resumeSessionId = typeof payload.resumeSessionId === "string" && payload.resumeSessionId ? payload.resumeSessionId : undefined;
|
|
1028
|
+
// Adopting an external session is the highest-risk control action — gate it
|
|
1029
|
+
// behind remoteAdoptExternal; starting a fresh agent is ordinary control.
|
|
1030
|
+
if (denyRemote(resumeSessionId ? "adoptExternal" : "control"))
|
|
1031
|
+
return;
|
|
1032
|
+
// Adopting an existing session needs no task (it continues the convo); a
|
|
1033
|
+
// fresh agent does. Guard: never resume a session that's currently live.
|
|
1034
|
+
if (!resumeSessionId && (typeof payload.task !== "string" || !payload.task.trim())) {
|
|
1035
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1036
|
+
res.end("task required");
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (resumeSessionId && liveClaudeSessionIds().has(resumeSessionId)) {
|
|
1040
|
+
res.writeHead(409, { "content-type": "text/plain" });
|
|
1041
|
+
res.end("that session is currently live (open in the app/terminal) — close it first; resuming a live session would corrupt its transcript");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd.startsWith("/") ? payload.cwd : process.cwd();
|
|
1045
|
+
try {
|
|
1046
|
+
const view = await ptyRegistry.start({
|
|
1047
|
+
agent,
|
|
1048
|
+
task: typeof payload.task === "string" ? payload.task : "",
|
|
1049
|
+
cwd,
|
|
1050
|
+
resumeSessionId,
|
|
1051
|
+
});
|
|
1052
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1053
|
+
res.end(JSON.stringify({ ok: true, agent: view }));
|
|
1054
|
+
}
|
|
1055
|
+
catch (err) {
|
|
1056
|
+
res.writeHead(503, { "content-type": "text/plain" });
|
|
1057
|
+
res.end(err.message);
|
|
1058
|
+
}
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (req.method === "GET" && url.startsWith("/api/agents/pty/") && url.endsWith("/output")) {
|
|
1062
|
+
if (denyRemote("control"))
|
|
1063
|
+
return;
|
|
1064
|
+
const id = url.slice("/api/agents/pty/".length, -"/output".length);
|
|
1065
|
+
const out = ptyRegistry.output(decodeURIComponent(id));
|
|
1066
|
+
if (out === null) {
|
|
1067
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1068
|
+
res.end(JSON.stringify({ ok: false }));
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1072
|
+
res.end(JSON.stringify({ ok: true, output: out }));
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// Live attach stream for a PTY agent: the current output tail (snapshot), then
|
|
1076
|
+
// each new ANSI-stripped chunk as it arrives. Drives `lisa agents pty`
|
|
1077
|
+
// (adopt-at-launch) and a remote live-output view. Same auth gate; 404 if unknown.
|
|
1078
|
+
if (req.method === "GET" && url.startsWith("/api/agents/pty/") && url.endsWith("/stream")) {
|
|
1079
|
+
if (denyRemote("control"))
|
|
1080
|
+
return;
|
|
1081
|
+
const id = decodeURIComponent(url.slice("/api/agents/pty/".length, -"/stream".length));
|
|
1082
|
+
const initial = ptyRegistry.output(id);
|
|
1083
|
+
if (initial === null) {
|
|
1084
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1085
|
+
res.end(JSON.stringify({ ok: false }));
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
res.writeHead(200, {
|
|
1089
|
+
"content-type": "text/event-stream",
|
|
1090
|
+
"cache-control": "no-cache",
|
|
1091
|
+
connection: "keep-alive",
|
|
1092
|
+
});
|
|
1093
|
+
res.write(`data: ${JSON.stringify({ type: "snapshot", text: initial })}\n\n`);
|
|
1094
|
+
const onOut = (e) => {
|
|
1095
|
+
if (e.id !== id)
|
|
1096
|
+
return;
|
|
1097
|
+
try {
|
|
1098
|
+
res.write(`data: ${JSON.stringify({ type: "chunk", text: e.chunk })}\n\n`);
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
/* dead connection — cleaned up on close */
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
// End the stream when the agent finishes, so an attach client exits cleanly.
|
|
1105
|
+
const onUpdate = (v) => {
|
|
1106
|
+
if (v.id !== id || v.state !== "done")
|
|
1107
|
+
return;
|
|
1108
|
+
try {
|
|
1109
|
+
res.write(`data: ${JSON.stringify({ type: "end", state: v.state })}\n\n`);
|
|
1110
|
+
res.end();
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
/* already closed */
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
ptyRegistry.on("output", onOut);
|
|
1117
|
+
ptyRegistry.on("update", onUpdate);
|
|
1118
|
+
req.on("close", () => {
|
|
1119
|
+
ptyRegistry.off("output", onOut);
|
|
1120
|
+
ptyRegistry.off("update", onUpdate);
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (req.method === "POST" && url.startsWith("/api/agents/pty/")) {
|
|
1125
|
+
if (denyRemote("control"))
|
|
1126
|
+
return;
|
|
1127
|
+
const rest = url.slice("/api/agents/pty/".length);
|
|
1128
|
+
const slash = rest.indexOf("/");
|
|
1129
|
+
const id = decodeURIComponent(slash >= 0 ? rest.slice(0, slash) : rest);
|
|
1130
|
+
const action = slash >= 0 ? rest.slice(slash + 1) : "";
|
|
1131
|
+
let pBody = "";
|
|
1132
|
+
for await (const chunk of req)
|
|
1133
|
+
pBody += chunk.toString("utf8");
|
|
1134
|
+
let payload = {};
|
|
1135
|
+
try {
|
|
1136
|
+
payload = pBody ? JSON.parse(pBody) : {};
|
|
1137
|
+
}
|
|
1138
|
+
catch { /* tolerate */ }
|
|
1139
|
+
let ok = false;
|
|
1140
|
+
if (action === "send" && typeof payload.text === "string")
|
|
1141
|
+
ok = ptyRegistry.send(id, payload.text);
|
|
1142
|
+
else if (action === "cancel")
|
|
1143
|
+
ok = ptyRegistry.cancel(id);
|
|
1144
|
+
else {
|
|
1145
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1146
|
+
res.end("action must be send|cancel");
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
res.writeHead(ok ? 200 : 404, { "content-type": "application/json" });
|
|
1150
|
+
res.end(JSON.stringify({ ok }));
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
// Recent ambient sense events, for the island's "recently sensed" list.
|
|
1154
|
+
if (req.method === "GET" && url === "/api/sense/recent") {
|
|
1155
|
+
const events = readSenseEvents().slice(-30).reverse(); // newest first
|
|
1156
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1157
|
+
res.end(JSON.stringify({ events }));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (req.method === "POST" && url === "/api/consent/revoke-all") {
|
|
1161
|
+
revokeAll();
|
|
1162
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1163
|
+
res.end(JSON.stringify({ ok: true, grants: listGrants() }));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (req.method === "POST" &&
|
|
1167
|
+
(url === "/api/consent/grant" || url === "/api/consent/revoke")) {
|
|
1168
|
+
let cBody = "";
|
|
1169
|
+
for await (const chunk of req)
|
|
1170
|
+
cBody += chunk.toString("utf8");
|
|
1171
|
+
let payload;
|
|
1172
|
+
try {
|
|
1173
|
+
payload = JSON.parse(cBody || "{}");
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1177
|
+
res.end("bad json");
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const signal = payload.signal;
|
|
1181
|
+
// Only the canonical sense signals are togglable from the UI.
|
|
1182
|
+
if (typeof signal !== "string" || !SENSE_SIGNALS.includes(signal)) {
|
|
1183
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1184
|
+
res.end(`signal must be one of: ${SENSE_SIGNALS.join(", ")}`);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (url === "/api/consent/grant")
|
|
1188
|
+
grant(signal);
|
|
1189
|
+
else
|
|
1190
|
+
revoke(signal);
|
|
1191
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1192
|
+
res.end(JSON.stringify({ ok: true, grants: listGrants() }));
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
493
1195
|
// Cross-agent session snapshot (O1). Returns every active session
|
|
494
1196
|
// across all integrations, normalized. activity (Tier 2) is present
|
|
495
1197
|
// when the integration runs at visibility ≥ "activity".
|
|
496
1198
|
if (req.method === "GET" && url === "/api/agents/sessions") {
|
|
1199
|
+
// Mark idle claude sessions as adoptable: not currently live (no running
|
|
1200
|
+
// owner ⇒ `claude --resume` is safe) and not already a LISA-controlled row.
|
|
1201
|
+
const live = liveClaudeSessionIds();
|
|
497
1202
|
const sessions = hub.list().map((s) => ({
|
|
498
1203
|
...s,
|
|
499
1204
|
lastMtime: new Date(s.lastMtime).toISOString(),
|
|
1205
|
+
...(s.agent === "claude-code" && !s.controllable && !live.has(s.sessionId)
|
|
1206
|
+
? { resumable: true }
|
|
1207
|
+
: {}),
|
|
500
1208
|
}));
|
|
501
1209
|
res.writeHead(200, { "content-type": "application/json" });
|
|
502
1210
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -696,6 +1404,68 @@ self.addEventListener('fetch', (event) => {
|
|
|
696
1404
|
}));
|
|
697
1405
|
return;
|
|
698
1406
|
}
|
|
1407
|
+
// ── Coding plans (CODING_PLANS Phase 5b): detect, show usage, pick a
|
|
1408
|
+
// delegation target. Detection is presence-only (no secrets read); the
|
|
1409
|
+
// POST writes LISA_CODING_PLAN, not the model — her own loop is unchanged.
|
|
1410
|
+
if (req.method === "GET" && url === "/api/plans") {
|
|
1411
|
+
const nowMs = Date.now();
|
|
1412
|
+
const sel = selectedPlan();
|
|
1413
|
+
const plans = detectPlans().map((p) => {
|
|
1414
|
+
const row = {
|
|
1415
|
+
id: p.id,
|
|
1416
|
+
label: p.label,
|
|
1417
|
+
cli: p.cli,
|
|
1418
|
+
detail: p.detail,
|
|
1419
|
+
available: p.available,
|
|
1420
|
+
loggedIn: p.loggedIn,
|
|
1421
|
+
mark: planMark(p),
|
|
1422
|
+
selected: p.id === sel,
|
|
1423
|
+
};
|
|
1424
|
+
if (p.available) {
|
|
1425
|
+
const u = planUsage(p.id, nowMs);
|
|
1426
|
+
if (u && (u.windowTokens || u.todayTokens))
|
|
1427
|
+
row.usage = formatUsage(u);
|
|
1428
|
+
}
|
|
1429
|
+
return row;
|
|
1430
|
+
});
|
|
1431
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1432
|
+
res.end(JSON.stringify({ plans, selected: sel }));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (req.method === "POST" && url === "/api/plans/select") {
|
|
1436
|
+
let body = "";
|
|
1437
|
+
for await (const chunk of req)
|
|
1438
|
+
body += chunk.toString("utf8");
|
|
1439
|
+
let payload;
|
|
1440
|
+
try {
|
|
1441
|
+
payload = JSON.parse(body || "{}");
|
|
1442
|
+
}
|
|
1443
|
+
catch {
|
|
1444
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1445
|
+
res.end("bad json");
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const raw = typeof payload.plan === "string" ? payload.plan.trim().toLowerCase() : "";
|
|
1449
|
+
const planId = raw === "" || raw === "none"
|
|
1450
|
+
? ""
|
|
1451
|
+
: (parsePlanRef(raw) ?? (PLAN_IDS.includes(raw) ? raw : null));
|
|
1452
|
+
if (planId === null) {
|
|
1453
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1454
|
+
res.end("unknown plan — use claude | codex | copilot | none");
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
await saveConfigEnv({ LISA_CODING_PLAN: planId });
|
|
1459
|
+
}
|
|
1460
|
+
catch (err) {
|
|
1461
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
1462
|
+
res.end(err.message);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1466
|
+
res.end(JSON.stringify({ ok: true, selected: planId || null }));
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
699
1469
|
if (req.method === "GET" && url === "/api/config/status") {
|
|
700
1470
|
res.writeHead(200, { "content-type": "application/json" });
|
|
701
1471
|
res.end(JSON.stringify({
|
|
@@ -847,7 +1617,23 @@ self.addEventListener('fetch', (event) => {
|
|
|
847
1617
|
let body = "";
|
|
848
1618
|
for await (const chunk of req)
|
|
849
1619
|
body += chunk.toString("utf8");
|
|
850
|
-
|
|
1620
|
+
let message;
|
|
1621
|
+
let files;
|
|
1622
|
+
try {
|
|
1623
|
+
// Every other endpoint guards its JSON.parse — this one used to be the
|
|
1624
|
+
// only one that didn't, and a malformed body left the socket hanging.
|
|
1625
|
+
const parsed = JSON.parse(body);
|
|
1626
|
+
if (typeof parsed.message !== "string" || !parsed.message.trim()) {
|
|
1627
|
+
throw new Error("missing message");
|
|
1628
|
+
}
|
|
1629
|
+
message = parsed.message;
|
|
1630
|
+
files = parsed.files;
|
|
1631
|
+
}
|
|
1632
|
+
catch (err) {
|
|
1633
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
1634
|
+
res.end(JSON.stringify({ error: `bad request: ${err.message}` }));
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
851
1637
|
// User just talked — reset the idle watcher.
|
|
852
1638
|
try {
|
|
853
1639
|
getIdleWatcher(60 * 60_000).tick();
|
|
@@ -863,64 +1649,92 @@ self.addEventListener('fetch', (event) => {
|
|
|
863
1649
|
moodBus.on("mood", onMood);
|
|
864
1650
|
// Send the current mood immediately so a fresh tab knows where to start.
|
|
865
1651
|
send({ type: "mood", slug: moodBus.current() });
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1652
|
+
const runTurn = async () => {
|
|
1653
|
+
try {
|
|
1654
|
+
// Use the freshest cached prompt for this chat. If soul / skills /
|
|
1655
|
+
// memory changed since the previous chat, rebuildPrompt() picks it up.
|
|
1656
|
+
const fresh = await rebuildPrompt();
|
|
1657
|
+
const result = await runAgent({
|
|
1658
|
+
provider: getProvider(),
|
|
1659
|
+
systemPrompt: fresh.text,
|
|
1660
|
+
tools: opts.tools,
|
|
1661
|
+
toolCtx: {
|
|
1662
|
+
cwd: process.cwd(),
|
|
1663
|
+
signal: abort.signal,
|
|
1664
|
+
log: () => { },
|
|
1665
|
+
},
|
|
1666
|
+
history,
|
|
1667
|
+
userMessage: message,
|
|
1668
|
+
userFiles: files,
|
|
1669
|
+
model: opts.model,
|
|
1670
|
+
thinking: opts.thinking,
|
|
1671
|
+
onEvent: (ev) => {
|
|
1672
|
+
if (ev.type === "text_delta" && ev.text)
|
|
1673
|
+
send({ type: "text", text: ev.text });
|
|
1674
|
+
if (ev.type === "tool_call_start")
|
|
1675
|
+
send({
|
|
1676
|
+
type: "tool_start",
|
|
1677
|
+
name: ev.toolName,
|
|
1678
|
+
input: ev.toolInput,
|
|
1679
|
+
});
|
|
1680
|
+
if (ev.type === "tool_call_end")
|
|
1681
|
+
send({
|
|
1682
|
+
type: "tool_end",
|
|
1683
|
+
name: ev.toolName,
|
|
1684
|
+
isError: ev.isError === true,
|
|
1685
|
+
resultPreview: typeof ev.toolResult === "string"
|
|
1686
|
+
? ev.toolResult.slice(0, 200)
|
|
1687
|
+
: "",
|
|
1688
|
+
});
|
|
1689
|
+
if (ev.type === "system_prompt_rebuilt")
|
|
1690
|
+
send({ type: "soul_reload", message: ev.message ?? "" });
|
|
1691
|
+
if (ev.type === "error")
|
|
1692
|
+
send({ type: "error", message: ev.message ?? "" });
|
|
1693
|
+
},
|
|
1694
|
+
// Same plugin hook wiring as the CLI turn — PreToolUse can block,
|
|
1695
|
+
// PostToolUse can rewrite. (Was CLI-only; web tool calls bypassed
|
|
1696
|
+
// every configured hook.)
|
|
1697
|
+
preToolHook: hooks.length === 0 ? undefined : async (name, input) => {
|
|
1698
|
+
const r = await fireHooks("PreToolUse", hooks, { TOOL_NAME: name, TOOL_INPUT: JSON.stringify(input), SESSION_ID: session.id, LISA_HOME, CLAUDE_PROJECT_DIR: process.cwd() }, process.cwd());
|
|
1699
|
+
if (r.blocked.length > 0)
|
|
1700
|
+
return { block: r.blocked.join("; ") };
|
|
1701
|
+
},
|
|
1702
|
+
postToolHook: hooks.length === 0 ? undefined : async (name, input, result, isError) => {
|
|
1703
|
+
const r = await fireHooks("PostToolUse", hooks, {
|
|
1704
|
+
TOOL_NAME: name,
|
|
1705
|
+
TOOL_INPUT: JSON.stringify(input),
|
|
1706
|
+
TOOL_RESULT: result,
|
|
1707
|
+
TOOL_ERROR: isError ? "1" : "",
|
|
1708
|
+
SESSION_ID: session.id,
|
|
1709
|
+
LISA_HOME,
|
|
1710
|
+
CLAUDE_PROJECT_DIR: process.cwd(),
|
|
1711
|
+
}, process.cwd());
|
|
1712
|
+
if (r.rewriteResult != null)
|
|
1713
|
+
return { rewriteResult: r.rewriteResult };
|
|
1714
|
+
},
|
|
1715
|
+
onMessagePersist: (m) => session.appendMessage(m),
|
|
1716
|
+
hotReload: {
|
|
1717
|
+
initialFingerprint: fresh.fingerprint,
|
|
1718
|
+
rebuild: rebuildPrompt,
|
|
1719
|
+
},
|
|
1720
|
+
});
|
|
1721
|
+
history.length = 0;
|
|
1722
|
+
history.push(...result.history);
|
|
1723
|
+
send({ type: "done" });
|
|
1724
|
+
}
|
|
1725
|
+
catch (err) {
|
|
1726
|
+
send({ type: "error", message: err.message });
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
moodBus.off("mood", onMood);
|
|
1730
|
+
res.end();
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
// Queue behind any in-flight turn; the SSE stream stays open while we
|
|
1734
|
+
// wait, so the second tab just sees its reply start a little later.
|
|
1735
|
+
const job = chatChain.then(runTurn, runTurn);
|
|
1736
|
+
chatChain = job.then(() => { }, () => { });
|
|
1737
|
+
await job;
|
|
924
1738
|
return;
|
|
925
1739
|
}
|
|
926
1740
|
if (req.method === "POST" && url === "/reflect") {
|
|
@@ -942,7 +1756,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
942
1756
|
res.writeHead(404);
|
|
943
1757
|
res.end();
|
|
944
1758
|
});
|
|
945
|
-
server.listen(opts.port);
|
|
1759
|
+
server.listen(opts.port, host);
|
|
946
1760
|
return server;
|
|
947
1761
|
}
|
|
948
1762
|
//# sourceMappingURL=server.js.map
|