@oratis/lisa 0.8.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/mcp/config.d.ts +4 -0
- package/dist/mcp/config.d.ts.map +1 -1
- package/dist/mcp/config.js +34 -0
- package/dist/mcp/config.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/github.d.ts +34 -0
- package/dist/tools/github.d.ts.map +1 -0
- package/dist/tools/github.js +140 -0
- package/dist/tools/github.js.map +1 -0
- package/dist/tools/github_link.d.ts +36 -0
- package/dist/tools/github_link.d.ts.map +1 -0
- package/dist/tools/github_link.js +127 -0
- package/dist/tools/github_link.js.map +1 -0
- package/dist/tools/mcp.d.ts +28 -0
- package/dist/tools/mcp.d.ts.map +1 -0
- package/dist/tools/mcp.js +55 -0
- package/dist/tools/mcp.js.map +1 -0
- package/dist/tools/npm_info.d.ts +21 -0
- package/dist/tools/npm_info.d.ts.map +1 -0
- package/dist/tools/npm_info.js +102 -0
- package/dist/tools/npm_info.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 +75 -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/voice/dictation.d.ts +51 -0
- package/dist/voice/dictation.d.ts.map +1 -0
- package/dist/voice/dictation.js +70 -0
- package/dist/voice/dictation.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 +427 -67
- 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 +16 -2091
- 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 +898 -65
- 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,16 +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";
|
|
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";
|
|
26
44
|
import { loadScreenAdvisorConfig, saveScreenAdvisorConfig, normalizeConfig, analyzeScreenshot, } from "../screen_advisor/engine.js";
|
|
27
45
|
import os from "node:os";
|
|
28
46
|
import { LISA_HOME } from "../paths.js";
|
|
29
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
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
|
+
}
|
|
31
93
|
async function resumeOrCreateWebSession(model) {
|
|
32
94
|
const lastId = await readActiveWebSession();
|
|
33
95
|
if (lastId) {
|
|
@@ -61,6 +123,14 @@ async function resumeOrCreateWebSession(model) {
|
|
|
61
123
|
return s;
|
|
62
124
|
}
|
|
63
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
|
+
}
|
|
64
134
|
const snapshot = await buildSystemPromptSnapshot();
|
|
65
135
|
const initialFingerprint = await getPromptFingerprint();
|
|
66
136
|
// Per-process hot-reload cache for the web server: same shape as cli's
|
|
@@ -99,6 +169,13 @@ export async function startWebServer(opts) {
|
|
|
99
169
|
const { messages: savedMessages } = await session.readMessagePage(0, 9999);
|
|
100
170
|
const history = [...savedMessages];
|
|
101
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();
|
|
102
179
|
// ── Persistent /events SSE subscribers (mood + idle broadcasts) ─────
|
|
103
180
|
const eventClients = new Set();
|
|
104
181
|
const broadcast = (event) => {
|
|
@@ -126,11 +203,15 @@ export async function startWebServer(opts) {
|
|
|
126
203
|
const hub = new OrchestratorHub(orchestratorCfg, {
|
|
127
204
|
log: (msg) => console.error(msg),
|
|
128
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) });
|
|
129
209
|
hub.on("update", (session) => {
|
|
130
210
|
// L6 — record the transition in the orchestrator journal so the
|
|
131
211
|
// cross-agent recap can answer "what happened while I was away?" even for
|
|
132
212
|
// sessions that have since ended. (Dedups consecutive same-state events.)
|
|
133
213
|
recordEvent(session);
|
|
214
|
+
pushBridge.onAgentUpdate(session);
|
|
134
215
|
// New generalized event for the multi-agent UI.
|
|
135
216
|
broadcast({ type: "agent_session_update", ...session });
|
|
136
217
|
// Back-compat: the island still listens for claude_session_update as a
|
|
@@ -158,6 +239,7 @@ export async function startWebServer(opts) {
|
|
|
158
239
|
// card — pull-friendly, not an interrupt. lastIdleMessage is assigned
|
|
159
240
|
// inside the async callback (runs after all locals init; no TDZ issue).
|
|
160
241
|
const ADVISE_INTERVAL_MS = 5 * 60_000;
|
|
242
|
+
let lastAdvisorSuggestions = null;
|
|
161
243
|
const adviseTimer = setInterval(() => {
|
|
162
244
|
void (async () => {
|
|
163
245
|
try {
|
|
@@ -171,6 +253,19 @@ export async function startWebServer(opts) {
|
|
|
171
253
|
const at = new Date().toISOString();
|
|
172
254
|
lastIdleMessage = { text, at };
|
|
173
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 });
|
|
174
269
|
console.error(`[advisor] surfaced ${surface.length} suggestion(s)`);
|
|
175
270
|
}
|
|
176
271
|
catch (err) {
|
|
@@ -192,7 +287,11 @@ export async function startWebServer(opts) {
|
|
|
192
287
|
let screenTimer = null;
|
|
193
288
|
let screenTickRunning = false;
|
|
194
289
|
const screenTick = async () => {
|
|
195
|
-
|
|
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"))
|
|
196
295
|
return;
|
|
197
296
|
screenTickRunning = true;
|
|
198
297
|
try {
|
|
@@ -236,7 +335,25 @@ export async function startWebServer(opts) {
|
|
|
236
335
|
restartScreenTimer();
|
|
237
336
|
if (screenCfg.enabled) {
|
|
238
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
|
+
}
|
|
239
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
|
+
});
|
|
240
357
|
// ── Island unread tracking (Phase 1 of MAC_ISLAND_PLAN) ─────────────
|
|
241
358
|
// The island widget caches "last idle_message" so a fresh tab opening
|
|
242
359
|
// mid-conversation knows there's something to read. Cleared via
|
|
@@ -279,6 +396,7 @@ export async function startWebServer(opts) {
|
|
|
279
396
|
});
|
|
280
397
|
lastIdleMessage = { text: result.text, at: startedAt };
|
|
281
398
|
broadcast({ type: "idle_message", text: result.text, at: startedAt });
|
|
399
|
+
pushBridge.onIdleMessage(result.text);
|
|
282
400
|
}
|
|
283
401
|
}
|
|
284
402
|
catch (err) {
|
|
@@ -295,14 +413,65 @@ export async function startWebServer(opts) {
|
|
|
295
413
|
}
|
|
296
414
|
const server = http.createServer(async (req, res) => {
|
|
297
415
|
const url = req.url ?? "/";
|
|
298
|
-
|
|
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("/?"))) {
|
|
299
468
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
300
469
|
res.end(MAIN_HTML);
|
|
301
470
|
return;
|
|
302
471
|
}
|
|
303
472
|
// Island widget — designed to be opened in a tiny browser window
|
|
304
473
|
// (Arc, Vivaldi PWA, Safari split). See docs/MAC_ISLAND_PLAN.md.
|
|
305
|
-
if (req.method === "GET" && url === "/island") {
|
|
474
|
+
if (req.method === "GET" && (url === "/island" || url.startsWith("/island?"))) {
|
|
306
475
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
307
476
|
res.end(ISLAND_HTML);
|
|
308
477
|
return;
|
|
@@ -453,8 +622,30 @@ export async function startWebServer(opts) {
|
|
|
453
622
|
await fs.writeFile(tmp, Buffer.from(payload.data, "base64"));
|
|
454
623
|
console.error(`[voice] transcribing ${Buffer.from(payload.data, "base64").length} bytes (${ext})`);
|
|
455
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);
|
|
629
|
+
// Dictation mode (Typeless-equivalent): polish the raw transcript into
|
|
630
|
+
// the clean text the speaker intended (filler/repetition removed,
|
|
631
|
+
// self-corrections applied, punctuation + formatting), to drop into the
|
|
632
|
+
// composer. Falls back to the raw transcript if the polish call fails.
|
|
633
|
+
let text;
|
|
634
|
+
if (payload.mode === "dictation") {
|
|
635
|
+
try {
|
|
636
|
+
text = await polishDictation({
|
|
637
|
+
provider: getProvider(),
|
|
638
|
+
model: opts.model,
|
|
639
|
+
transcript,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
console.error(`[voice] dictation polish failed: ${err.message}`);
|
|
644
|
+
text = transcript;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
456
647
|
res.writeHead(200, { "content-type": "application/json" });
|
|
457
|
-
res.end(JSON.stringify({ transcript }));
|
|
648
|
+
res.end(JSON.stringify(text !== undefined ? { transcript, text } : { transcript }));
|
|
458
649
|
}
|
|
459
650
|
catch (err) {
|
|
460
651
|
res.writeHead(500, { "content-type": "application/json" });
|
|
@@ -471,13 +662,549 @@ export async function startWebServer(opts) {
|
|
|
471
662
|
res.end(JSON.stringify({ ok: true }));
|
|
472
663
|
return;
|
|
473
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
|
+
}
|
|
474
1195
|
// Cross-agent session snapshot (O1). Returns every active session
|
|
475
1196
|
// across all integrations, normalized. activity (Tier 2) is present
|
|
476
1197
|
// when the integration runs at visibility ≥ "activity".
|
|
477
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();
|
|
478
1202
|
const sessions = hub.list().map((s) => ({
|
|
479
1203
|
...s,
|
|
480
1204
|
lastMtime: new Date(s.lastMtime).toISOString(),
|
|
1205
|
+
...(s.agent === "claude-code" && !s.controllable && !live.has(s.sessionId)
|
|
1206
|
+
? { resumable: true }
|
|
1207
|
+
: {}),
|
|
481
1208
|
}));
|
|
482
1209
|
res.writeHead(200, { "content-type": "application/json" });
|
|
483
1210
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -677,6 +1404,68 @@ self.addEventListener('fetch', (event) => {
|
|
|
677
1404
|
}));
|
|
678
1405
|
return;
|
|
679
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
|
+
}
|
|
680
1469
|
if (req.method === "GET" && url === "/api/config/status") {
|
|
681
1470
|
res.writeHead(200, { "content-type": "application/json" });
|
|
682
1471
|
res.end(JSON.stringify({
|
|
@@ -828,7 +1617,23 @@ self.addEventListener('fetch', (event) => {
|
|
|
828
1617
|
let body = "";
|
|
829
1618
|
for await (const chunk of req)
|
|
830
1619
|
body += chunk.toString("utf8");
|
|
831
|
-
|
|
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
|
+
}
|
|
832
1637
|
// User just talked — reset the idle watcher.
|
|
833
1638
|
try {
|
|
834
1639
|
getIdleWatcher(60 * 60_000).tick();
|
|
@@ -844,64 +1649,92 @@ self.addEventListener('fetch', (event) => {
|
|
|
844
1649
|
moodBus.on("mood", onMood);
|
|
845
1650
|
// Send the current mood immediately so a fresh tab knows where to start.
|
|
846
1651
|
send({ type: "mood", slug: moodBus.current() });
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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;
|
|
905
1738
|
return;
|
|
906
1739
|
}
|
|
907
1740
|
if (req.method === "POST" && url === "/reflect") {
|
|
@@ -923,7 +1756,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
923
1756
|
res.writeHead(404);
|
|
924
1757
|
res.end();
|
|
925
1758
|
});
|
|
926
|
-
server.listen(opts.port);
|
|
1759
|
+
server.listen(opts.port, host);
|
|
927
1760
|
return server;
|
|
928
1761
|
}
|
|
929
1762
|
//# sourceMappingURL=server.js.map
|