baychat 0.27.1 → 0.29.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/.claude-plugin/plugin.json +32 -0
- package/README.md +42 -4
- package/dist/approve-hook.js +93 -3
- package/dist/channel/claude-link.js +145 -0
- package/dist/channel/detect.js +10 -2
- package/dist/channel/feed.js +27 -13
- package/dist/channel/launch.js +67 -12
- package/dist/channel/permission.js +118 -0
- package/dist/channel/plugin.js +182 -0
- package/dist/channel/relay-link.js +257 -0
- package/dist/channel/server.js +191 -10
- package/dist/claude-onboarding.js +7 -5
- package/dist/cli-help.js +235 -0
- package/dist/commands.js +4 -4
- package/dist/connect-acp.js +3 -2
- package/dist/connect-claude.js +31 -10
- package/dist/device-login.js +2 -2
- package/dist/doctor-command.js +2 -0
- package/dist/doctor.js +97 -3
- package/dist/help-topics.js +4 -3
- package/dist/launch/claude-trust.js +221 -0
- package/dist/launch/command.js +177 -0
- package/dist/launch/policy.js +152 -0
- package/dist/machine-name.js +76 -0
- package/dist/main.js +16 -170
- package/dist/multiplexer-launch.js +271 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/agents.js +42 -19
- package/dist/relay/acp/daemon-glue.js +3 -2
- package/dist/relay/acp/runner.js +8 -7
- package/dist/relay/channel-sessions.js +66 -0
- package/dist/relay/commands.js +9 -2
- package/dist/relay/daemon.js +160 -9
- package/dist/relay/launcher/approval-settings.js +140 -0
- package/dist/relay/launcher/argv.js +83 -0
- package/dist/relay/launcher/capabilities.js +76 -0
- package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
- package/dist/relay/launcher/dev-channel-screen.js +47 -0
- package/dist/relay/launcher/handle.js +303 -0
- package/dist/relay/launcher/loop.js +231 -0
- package/dist/relay/launcher/multiplexer.js +168 -0
- package/dist/relay/launcher/started.js +63 -0
- package/dist/relay/profiles.js +1 -1
- package/dist/relay/registry.js +26 -0
- package/dist/relay/terminal-pane.js +31 -2
- package/dist/relay/transport.js +7 -2
- package/dist/relay/ws.js +18 -1
- package/dist/runtime-install.js +19 -3
- package/dist/runtimes.js +3 -0
- package/dist/skill-bootstrap.js +1 -1
- package/dist/start-command.js +12 -56
- package/dist/tool-defs.js +23 -36
- package/package.json +4 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RUNTIME_BINARY = void 0;
|
|
37
|
+
exports.buildCapabilities = buildCapabilities;
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
/** The executable each launch runtime is resolved by. */
|
|
40
|
+
exports.RUNTIME_BINARY = {
|
|
41
|
+
claude: "claude",
|
|
42
|
+
codex: "codex",
|
|
43
|
+
dsh: "dsh",
|
|
44
|
+
"cursor-cli": "agent",
|
|
45
|
+
};
|
|
46
|
+
const MACHINE_MAX = 64;
|
|
47
|
+
const VERSION_MAX = 64;
|
|
48
|
+
/**
|
|
49
|
+
* Build the capabilities. A missing policy offers no spots; a spot whose runtimes are all
|
|
50
|
+
* missing is left out entirely, since the app could not start anything there.
|
|
51
|
+
*/
|
|
52
|
+
function buildCapabilities(policy, deps) {
|
|
53
|
+
const resolved = new Map();
|
|
54
|
+
const versionOf = (runtime) => {
|
|
55
|
+
if (!resolved.has(runtime)) {
|
|
56
|
+
const r = deps.resolveBinary(exports.RUNTIME_BINARY[runtime]);
|
|
57
|
+
resolved.set(runtime, r.ok ? r.version.split("\n")[0].trim().slice(0, VERSION_MAX) || null : undefined);
|
|
58
|
+
}
|
|
59
|
+
return resolved.get(runtime);
|
|
60
|
+
};
|
|
61
|
+
const spots = [];
|
|
62
|
+
for (const spot of policy?.spots ?? []) {
|
|
63
|
+
const runtimes = spot.runtimes.flatMap((name) => {
|
|
64
|
+
const version = versionOf(name);
|
|
65
|
+
return version === undefined ? [] : [{ name, version }];
|
|
66
|
+
});
|
|
67
|
+
if (runtimes.length > 0)
|
|
68
|
+
spots.push({ id: spot.id, label: spot.label, runtimes });
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
machine: (deps.hostname ?? os.hostname()).replace(/\.local$/, "").slice(0, MACHINE_MAX) || "computer",
|
|
72
|
+
os: deps.platform ?? process.platform,
|
|
73
|
+
multiplexer: deps.multiplexer,
|
|
74
|
+
spots,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pane captures for the launcher's screen-matching tests. The stable phrases are the ones the owner
|
|
4
|
+
* quoted from Claude Code (2026-10-05); the surrounding layout is a representative capture.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.CLAUDE_PROMPT = exports.FOLDER_TRUST = exports.DEV_CHANNEL_WARNING_EXIT_SELECTED = exports.DEV_CHANNEL_WARNING = void 0;
|
|
8
|
+
exports.DEV_CHANNEL_WARNING = [
|
|
9
|
+
"╭──────────────────────────────────────────────────────────────────────────╮",
|
|
10
|
+
"│ WARNING: Loading development channels │",
|
|
11
|
+
"│ │",
|
|
12
|
+
"│ --dangerously-load-development-channels is for local channel development │",
|
|
13
|
+
"│ only. Do not use this option to run channels you have downloaded off the │",
|
|
14
|
+
"│ internet. │",
|
|
15
|
+
"│ │",
|
|
16
|
+
"│ Channels: server:baychat-channel │",
|
|
17
|
+
"│ │",
|
|
18
|
+
"│ ❯ 1. I am using this for local development │",
|
|
19
|
+
"│ 2. Exit │",
|
|
20
|
+
"╰──────────────────────────────────────────────────────────────────────────╯",
|
|
21
|
+
" Enter to confirm · Esc to cancel",
|
|
22
|
+
"",
|
|
23
|
+
].join("\n");
|
|
24
|
+
/** The same warning with the selection moved to "Exit". */
|
|
25
|
+
exports.DEV_CHANNEL_WARNING_EXIT_SELECTED = exports.DEV_CHANNEL_WARNING.replace("❯ 1. I am using", " 1. I am using").replace(" 2. Exit", "❯ 2. Exit");
|
|
26
|
+
exports.FOLDER_TRUST = [
|
|
27
|
+
"╭──────────────────────────────────────────────────────────────────────────╮",
|
|
28
|
+
"│ Do you trust the files in this folder? │",
|
|
29
|
+
"│ │",
|
|
30
|
+
"│ /work/web │",
|
|
31
|
+
"│ │",
|
|
32
|
+
"│ Quick safety check: Is this a project you created or one you trust? │",
|
|
33
|
+
"│ │",
|
|
34
|
+
"│ ❯ 1. Yes, I trust this folder │",
|
|
35
|
+
"│ 2. No, exit │",
|
|
36
|
+
"╰──────────────────────────────────────────────────────────────────────────╯",
|
|
37
|
+
" Enter to confirm · Esc to cancel",
|
|
38
|
+
"",
|
|
39
|
+
].join("\n");
|
|
40
|
+
/** Claude Code's ordinary prompt, past every question. */
|
|
41
|
+
exports.CLAUDE_PROMPT = ["╭─────────────────────────╮", "│ > /baychat mac-1 │", "╰─────────────────────────╯", " ? for shortcuts", ""].join("\n");
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Recognising the two Claude Code screens a detached launch can stop at — pure, so the rule that
|
|
4
|
+
* decides whether a key is ever pressed is testable on captured text.
|
|
5
|
+
*
|
|
6
|
+
* ── STRICT ON PURPOSE ─────────────────────────────────────────────────────────────────
|
|
7
|
+
* The only key the launcher ever sends is ONE Enter, and only on the development-channels warning
|
|
8
|
+
* after the computer's owner tapped Allow (Karmen, 2026-10-05). So the warning is recognised only
|
|
9
|
+
* when ALL of its stable phrases are on screen, and the Enter only when the selected row is
|
|
10
|
+
* option 1 ("I am using this for local development"). Anything less is "other", and "other" is
|
|
11
|
+
* never answered.
|
|
12
|
+
*
|
|
13
|
+
* The folder-trust screen ("Quick safety check") is recognised so it can be REFUSED: trust is the
|
|
14
|
+
* person at the computer's decision (launch/claude-trust.ts), never the network's.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.FOLDER_TRUST_PHRASES = exports.DEV_CHANNEL_WARNING_PHRASES = void 0;
|
|
18
|
+
exports.classifyPane = classifyPane;
|
|
19
|
+
exports.firstOptionSelected = firstOptionSelected;
|
|
20
|
+
exports.safeToConfirm = safeToConfirm;
|
|
21
|
+
/** Every one of these must be on screen for the development-channels warning. */
|
|
22
|
+
exports.DEV_CHANNEL_WARNING_PHRASES = ["Loading development channels", "I am using this for local development", "Enter to confirm"];
|
|
23
|
+
/** Both of these must be on screen for Claude Code's workspace-trust question. */
|
|
24
|
+
exports.FOLDER_TRUST_PHRASES = ["Quick safety check", "Yes, I trust this folder"];
|
|
25
|
+
function classifyPane(text) {
|
|
26
|
+
if (exports.DEV_CHANNEL_WARNING_PHRASES.every((p) => text.includes(p)))
|
|
27
|
+
return "dev-channel-warning";
|
|
28
|
+
if (exports.FOLDER_TRUST_PHRASES.every((p) => text.includes(p)))
|
|
29
|
+
return "folder-trust";
|
|
30
|
+
return "other";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The selection marker on option 1 of the warning, on one line. Only whitespace and a box border
|
|
34
|
+
* (`│`) may come before the marker; nothing else.
|
|
35
|
+
*/
|
|
36
|
+
const FIRST_OPTION_SELECTED = /^[\s│]*❯\s*1\.\s*I am using this for local development/;
|
|
37
|
+
/** True only when option 1 ("I am using this for local development") is the selected row. */
|
|
38
|
+
function firstOptionSelected(text) {
|
|
39
|
+
const lines = text.split("\n");
|
|
40
|
+
const selected = lines.filter((line) => line.includes("❯"));
|
|
41
|
+
// Exactly one selection marker, and it is on option 1.
|
|
42
|
+
return selected.length === 1 && FIRST_OPTION_SELECTED.test(selected[0]);
|
|
43
|
+
}
|
|
44
|
+
/** May the one Enter be pressed on this screen? The warning, with option 1 selected. */
|
|
45
|
+
function safeToConfirm(text) {
|
|
46
|
+
return classifyPane(text) === "dev-channel-warning" && firstOptionSelected(text);
|
|
47
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONFIRM_DEADLINE_MS = exports.WARNING_POLL_MS = exports.WARNING_WATCH_MS = void 0;
|
|
4
|
+
exports.handleLaunch = handleLaunch;
|
|
5
|
+
exports.parseLaunch = parseLaunch;
|
|
6
|
+
const policy_1 = require("../../launch/policy");
|
|
7
|
+
const argv_1 = require("./argv");
|
|
8
|
+
const approval_settings_1 = require("./approval-settings");
|
|
9
|
+
const capabilities_1 = require("./capabilities");
|
|
10
|
+
const dev_channel_screen_1 = require("./dev-channel-screen");
|
|
11
|
+
const multiplexer_1 = require("./multiplexer");
|
|
12
|
+
const started_1 = require("./started");
|
|
13
|
+
const refused = (reason) => ({ status: "REFUSED", reason });
|
|
14
|
+
const NO_MULTIPLEXER_HINT = "Install tmux (macOS: `brew install tmux`; Linux: `sudo apt install tmux`) so BayChat can start sessions here.";
|
|
15
|
+
/** How long the pane is watched for the dev-channel warning after a channel launch. */
|
|
16
|
+
exports.WARNING_WATCH_MS = 60_000;
|
|
17
|
+
/** How often the pane is read while watching. */
|
|
18
|
+
exports.WARNING_POLL_MS = 1_000;
|
|
19
|
+
/**
|
|
20
|
+
* The relay's own limit on waiting for the owner. The server decides "no answer" at 10 min
|
|
21
|
+
* (CONFIRM_TIMEOUT); this is only for when the server cannot be reached to say so.
|
|
22
|
+
*/
|
|
23
|
+
exports.CONFIRM_DEADLINE_MS = 10 * 60_000 + 30_000;
|
|
24
|
+
const INTERIM_REPORT_ATTEMPTS = 3;
|
|
25
|
+
const RETRY_PAUSE_MS = 2_000;
|
|
26
|
+
async function handleLaunch(launch, deps, io) {
|
|
27
|
+
if (!launch.commandAuthorized)
|
|
28
|
+
return refused("NOT_ALLOWED");
|
|
29
|
+
if ((0, argv_1.launchNameProblem)(launch.sessionName))
|
|
30
|
+
return refused("NAME_INVALID");
|
|
31
|
+
if (launch.kind === "STOP")
|
|
32
|
+
return stopSession(launch, deps);
|
|
33
|
+
const started = startSession(launch, deps);
|
|
34
|
+
if ("status" in started)
|
|
35
|
+
return started;
|
|
36
|
+
// Monitor sessions have no channel flag, so there is no warning to wait for.
|
|
37
|
+
if (launch.listen === "monitor")
|
|
38
|
+
return { status: "STARTED" };
|
|
39
|
+
return watchForDevChannelWarning(launch, started.entry, deps, io);
|
|
40
|
+
}
|
|
41
|
+
/** Check, spawn and record a START; the report when it ends here, else the recorded session. */
|
|
42
|
+
function startSession(launch, deps) {
|
|
43
|
+
const spot = deps.loadPolicy()?.spots.find((s) => s.id === launch.spotId);
|
|
44
|
+
if (!spot)
|
|
45
|
+
return refused("SPOT_UNKNOWN");
|
|
46
|
+
if (!(0, policy_1.isLaunchRuntime)(launch.runtime) || !spot.runtimes.includes(launch.runtime))
|
|
47
|
+
return refused("RUNTIME_NOT_OFFERED");
|
|
48
|
+
if (launch.runtime !== "claude")
|
|
49
|
+
return refused("RUNTIME_NOT_YET");
|
|
50
|
+
if (launch.join && "group" in launch.join && (0, argv_1.roomTitleProblem)(launch.join.group))
|
|
51
|
+
return refused("ROOM_INVALID");
|
|
52
|
+
const folder = deps.resolveFolder(spot);
|
|
53
|
+
if (!folder.ok) {
|
|
54
|
+
deps.log(`launch ${launch.id}: refused, ${folder.reason}`);
|
|
55
|
+
return refused("FOLDER_CHANGED");
|
|
56
|
+
}
|
|
57
|
+
// An untrusted folder would leave claude waiting at "Is this a project you trust?" forever,
|
|
58
|
+
// in a detached pane nobody sees. Refuse instead, so the phone can say what to do.
|
|
59
|
+
if (!deps.isFolderTrusted(folder.path)) {
|
|
60
|
+
deps.log(`launch ${launch.id}: refused, Claude Code has not trusted ${folder.path}. Run \`baychat launch add\` for it at this computer.`);
|
|
61
|
+
return refused("FOLDER_NOT_TRUSTED");
|
|
62
|
+
}
|
|
63
|
+
const multiplexer = deps.detectMultiplexer();
|
|
64
|
+
if (!multiplexer) {
|
|
65
|
+
deps.log(`launch ${launch.id}: refused, no tmux or screen. ${NO_MULTIPLEXER_HINT}`);
|
|
66
|
+
return refused("NO_MULTIPLEXER");
|
|
67
|
+
}
|
|
68
|
+
const live = (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run);
|
|
69
|
+
const recordFile = (0, started_1.startedRecordPath)(deps.launchDir);
|
|
70
|
+
const started = pruneEnded((0, started_1.loadStarted)(recordFile), live, deps);
|
|
71
|
+
if (live.includes(launch.sessionName))
|
|
72
|
+
return refused("NAME_TAKEN");
|
|
73
|
+
if (live.length >= policy_1.MAX_SESSIONS_PER_COMPUTER)
|
|
74
|
+
return refused("CAP_REACHED");
|
|
75
|
+
if (started.filter((s) => s.spotId === spot.id).length >= spot.maxSessions)
|
|
76
|
+
return refused("CAP_REACHED");
|
|
77
|
+
const binary = deps.resolveBinary(capabilities_1.RUNTIME_BINARY.claude);
|
|
78
|
+
if (!binary.ok)
|
|
79
|
+
return refused("RUNTIME_MISSING");
|
|
80
|
+
// Monitor: no channel flag and no channel marker — the session listens with listen_messages + Monitor.
|
|
81
|
+
const channel = launch.listen === "monitor" ? { args: [], env: {} } : deps.channelLaunch(binary.path);
|
|
82
|
+
const settingsFile = (0, approval_settings_1.approvalPathFor)(launch.sessionName) === "hook" ? (0, approval_settings_1.writeApprovalSettings)(launch.sessionName, deps.cli, deps.launchDir) : undefined;
|
|
83
|
+
const command = (0, argv_1.buildLaunchCommand)({
|
|
84
|
+
multiplexer,
|
|
85
|
+
sessionName: launch.sessionName,
|
|
86
|
+
folder: folder.path,
|
|
87
|
+
claudeBin: binary.path,
|
|
88
|
+
channelArgs: channel.args,
|
|
89
|
+
env: { ...channel.env, PATH: deps.pathEnv },
|
|
90
|
+
settingsFile,
|
|
91
|
+
join: launch.join,
|
|
92
|
+
});
|
|
93
|
+
const res = deps.run(command.file, command.args, { cwd: command.cwd, env: command.env });
|
|
94
|
+
if (res.error || res.status !== 0) {
|
|
95
|
+
(0, approval_settings_1.removeApprovalSettings)(launch.sessionName, deps.launchDir);
|
|
96
|
+
deps.log(`launch ${launch.id}: ${command.file} failed: ${res.error?.message ?? (res.stderr.trim() || `exit ${res.status}`)}`);
|
|
97
|
+
return { status: "FAILED", reason: "SPAWN_FAILED" };
|
|
98
|
+
}
|
|
99
|
+
const entry = { name: launch.sessionName, spotId: spot.id, runtime: launch.runtime, multiplexer, launchId: launch.id, startedAt: deps.now().toISOString() };
|
|
100
|
+
// Recorded BEFORE any watching, so a STOP (the owner's Deny, or Stop) can always find it.
|
|
101
|
+
(0, started_1.saveStarted)([...started, entry], recordFile);
|
|
102
|
+
deps.log(`launch ${launch.id}: started "${launch.sessionName}" in "${spot.label}" (${launch.listen}) for ${launch.requestedBy.name}. Approvals for this session come to BayChat.`);
|
|
103
|
+
return { entry };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* After a channel launch: Claude Code stops at "WARNING: Loading development channels … Enter to
|
|
107
|
+
* confirm" on every start with the flag, and a detached pane would wait there forever. Watch the
|
|
108
|
+
* pane; when the warning shows, ask the computer's owner (NEEDS_CONFIRM) and press ONE Enter only
|
|
109
|
+
* after their Allow, only on that same screen with option 1 selected. Nothing is ever pressed
|
|
110
|
+
* otherwise. The folder-trust screen is never answered.
|
|
111
|
+
*/
|
|
112
|
+
async function watchForDevChannelWarning(launch, entry, deps, io) {
|
|
113
|
+
const until = deps.now().getTime() + exports.WARNING_WATCH_MS;
|
|
114
|
+
for (;;) {
|
|
115
|
+
const text = deps.capture(entry.multiplexer, entry.name);
|
|
116
|
+
const screen = text === null ? "other" : (0, dev_channel_screen_1.classifyPane)(text);
|
|
117
|
+
if (screen === "folder-trust")
|
|
118
|
+
return stopOwn(launch, entry, deps, { status: "FAILED", reason: "FOLDER_NOT_TRUSTED" }, "Claude Code asks whether to trust the folder");
|
|
119
|
+
if (screen === "dev-channel-warning")
|
|
120
|
+
return askOwner(launch, entry, deps, io);
|
|
121
|
+
// Never seen (it may already be past it), or the relay is shutting down: the pane runs on.
|
|
122
|
+
if (deps.now().getTime() >= until || io?.signal?.aborted)
|
|
123
|
+
return { status: "STARTED" };
|
|
124
|
+
await deps.sleep(exports.WARNING_POLL_MS);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function askOwner(launch, entry, deps, io) {
|
|
128
|
+
if (!io || !(await sendInterim(io, deps))) {
|
|
129
|
+
return stopOwn(launch, entry, deps, { status: "FAILED", reason: "CONFIRM_UNREACHABLE" }, "could not ask the owner");
|
|
130
|
+
}
|
|
131
|
+
deps.log(`launch ${launch.id}: Claude Code shows the development-channels warning; waiting for the computer owner's OK in BayChat.`);
|
|
132
|
+
const answer = await awaitDecision(io, deps);
|
|
133
|
+
if (io.signal?.aborted) {
|
|
134
|
+
// Shutting down: the report would not be delivered anyway. The server times the question out
|
|
135
|
+
// and queues a STOP, which the relay carries out when it is back.
|
|
136
|
+
return refused("CONFIRM_TIMEOUT");
|
|
137
|
+
}
|
|
138
|
+
if (!answer)
|
|
139
|
+
return stopOwn(launch, entry, deps, refused("CONFIRM_TIMEOUT"), "no answer from the owner");
|
|
140
|
+
if (answer.decision === "deny") {
|
|
141
|
+
// Press nothing. The server queued a STOP for this session; the next poll carries it out.
|
|
142
|
+
const reason = answer.reason === "CONFIRM_TIMEOUT" ? "CONFIRM_TIMEOUT" : "OWNER_DECLINED";
|
|
143
|
+
deps.log(`launch ${launch.id}: the owner did not allow it (${reason}); nothing was pressed.`);
|
|
144
|
+
return refused(reason);
|
|
145
|
+
}
|
|
146
|
+
return confirmWarning(launch, entry, deps);
|
|
147
|
+
}
|
|
148
|
+
/** The owner allowed it: press ONE Enter, only on the same warning with option 1 selected. */
|
|
149
|
+
function confirmWarning(launch, entry, deps) {
|
|
150
|
+
// Only ever into the pane this launch created and the record lists.
|
|
151
|
+
const recorded = (0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)).find((s) => s.name === entry.name && s.launchId === launch.id && s.multiplexer === entry.multiplexer);
|
|
152
|
+
if (!recorded) {
|
|
153
|
+
deps.log(`launch ${launch.id}: its pane is no longer in the launcher's record; nothing was pressed.`);
|
|
154
|
+
return { status: "FAILED", reason: "CONFIRM_SCREEN_CHANGED" };
|
|
155
|
+
}
|
|
156
|
+
const text = deps.capture(entry.multiplexer, entry.name);
|
|
157
|
+
if (text === null)
|
|
158
|
+
return stopOwn(launch, entry, deps, { status: "FAILED", reason: "CONFIRM_SCREEN_CHANGED" }, "its pane cannot be read");
|
|
159
|
+
const screen = (0, dev_channel_screen_1.classifyPane)(text);
|
|
160
|
+
if (screen === "folder-trust")
|
|
161
|
+
return stopOwn(launch, entry, deps, { status: "FAILED", reason: "FOLDER_NOT_TRUSTED" }, "Claude Code asks whether to trust the folder");
|
|
162
|
+
if (screen === "other") {
|
|
163
|
+
// Someone at the computer answered it already (or Claude moved on): nothing to press.
|
|
164
|
+
deps.log(`launch ${launch.id}: the warning is gone; nothing was pressed.`);
|
|
165
|
+
return { status: "STARTED" };
|
|
166
|
+
}
|
|
167
|
+
if (!(0, dev_channel_screen_1.safeToConfirm)(text))
|
|
168
|
+
return stopOwn(launch, entry, deps, { status: "FAILED", reason: "CONFIRM_SCREEN_CHANGED" }, "the warning changed (option 1 is not selected)");
|
|
169
|
+
if (!deps.pressEnter(entry.multiplexer, entry.name))
|
|
170
|
+
return stopOwn(launch, entry, deps, { status: "FAILED", reason: "LAUNCHER_ERROR" }, "Enter could not be sent");
|
|
171
|
+
deps.log(`launch ${launch.id}: the owner allowed it; pressed Enter once on "I am using this for local development".`);
|
|
172
|
+
return { status: "STARTED" };
|
|
173
|
+
}
|
|
174
|
+
async function sendInterim(io, deps) {
|
|
175
|
+
for (let attempt = 1; attempt <= INTERIM_REPORT_ATTEMPTS; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
await io.report({ status: "NEEDS_CONFIRM", reason: "DEV_CHANNEL_WARNING" });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
deps.log(`launcher: NEEDS_CONFIRM report failed (${err instanceof Error ? err.message : String(err)})`);
|
|
182
|
+
if (attempt < INTERIM_REPORT_ATTEMPTS)
|
|
183
|
+
await deps.sleep(RETRY_PAUSE_MS);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
/** Long-poll until the owner decides, or the relay's own deadline passes (null). */
|
|
189
|
+
async function awaitDecision(io, deps) {
|
|
190
|
+
const until = deps.now().getTime() + exports.CONFIRM_DEADLINE_MS;
|
|
191
|
+
while (deps.now().getTime() < until && !io.signal?.aborted) {
|
|
192
|
+
const asked = deps.now().getTime();
|
|
193
|
+
try {
|
|
194
|
+
const answer = await io.pollDecision();
|
|
195
|
+
if (answer.decision === "allow" || answer.decision === "deny")
|
|
196
|
+
return answer;
|
|
197
|
+
// A poll that came back at once must not turn into a busy loop.
|
|
198
|
+
if (deps.now().getTime() - asked < exports.WARNING_POLL_MS)
|
|
199
|
+
await deps.sleep(exports.WARNING_POLL_MS);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
deps.log(`launcher: confirm poll failed (${err instanceof Error ? err.message : String(err)})`);
|
|
203
|
+
await deps.sleep(RETRY_PAUSE_MS);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** Kill this launch's own pane (never pressing anything), forget it, and return `report`. */
|
|
209
|
+
function stopOwn(launch, entry, deps, report, why) {
|
|
210
|
+
deps.log(`launch ${launch.id}: ${why}; nothing was pressed, stopping "${entry.name}".`);
|
|
211
|
+
stopRecorded(entry, deps);
|
|
212
|
+
return report;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* STOP kills only what the launcher itself started: the session must be in started.json AND
|
|
216
|
+
* alive. A hand-made `bc-x` tmux session, or one listed but already gone, is never touched.
|
|
217
|
+
*/
|
|
218
|
+
function stopSession(launch, deps) {
|
|
219
|
+
const recordFile = (0, started_1.startedRecordPath)(deps.launchDir);
|
|
220
|
+
const started = (0, started_1.loadStarted)(recordFile);
|
|
221
|
+
const entry = started.find((s) => s.name === launch.sessionName);
|
|
222
|
+
if (!entry)
|
|
223
|
+
return refused("NOT_LAUNCHED");
|
|
224
|
+
const outcome = stopRecorded(entry, deps);
|
|
225
|
+
if (outcome === "not-running")
|
|
226
|
+
return refused("NOT_RUNNING");
|
|
227
|
+
if (outcome === "failed")
|
|
228
|
+
return { status: "FAILED", reason: "STOP_FAILED" };
|
|
229
|
+
deps.log(`launch ${launch.id}: stopped "${entry.name}" for ${launch.requestedBy.name}`);
|
|
230
|
+
return { status: "STARTED" };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The one stop routine (STOP, and a launch abandoned while watching its pane): kill the recorded
|
|
234
|
+
* session if it is alive, then forget it and delete its settings file. A kill that fails leaves
|
|
235
|
+
* the record, so a later STOP can try again.
|
|
236
|
+
*/
|
|
237
|
+
function stopRecorded(entry, deps) {
|
|
238
|
+
const recordFile = (0, started_1.startedRecordPath)(deps.launchDir);
|
|
239
|
+
const forget = () => {
|
|
240
|
+
(0, started_1.saveStarted)((0, started_1.loadStarted)(recordFile).filter((s) => s.name !== entry.name), recordFile);
|
|
241
|
+
(0, approval_settings_1.removeApprovalSettings)(entry.name, deps.launchDir);
|
|
242
|
+
};
|
|
243
|
+
if (!(0, multiplexer_1.liveLaunchSessions)(entry.multiplexer, deps.run).includes(entry.name)) {
|
|
244
|
+
forget();
|
|
245
|
+
return "not-running";
|
|
246
|
+
}
|
|
247
|
+
if (!(0, multiplexer_1.killLaunchSession)(entry.multiplexer, entry.name, deps.run))
|
|
248
|
+
return "failed";
|
|
249
|
+
forget();
|
|
250
|
+
return "stopped";
|
|
251
|
+
}
|
|
252
|
+
/** Forget sessions that ended on their own (and delete their settings files); return the rest. */
|
|
253
|
+
function pruneEnded(started, live, deps) {
|
|
254
|
+
const alive = started.filter((s) => live.includes(s.name));
|
|
255
|
+
if (alive.length === started.length)
|
|
256
|
+
return started;
|
|
257
|
+
for (const gone of started.filter((s) => !alive.includes(s)))
|
|
258
|
+
(0, approval_settings_1.removeApprovalSettings)(gone.name, deps.launchDir);
|
|
259
|
+
(0, started_1.saveStarted)(alive, (0, started_1.startedRecordPath)(deps.launchDir));
|
|
260
|
+
return alive;
|
|
261
|
+
}
|
|
262
|
+
/** The launch in a poll answer, checked field by field; null for anything malformed. */
|
|
263
|
+
function parseLaunch(raw) {
|
|
264
|
+
if (!raw || typeof raw !== "object")
|
|
265
|
+
return null;
|
|
266
|
+
const l = raw;
|
|
267
|
+
if (typeof l.id !== "string" || (l.kind !== "START" && l.kind !== "STOP") || typeof l.sessionName !== "string")
|
|
268
|
+
return null;
|
|
269
|
+
const requestedBy = l.requestedBy;
|
|
270
|
+
if (!requestedBy || typeof requestedBy.id !== "string" || typeof requestedBy.name !== "string")
|
|
271
|
+
return null;
|
|
272
|
+
const join = parseJoin(l.join);
|
|
273
|
+
if (join === undefined)
|
|
274
|
+
return null;
|
|
275
|
+
// Missing = an older server = the channel, as before. Anything unknown is malformed.
|
|
276
|
+
const listen = l.listen === undefined || l.listen === "channel" ? "channel" : l.listen === "monitor" ? "monitor" : null;
|
|
277
|
+
if (!listen)
|
|
278
|
+
return null;
|
|
279
|
+
return {
|
|
280
|
+
id: l.id,
|
|
281
|
+
kind: l.kind,
|
|
282
|
+
spotId: typeof l.spotId === "string" ? l.spotId : null,
|
|
283
|
+
runtime: typeof l.runtime === "string" ? l.runtime : null,
|
|
284
|
+
sessionName: l.sessionName,
|
|
285
|
+
join,
|
|
286
|
+
listen,
|
|
287
|
+
requestedBy: { id: requestedBy.id, name: requestedBy.name },
|
|
288
|
+
requestedByIsOwner: l.requestedByIsOwner === true,
|
|
289
|
+
// Only an explicit `true` authorises; a missing or odd value is a refusal.
|
|
290
|
+
commandAuthorized: l.commandAuthorized === true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** `null` is "join nothing"; undefined is a join that is present but malformed. */
|
|
294
|
+
function parseJoin(raw) {
|
|
295
|
+
if (raw === null || raw === undefined)
|
|
296
|
+
return null;
|
|
297
|
+
const j = raw;
|
|
298
|
+
if (typeof j.group === "string")
|
|
299
|
+
return { group: j.group };
|
|
300
|
+
if (j.sessions === true)
|
|
301
|
+
return { sessions: true };
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|