@logicseek/hello-cc 1.1.0-rc.8 → 1.1.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/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +13 -2
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +153 -50
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +113 -7
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- package/scripts/web-workbench-installed-acceptance.mjs +11 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
4
5
|
import path from 'node:path';
|
|
5
|
-
import
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { promisify, TextDecoder } from 'node:util';
|
|
6
8
|
import { CliError } from '../shared/errors.mjs';
|
|
9
|
+
import { preparePinnedCwdLaunch } from '../process/pinned-cwd.mjs';
|
|
10
|
+
import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
7
11
|
|
|
8
12
|
export const PROJECT_FILE_LIMITS = Object.freeze({ entries: 500, scannedEntries: 5000,
|
|
9
13
|
textBytes: 1024 * 1024, mediaBytes: 10 * 1024 * 1024, sniffBytes: 4096 });
|
|
@@ -31,10 +35,11 @@ const errorStatus = new Map([['PROJECT_FILE_BAD_PATH', 400], ['PROJECT_FILE_FORB
|
|
|
31
35
|
|
|
32
36
|
export function projectFileErrorStatus(error) { return errorStatus.get(error?.code); }
|
|
33
37
|
|
|
34
|
-
function privateComponent(name) {
|
|
38
|
+
export function privateComponent(name) {
|
|
35
39
|
const lower = name.toLowerCase();
|
|
36
|
-
return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[
|
|
37
|
-
/\.env(?:[
|
|
40
|
+
return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[._-]|$)/.test(lower) ||
|
|
41
|
+
/\.env(?:[._-]|$)/.test(lower) ||
|
|
42
|
+
/^(?:auth|accounts)(?:[._-](?:backup|bak|old|orig|copy))?\.(?:json|jsonl)(?:[._-](?:backup|bak|old|orig|copy))?$/.test(lower) ||
|
|
38
43
|
/(?:^|[._-])(?:secrets?|credentials?|tokens?)(?:[._-]|$)/.test(lower) ||
|
|
39
44
|
/^(?:id_(?:rsa|dsa|ecdsa|ed25519)|terraform\.tfstate)(?:[.-]|$)/.test(lower) ||
|
|
40
45
|
/^service[-_]account(?:[._-]|$)/.test(lower) || /\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ovpn)(?:[.-]|$)/.test(lower);
|
|
@@ -93,6 +98,86 @@ function publicError(error) {
|
|
|
93
98
|
return new CliError('PROJECT_FILE_UNAVAILABLE', 'The requested project path could not be read');
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
const execFileAsync = promisify(execFile);
|
|
102
|
+
|
|
103
|
+
// The helper is launched with a checked directory identity and uses only
|
|
104
|
+
// relative names after entering that inode. It never opens a project path.
|
|
105
|
+
export async function runPinnedDirectoryWorker(directory, expectedIdentity, source, input) {
|
|
106
|
+
const binding = preparePinnedCwdLaunch(directory, process.execPath,
|
|
107
|
+
['-e', source, JSON.stringify(input)], {
|
|
108
|
+
purpose: 'maintenance', expectedIdentity,
|
|
109
|
+
env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
const { stdout } = await execFileAsync(binding.command, binding.args, {
|
|
113
|
+
cwd: binding.cwd, env: binding.env, timeout: 10000, maxBuffer: 4 * 1024 * 1024
|
|
114
|
+
});
|
|
115
|
+
return JSON.parse(stdout);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const stderr = String(error.stderr || '');
|
|
118
|
+
if (/HCC_PINNED_CWD_CHANGED:/.test(stderr)) throw changed();
|
|
119
|
+
const workerError = /HCC_DIRECTORY_WORKER_FAILED:([A-Z_]+)/.exec(stderr);
|
|
120
|
+
if (workerError?.[1] === 'PROJECT_FILE_CHANGED') throw changed();
|
|
121
|
+
if (workerError) { const failure = new Error(workerError[1]); failure.code = workerError[1]; throw failure; }
|
|
122
|
+
throw error;
|
|
123
|
+
} finally { binding.release(); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function projectListWorker() {
|
|
127
|
+
const fs = require('node:fs');
|
|
128
|
+
const { scannedEntries } = JSON.parse(process.argv[1]);
|
|
129
|
+
const entries = [];
|
|
130
|
+
let scanned = 0, truncated = false, directory;
|
|
131
|
+
try {
|
|
132
|
+
directory = fs.opendirSync('.');
|
|
133
|
+
for (let item; (item = directory.readSync()) !== null;) {
|
|
134
|
+
if (++scanned > scannedEntries) { truncated = true; break; }
|
|
135
|
+
if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
|
|
136
|
+
if (!item.isDirectory() && !item.isFile()) continue;
|
|
137
|
+
let stat;
|
|
138
|
+
try { stat = fs.lstatSync(item.name, { bigint: true }); }
|
|
139
|
+
catch (error) { if (error.code === 'ENOENT') error.code = 'PROJECT_FILE_CHANGED'; throw error; }
|
|
140
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
|
|
141
|
+
entries.push({ name: item.name, type: stat.isDirectory() ? 'directory' : 'file',
|
|
142
|
+
...(stat.isFile() ? { size: Number(stat.size) } : {}) });
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(JSON.stringify({ entries, truncated }));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
process.stderr.write('HCC_DIRECTORY_WORKER_FAILED:' + (error.code || 'PROJECT_FILE_UNAVAILABLE') + '\n');
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
} finally { directory?.closeSync(); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE = `const ignored = new Set(${JSON.stringify([...ignored])});\n` +
|
|
152
|
+
`const privateNames = new Set(${JSON.stringify([...privateNames])});\n` +
|
|
153
|
+
`const privateComponent = ${privateComponent.toString()};\n`;
|
|
154
|
+
const PROJECT_LIST_WORKER_SOURCE = PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE +
|
|
155
|
+
`(${projectListWorker.toString()})()`;
|
|
156
|
+
|
|
157
|
+
// Windows has no trusted pinned-cwd launch. Keep its existing read-only
|
|
158
|
+
// listing behavior; transient ancestor replacement remains outside this POSIX fix.
|
|
159
|
+
async function listProjectFilesByPath(snapshot) {
|
|
160
|
+
let directory;
|
|
161
|
+
try {
|
|
162
|
+
directory = await fs.opendir(snapshot.absolute);
|
|
163
|
+
const entries = [];
|
|
164
|
+
let scanned = 0, truncated = false;
|
|
165
|
+
for await (const item of directory) {
|
|
166
|
+
if (++scanned > PROJECT_FILE_LIMITS.scannedEntries) { truncated = true; break; }
|
|
167
|
+
if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
|
|
168
|
+
if (!item.isDirectory() && !item.isFile()) continue;
|
|
169
|
+
let stat;
|
|
170
|
+
try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
|
|
171
|
+
catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
|
|
172
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
|
|
173
|
+
entries.push({ name: item.name, type: stat.isDirectory() ? 'directory' : 'file',
|
|
174
|
+
...(stat.isFile() ? { size: Number(stat.size) } : {}) });
|
|
175
|
+
}
|
|
176
|
+
directory = null;
|
|
177
|
+
return { entries, truncated };
|
|
178
|
+
} finally { await directory?.close().catch(() => {}); }
|
|
179
|
+
}
|
|
180
|
+
|
|
96
181
|
async function inspectPath(root, parts, directory, rootIdentity) {
|
|
97
182
|
assertBoundRoot(rootIdentity);
|
|
98
183
|
const base = await fs.realpath(root), chain = [];
|
|
@@ -149,34 +234,31 @@ async function openChecked(snapshot, directory) {
|
|
|
149
234
|
|
|
150
235
|
/** Single-level listing only. No project module or executable is loaded. */
|
|
151
236
|
export async function listProjectFiles(root, relative = '', { rootIdentity } = {}) {
|
|
152
|
-
let handle,
|
|
237
|
+
let handle, ownedIdentity;
|
|
153
238
|
try {
|
|
154
|
-
const parts = relativePath(relative, true)
|
|
239
|
+
const parts = relativePath(relative, true);
|
|
240
|
+
ownedIdentity = rootIdentity ? null : captureSelectedCwdIdentity(root);
|
|
241
|
+
const identity = rootIdentity || ownedIdentity;
|
|
242
|
+
const snapshot = await inspectPath(root, parts, true, identity);
|
|
155
243
|
handle = await openChecked(snapshot, true);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
|
|
165
|
-
catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
|
|
166
|
-
if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
|
|
167
|
-
const entry = { name: item.name, path: relative ? relative + '/' + item.name : item.name,
|
|
168
|
-
type: stat.isDirectory() ? 'directory' : 'file' };
|
|
169
|
-
if (stat.isFile()) entry.size = Number(stat.size);
|
|
170
|
-
entries.push(entry);
|
|
244
|
+
let listing;
|
|
245
|
+
if (process.platform === 'win32') listing = await listProjectFilesByPath(snapshot);
|
|
246
|
+
else {
|
|
247
|
+
const expectedIdentity = { canonical: snapshot.absolute, identity: {
|
|
248
|
+
dev: snapshot.stat.dev.toString(), ino: snapshot.stat.ino.toString(),
|
|
249
|
+
birthtimeNs: snapshot.stat.birthtimeNs?.toString() || null } };
|
|
250
|
+
listing = await runPinnedDirectoryWorker(snapshot.absolute, expectedIdentity,
|
|
251
|
+
PROJECT_LIST_WORKER_SOURCE, { scannedEntries: PROJECT_FILE_LIMITS.scannedEntries });
|
|
171
252
|
}
|
|
172
|
-
|
|
253
|
+
const entries = listing.entries.map(item => ({ ...item,
|
|
254
|
+
path: relative ? relative + '/' + item.name : item.name }));
|
|
173
255
|
await verifyPath(snapshot, handle);
|
|
174
|
-
assertBoundRoot(
|
|
256
|
+
assertBoundRoot(identity, snapshot.chain[0].stat);
|
|
175
257
|
entries.sort((a, b) => a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.type === 'directory' ? -1 : 1);
|
|
176
|
-
|
|
177
|
-
|
|
258
|
+
return { path: relative, entries: entries.slice(0, PROJECT_FILE_LIMITS.entries),
|
|
259
|
+
truncated: listing.truncated || entries.length > PROJECT_FILE_LIMITS.entries };
|
|
178
260
|
} catch (error) { throw publicError(error); }
|
|
179
|
-
finally { await
|
|
261
|
+
finally { await handle?.close().catch(() => {}); ownedIdentity?.release(); }
|
|
180
262
|
}
|
|
181
263
|
|
|
182
264
|
async function readBytes(handle, length) {
|
package/lib/web/pty-sessions.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { detectBranch } from '../project-context.mjs';
|
|
|
17
17
|
import { tx } from '../db/schema.mjs';
|
|
18
18
|
import { spawnPinnedCwdPty } from '../process/pinned-cwd.mjs';
|
|
19
19
|
import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
20
|
+
import { assertCodexTerminalLaunch, CODEX_SESSION_ORIGIN_ENV } from '../core/sessions/codex-thread-root.mjs';
|
|
20
21
|
|
|
21
22
|
export function createPtySessions(deps) {
|
|
22
23
|
const {
|
|
@@ -60,13 +61,20 @@ export function createPtySessions(deps) {
|
|
|
60
61
|
const expectedCwdIdentity = input.cwdIdentity ||
|
|
61
62
|
(cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
|
|
62
63
|
captureSelectedCwdSnapshot(cwd);
|
|
64
|
+
const rootDb = connectWebProject(pctx);
|
|
65
|
+
let codexOrigin;
|
|
66
|
+
try {
|
|
67
|
+
codexOrigin = assertCodexTerminalLaunch(rootDb, input.binding, { command, root: pctx.root,
|
|
68
|
+
rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity, cwd, cwdIdentity: expectedCwdIdentity });
|
|
69
|
+
} finally { rootDb.close(); }
|
|
63
70
|
const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
|
|
64
71
|
const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
|
|
65
72
|
const env = childSessionEnv({
|
|
66
73
|
HCC_PEER: id,
|
|
67
74
|
HCC_ROOT: pctx.root,
|
|
68
75
|
HCC_DB: pctx.dbPath,
|
|
69
|
-
TERM: 'xterm-256color'
|
|
76
|
+
TERM: 'xterm-256color',
|
|
77
|
+
...(codexOrigin ? { [CODEX_SESSION_ORIGIN_ENV]: codexOrigin } : {})
|
|
70
78
|
}, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
|
|
71
79
|
const size = input.size || { cols: 100, rows: 30 };
|
|
72
80
|
const previousSession = sessions.get(key);
|
|
@@ -199,7 +207,12 @@ export function createPtySessions(deps) {
|
|
|
199
207
|
if (!['claude', 'codex', 'shell'].includes(kind)) {
|
|
200
208
|
throw new CliError('BAD_REQUEST', `Unsupported session kind: ${kind}`);
|
|
201
209
|
}
|
|
202
|
-
if (input.command || input.binding)
|
|
210
|
+
if (input.command || input.binding) {
|
|
211
|
+
if (kind === 'codex' && input.mode && input.mode !== 'new') {
|
|
212
|
+
throw new CliError('BAD_REQUEST', 'Codex history mode cannot be combined with a custom command or binding');
|
|
213
|
+
}
|
|
214
|
+
return { ...input, kind, projectCtx: pctx };
|
|
215
|
+
}
|
|
203
216
|
|
|
204
217
|
const opts = webSessionBuildOptions(input);
|
|
205
218
|
if (kind === 'shell' && hasResumeOpts(opts)) {
|
|
@@ -13,7 +13,12 @@ export function createSessionSerialize({
|
|
|
13
13
|
localClientObservation = () => null
|
|
14
14
|
}) {
|
|
15
15
|
function sessionKey(projectCtx, id) {
|
|
16
|
-
|
|
16
|
+
const owner = projectCtx.rootIdentity || projectCtx.initialRootIdentity;
|
|
17
|
+
const identity = owner?.identity;
|
|
18
|
+
const binding = identity
|
|
19
|
+
? `${owner.canonical}\0${identity.dev}\0${identity.ino}\0${identity.birthtimeNs}`
|
|
20
|
+
: projectCtx.root;
|
|
21
|
+
return `${binding}\0${projectCtx.dbPath || ''}\0${id}`;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
function sessionIdentityMatches(session, projectCtx) {
|
package/lib/web/startup.mjs
CHANGED
|
@@ -373,6 +373,8 @@ async function startWebBackgroundLocked(ctx, args, opts) {
|
|
|
373
373
|
const childEnv = {
|
|
374
374
|
...process.env,
|
|
375
375
|
[WEB_CHILD_ENV]: '1',
|
|
376
|
+
HCC_PINNED_ROOT_IDENTITY: JSON.stringify({ canonical: expectedRootIdentity.canonical,
|
|
377
|
+
identity: expectedRootIdentity.identity }),
|
|
376
378
|
HCC_ROOT: boundRoot,
|
|
377
379
|
HCC_DB: boundCtx.dbPath
|
|
378
380
|
};
|
|
@@ -404,7 +406,7 @@ async function waitForStartedRuntime(ctx, child, logFile) {
|
|
|
404
406
|
const deadline = Date.now() + WEB_RUNTIME_START_TIMEOUT_MS;
|
|
405
407
|
while (Date.now() < deadline) {
|
|
406
408
|
const runtime = await readHealthyGlobalRuntime();
|
|
407
|
-
if (runtime) return runtime;
|
|
409
|
+
if (runtime && runtime.pid === child.pid && runtime.root === ctx.root && runtime.db === ctx.dbPath) return runtime;
|
|
408
410
|
if (exitInfo) {
|
|
409
411
|
const detail = redactedLogText(tailFile(logFile));
|
|
410
412
|
throw new CliError('RUNTIME_START_FAILED',
|
|
@@ -12,8 +12,11 @@ import { CliError } from '../shared/errors.mjs';
|
|
|
12
12
|
import { tx } from '../db/schema.mjs';
|
|
13
13
|
import { required } from '../cli-args.mjs';
|
|
14
14
|
import { readClockGraceUntil } from '../shared/clock-grace.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
16
|
+
import { assertPinnedSessionLaunchAllowed } from '../process/pinned-cwd.mjs';
|
|
17
|
+
import { prepareTmuxCwdHandoff } from '../process/tmux-cwd-handoff.mjs';
|
|
16
18
|
import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
19
|
+
import { assertCodexTerminalLaunch, CODEX_SESSION_ORIGIN_ENV } from '../core/sessions/codex-thread-root.mjs';
|
|
17
20
|
import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
|
|
18
21
|
import { isDshHookBinding } from '../core/peers/bindings.mjs';
|
|
19
22
|
import { conditionalTmuxKill, conditionalTmuxRename, prepareTmuxRestartBinding, rollbackTmuxRestartBinding, validateTmuxDestructiveEvidence } from '../core/peers/tmux-safety.mjs';
|
|
@@ -25,8 +28,8 @@ import { adoptionPaused } from './adoption-state.mjs';
|
|
|
25
28
|
import { ensureTmuxAvailable, runTmux, tmuxCapturePane, tmuxEnvironmentArgs, tmuxHasSession, tmuxKillSession, tmuxLaunchFingerprint, tmuxListSessionNames, tmuxManagedSessionName, tmuxManagedSessionNameMatches, tmuxPaneInfo, tmuxProviderState, tmuxSendLiteral, tmuxSessionEnvironmentValue, tmuxSessionHasClients } from '../tmux.mjs';
|
|
26
29
|
|
|
27
30
|
// tmux returns after creating a pane, not after its command has entered cwd.
|
|
28
|
-
//
|
|
29
|
-
//
|
|
31
|
+
// The broker holds the captured FD until the pane confirms fchdir, or the
|
|
32
|
+
// bounded handoff fails without granting access to the selected directory.
|
|
30
33
|
export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPaneInfo, intervalMs = 100 } = {}) {
|
|
31
34
|
let timer;
|
|
32
35
|
let released = false;
|
|
@@ -39,11 +42,13 @@ export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPa
|
|
|
39
42
|
function check() {
|
|
40
43
|
if (released) return true;
|
|
41
44
|
if (binding.acknowledged()) { release(); return true; }
|
|
45
|
+
if (binding.failed?.()) { release(); return true; }
|
|
42
46
|
try {
|
|
43
47
|
if (paneInfo(paneTarget).dead) { release(); return true; }
|
|
44
48
|
} catch {
|
|
45
49
|
// Even a missing pane/server does not prove its child process exited:
|
|
46
|
-
// a tmux crash can leave an orphan
|
|
50
|
+
// a tmux crash can leave an orphan client alive. The broker timeout
|
|
51
|
+
// still bounds this pending handoff without granting an unknown PID.
|
|
47
52
|
}
|
|
48
53
|
return false;
|
|
49
54
|
}
|
|
@@ -54,15 +59,15 @@ export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPa
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export function runPinnedTmuxNewSession({ cwd, expectedIdentity, shell, command, env, sessionName, tmuxEnv },
|
|
57
|
-
{ run = runTmux, paneInfo = tmuxPaneInfo,
|
|
58
|
-
const binding =
|
|
59
|
-
env, expectedIdentity, acknowledge: true, ...(bootstrapCwd ? { bootstrapCwd } : {})
|
|
60
|
-
});
|
|
62
|
+
{ run = runTmux, paneInfo = tmuxPaneInfo, prepare = prepareTmuxCwdHandoff, onCreated } = {}) {
|
|
63
|
+
const binding = prepare({ cwd, expectedIdentity, shell, command, env });
|
|
61
64
|
let args;
|
|
62
65
|
try {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
+
// -P atomically reports the pane's original PID. The native broker
|
|
67
|
+
// authorizes exactly that PID before sending the captured directory FD;
|
|
68
|
+
// tmux and its shell never resolve the user's selected pathname.
|
|
69
|
+
args = ['new-session', '-d', '-P', '-F', '#{pane_id}|#{pane_pid}|#{session_id}|#{session_created}',
|
|
70
|
+
'-s', sessionName, '-c', binding.cwd,
|
|
66
71
|
...tmuxEnvironmentArgs(tmuxEnv), '--',
|
|
67
72
|
...isolatedEnvCommandArgs(binding.env), binding.command, ...binding.args];
|
|
68
73
|
} catch (error) {
|
|
@@ -70,14 +75,80 @@ export function runPinnedTmuxNewSession({ cwd, expectedIdentity, shell, command,
|
|
|
70
75
|
throw error;
|
|
71
76
|
}
|
|
72
77
|
try {
|
|
73
|
-
run(args);
|
|
78
|
+
const receipt = String(run(args) || '').trim();
|
|
79
|
+
const fields = receipt.split('|');
|
|
80
|
+
if (fields.length !== 4 || !/^%\d+$/.test(fields[0]) ||
|
|
81
|
+
!/^[1-9]\d*$/.test(fields[1]) || !/^\$\d+$/.test(fields[2]) ||
|
|
82
|
+
!/^[1-9]\d*$/.test(fields[3]) || !Number.isSafeInteger(Number(fields[1]))) {
|
|
83
|
+
throw new CliError('TMUX_ERROR', 'tmux did not report an unambiguous new pane identity');
|
|
84
|
+
}
|
|
85
|
+
// The caller must know there is a candidate session to roll back even if
|
|
86
|
+
// authorization or the subsequent bind acknowledgement fails.
|
|
87
|
+
onCreated?.(fields);
|
|
88
|
+
binding.authorize(Number(fields[1]));
|
|
89
|
+
// A tmux pane receipt proves only creation. Do not publish/attach it or
|
|
90
|
+
// tear down a parked old session until the native client reports fchdir.
|
|
91
|
+
binding.waitBoundSync();
|
|
92
|
+
return monitorPinnedTmuxLaunch(binding, fields[0], { paneInfo });
|
|
74
93
|
} catch (error) {
|
|
75
|
-
// A timed-out tmux command may have created
|
|
76
|
-
//
|
|
77
|
-
|
|
94
|
+
// A timed-out tmux command may have created a pane, but without the
|
|
95
|
+
// immutable receipt no PID is authorized. Closing the broker leaves any
|
|
96
|
+
// orphan client unable to enter the selected directory.
|
|
97
|
+
binding.release();
|
|
78
98
|
throw error;
|
|
79
99
|
}
|
|
80
|
-
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A missing tmux pane is only evidence about tmux. The captured process
|
|
103
|
+
// fingerprint decides whether the original owner exited or became orphaned.
|
|
104
|
+
export function pollTmuxSessionExit(session, state, {
|
|
105
|
+
paneInfo = tmuxPaneInfo,
|
|
106
|
+
inspectProcess = inspectProcessIdentity,
|
|
107
|
+
detach
|
|
108
|
+
} = {}) {
|
|
109
|
+
const stored = session.adoptionIdentity?.process_identity;
|
|
110
|
+
function originalStatus() {
|
|
111
|
+
if (compareProcessIdentity(stored, stored) !== 'live') return 'detached';
|
|
112
|
+
let observation;
|
|
113
|
+
try { observation = inspectProcess(stored.pid); }
|
|
114
|
+
catch { return 'detached'; }
|
|
115
|
+
if (observation?.state === 'dead') return 'exited';
|
|
116
|
+
if (observation?.state !== 'live') return 'detached';
|
|
117
|
+
return compareProcessIdentity(stored, observation.identity) === 'dead' ? 'exited' : 'detached';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let result = 'unknown';
|
|
121
|
+
let paneReused = false;
|
|
122
|
+
try {
|
|
123
|
+
const fresh = paneInfo(session.pane);
|
|
124
|
+
if (!fresh || fresh.dead) {
|
|
125
|
+
result = originalStatus();
|
|
126
|
+
} else if ((fresh.pane && fresh.pane !== session.pane) ||
|
|
127
|
+
Number(fresh.pid) !== Number(stored?.pid)) {
|
|
128
|
+
paneReused = true;
|
|
129
|
+
result = originalStatus();
|
|
130
|
+
} else {
|
|
131
|
+
let observation;
|
|
132
|
+
try { observation = inspectProcess(stored.pid); }
|
|
133
|
+
catch { observation = null; }
|
|
134
|
+
if (observation?.state === 'dead') result = 'exited';
|
|
135
|
+
else if (observation?.state === 'live') {
|
|
136
|
+
const match = compareProcessIdentity(stored, observation.identity);
|
|
137
|
+
result = match === 'live' ? 'running' : match === 'dead' ? 'exited' : 'unknown';
|
|
138
|
+
}
|
|
139
|
+
paneReused = result === 'exited';
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const message = error?.code === 'TMUX_ERROR' ? String(error.message || '') : '';
|
|
143
|
+
if (/can't find pane|can't find session|no server running/i.test(message)) result = originalStatus();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (result === 'running' || result === 'unknown') {
|
|
147
|
+
state.deadCount = 0;
|
|
148
|
+
} else if (paneReused || ++state.deadCount >= 3) {
|
|
149
|
+
detach(session, result);
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
export function createTmuxSessions(deps) {
|
|
@@ -105,6 +176,8 @@ export function createTmuxSessions(deps) {
|
|
|
105
176
|
const adoptionSessionNames = deps.tmuxListSessionNames || tmuxListSessionNames;
|
|
106
177
|
const adoptionSessionEnvironment = deps.tmuxSessionEnvironmentValue || tmuxSessionEnvironmentValue;
|
|
107
178
|
const adoptionSessionMatches = deps.tmuxManagedSessionNameMatches || tmuxManagedSessionNameMatches;
|
|
179
|
+
const inspectExitProcess = deps.inspectProcessIdentity || inspectProcessIdentity;
|
|
180
|
+
const scheduleExitPoller = deps.scheduleTmuxExitPoller || setInterval;
|
|
108
181
|
|
|
109
182
|
function isAdoptionPaused(projectCtx, info, peer, existingDb = null) {
|
|
110
183
|
const db = existingDb || connectWebProject(projectCtx);
|
|
@@ -709,29 +782,12 @@ export function createTmuxSessions(deps) {
|
|
|
709
782
|
let rebindOldPlan = null;
|
|
710
783
|
let rebindOldBindingSubject = null;
|
|
711
784
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
session.exitPoller = setInterval(() => {
|
|
785
|
+
const exitState = { deadCount: 0 };
|
|
786
|
+
session.exitPoller = scheduleExitPoller(() => {
|
|
715
787
|
if (isStopping()) return;
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
deadCount++;
|
|
720
|
-
if (deadCount >= 3) detachTmuxSession(session, 'exited');
|
|
721
|
-
} else {
|
|
722
|
-
deadCount = 0;
|
|
723
|
-
}
|
|
724
|
-
} catch (err) {
|
|
725
|
-
// sess-03: only a definitive "pane/session is gone" error counts toward
|
|
726
|
-
// detach; a transient tmux failure (busy server, momentary hiccup) must
|
|
727
|
-
// not detach a live session after 3 attempts.
|
|
728
|
-
const message = err?.code === 'TMUX_ERROR' ? String(err.message || '') : '';
|
|
729
|
-
const definitivelyGone = /can't find pane|can't find session|no server running/i.test(message);
|
|
730
|
-
if (definitivelyGone) {
|
|
731
|
-
deadCount++;
|
|
732
|
-
if (deadCount >= 3) detachTmuxSession(session, 'exited');
|
|
733
|
-
}
|
|
734
|
-
}
|
|
788
|
+
pollTmuxSessionExit(session, exitState, {
|
|
789
|
+
paneInfo: adoptionPaneInfo, inspectProcess: inspectExitProcess, detach: detachTmuxSession
|
|
790
|
+
});
|
|
735
791
|
}, 3000);
|
|
736
792
|
|
|
737
793
|
let db = null;
|
|
@@ -909,7 +965,6 @@ export function createTmuxSessions(deps) {
|
|
|
909
965
|
for (const existing of sessions.values()) {
|
|
910
966
|
if (providerSessionBindingMatches(existing.binding, input.binding)) input.authorizeMutation?.(existing);
|
|
911
967
|
}
|
|
912
|
-
ensureTmuxAvailable({ autoInstall: false });
|
|
913
968
|
const pctx = input.projectCtx || ctx;
|
|
914
969
|
const kind = input.kind || 'shell';
|
|
915
970
|
const id = input.id || nextProjectSessionId(pctx, kind);
|
|
@@ -924,6 +979,13 @@ export function createTmuxSessions(deps) {
|
|
|
924
979
|
const expectedCwdIdentity = input.cwdIdentity ||
|
|
925
980
|
(cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
|
|
926
981
|
captureSelectedCwdSnapshot(cwd);
|
|
982
|
+
const historyDb = connectWebProject(pctx);
|
|
983
|
+
let codexOrigin;
|
|
984
|
+
try {
|
|
985
|
+
codexOrigin = assertCodexTerminalLaunch(historyDb, input.binding, { command, root: pctx.root,
|
|
986
|
+
rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity, cwd, cwdIdentity: expectedCwdIdentity });
|
|
987
|
+
} finally { historyDb.close(); }
|
|
988
|
+
ensureTmuxAvailable({ autoInstall: false });
|
|
927
989
|
const sessionName = tmuxManagedSessionName(pctx, id);
|
|
928
990
|
let paneTarget = `${sessionName}:0.0`;
|
|
929
991
|
const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
|
|
@@ -931,14 +993,15 @@ export function createTmuxSessions(deps) {
|
|
|
931
993
|
HCC_PEER: id,
|
|
932
994
|
HCC_ROOT: pctx.root,
|
|
933
995
|
HCC_DB: pctx.dbPath,
|
|
934
|
-
TERM: 'xterm-256color'
|
|
996
|
+
TERM: 'xterm-256color',
|
|
997
|
+
...(codexOrigin ? { [CODEX_SESSION_ORIGIN_ENV]: codexOrigin } : {})
|
|
935
998
|
}, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
|
|
936
999
|
env[LAUNCH_FINGERPRINT_ENV] = launchFingerprint({ command, cwd, env });
|
|
937
1000
|
let hasSession = tmuxHasSession(sessionName);
|
|
938
1001
|
const relaunchableProvider = isRelaunchableProviderSession(kind, command, input.binding || {});
|
|
939
1002
|
const oldTmuxTargetsForRebind = [];
|
|
940
1003
|
const parkedOldTmuxSessions = [];
|
|
941
|
-
let createdTmuxSession =
|
|
1004
|
+
let createdTmuxSession = null;
|
|
942
1005
|
let pendingOldSession = null;
|
|
943
1006
|
let pendingRestartAudit = null;
|
|
944
1007
|
|
|
@@ -954,29 +1017,59 @@ export function createTmuxSessions(deps) {
|
|
|
954
1017
|
if (tmuxHasSession(parked.originalName)) {
|
|
955
1018
|
quarantineName = `hcc-rollback-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
956
1019
|
try {
|
|
957
|
-
|
|
1020
|
+
if (!createdTmuxSession || createdTmuxSession.session !== parked.originalName) {
|
|
1021
|
+
throw new CliError('TMUX_CONDITIONAL_RENAME_MISMATCH',
|
|
1022
|
+
'Cannot prove the session occupying the original name is our failed candidate');
|
|
1023
|
+
}
|
|
1024
|
+
conditionalTmuxRename(runTmux, createdTmuxSession, quarantineName, { allowClients: true });
|
|
958
1025
|
} catch (err) {
|
|
959
|
-
failures.push(`could not quarantine failed replacement ${parked.originalName}: ${err?.message || err}`);
|
|
1026
|
+
failures.push(`could not prove and quarantine failed replacement ${parked.originalName}: ${err?.message || err}`);
|
|
960
1027
|
continue;
|
|
961
1028
|
}
|
|
962
1029
|
}
|
|
963
1030
|
try {
|
|
964
|
-
runTmux
|
|
1031
|
+
conditionalTmuxRename(runTmux, parked.authority, parked.originalName);
|
|
965
1032
|
} catch (err) {
|
|
966
1033
|
failures.push(`could not restore ${parked.parkedName} to ${parked.originalName}: ${err?.message || err}`);
|
|
967
1034
|
if (quarantineName && !tmuxHasSession(parked.originalName)) {
|
|
968
|
-
try {
|
|
1035
|
+
try {
|
|
1036
|
+
conditionalTmuxRename(runTmux,
|
|
1037
|
+
{ ...createdTmuxSession, session: quarantineName }, parked.originalName,
|
|
1038
|
+
{ allowClients: true });
|
|
1039
|
+
} catch {}
|
|
969
1040
|
}
|
|
970
1041
|
continue;
|
|
971
1042
|
}
|
|
1043
|
+
if (parked.bindingPreparation?.restored) {
|
|
1044
|
+
let bindingRolledBack = false;
|
|
1045
|
+
let bindingRollbackError = null;
|
|
1046
|
+
try {
|
|
1047
|
+
const rollbackDb = connectWebProject(pctx);
|
|
1048
|
+
try {
|
|
1049
|
+
bindingRolledBack = rollbackTmuxRestartBinding(rollbackDb, parked.bindingPreparation);
|
|
1050
|
+
} finally { rollbackDb.close(); }
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
bindingRollbackError = err?.message || String(err);
|
|
1053
|
+
}
|
|
1054
|
+
if (!bindingRolledBack) {
|
|
1055
|
+
const reason = bindingRollbackError || 'binding changed';
|
|
1056
|
+
failures.push(`could not roll back parked binding ${parked.originalName}: ${reason}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
972
1059
|
if (quarantineName) {
|
|
973
|
-
try {
|
|
1060
|
+
try {
|
|
1061
|
+
conditionalTmuxKill(runTmux,
|
|
1062
|
+
{ ...createdTmuxSession, session: quarantineName }, { allowClients: true });
|
|
1063
|
+
} catch { quarantined.push(quarantineName); }
|
|
974
1064
|
}
|
|
975
1065
|
}
|
|
976
1066
|
return { failures, quarantined };
|
|
977
1067
|
}
|
|
978
1068
|
|
|
979
1069
|
function restartExistingTmuxSession(reason) {
|
|
1070
|
+
// A held rollout may still attach to a live pane, but must not park it
|
|
1071
|
+
// before discovering that a replacement launch is disallowed.
|
|
1072
|
+
assertPinnedSessionLaunchAllowed({ env });
|
|
980
1073
|
const existing = getSession(pctx, id);
|
|
981
1074
|
const hasWebClients = hasOpenClients(existing);
|
|
982
1075
|
const hasTmuxClients = tmuxSessionHasClients(sessionName);
|
|
@@ -1056,7 +1149,8 @@ export function createTmuxSessions(deps) {
|
|
|
1056
1149
|
binding_rollback_failed: !bindingRolledBack
|
|
1057
1150
|
});
|
|
1058
1151
|
}
|
|
1059
|
-
parkedOldTmuxSessions.push({ oldTarget, originalName: sessionName, parkedName
|
|
1152
|
+
parkedOldTmuxSessions.push({ oldTarget, originalName: sessionName, parkedName,
|
|
1153
|
+
authority: { ...storedAuthority, session: parkedName }, bindingPreparation });
|
|
1060
1154
|
if (oldTarget) oldTmuxTargetsForRebind.push({ oldPeer: id, oldTarget, allowedSessionName: parkedName });
|
|
1061
1155
|
if (existing) {
|
|
1062
1156
|
oldTarget = oldTarget || existing.pane || null;
|
|
@@ -1112,8 +1206,17 @@ export function createTmuxSessions(deps) {
|
|
|
1112
1206
|
};
|
|
1113
1207
|
if (relaunchableProvider) tmuxEnv[PROVIDER_STATE_ENV] = 'starting';
|
|
1114
1208
|
runPinnedTmuxNewSession({ cwd, expectedIdentity: expectedCwdIdentity,
|
|
1115
|
-
shell, command, env, sessionName, tmuxEnv }
|
|
1116
|
-
|
|
1209
|
+
shell, command, env, sessionName, tmuxEnv }, {
|
|
1210
|
+
onCreated(fields) {
|
|
1211
|
+
createdTmuxSession = {
|
|
1212
|
+
session: sessionName,
|
|
1213
|
+
session_created: fields[3],
|
|
1214
|
+
session_id: fields[2],
|
|
1215
|
+
pane: fields[0],
|
|
1216
|
+
process_identity: { pid: Number(fields[1]) }
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1117
1220
|
}
|
|
1118
1221
|
|
|
1119
1222
|
pane = tmuxPaneInfo(paneTarget).pane;
|
|
@@ -1152,7 +1255,7 @@ export function createTmuxSessions(deps) {
|
|
|
1152
1255
|
});
|
|
1153
1256
|
} catch (err) {
|
|
1154
1257
|
if (createdTmuxSession) {
|
|
1155
|
-
try {
|
|
1258
|
+
try { conditionalTmuxKill(runTmux, createdTmuxSession, { allowClients: true }); } catch {}
|
|
1156
1259
|
}
|
|
1157
1260
|
const rollback = restoreParkedOldTmuxSessions();
|
|
1158
1261
|
// attachTmuxSession removes a failed candidate from the map. Restore the
|
|
@@ -8,6 +8,9 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
|
|
|
8
8
|
(() => {
|
|
9
9
|
if (window.hccInteractions) return;
|
|
10
10
|
const mcpForms = createForms(), mcpUrls = createUrls();
|
|
11
|
+
if (typeof document !== 'undefined') document.addEventListener?.('submit', event => {
|
|
12
|
+
if (event.target?.matches?.('.hcc-interaction-form')) event.preventDefault();
|
|
13
|
+
});
|
|
11
14
|
const drafts = new Map(), expanded = new Map();
|
|
12
15
|
let focus = null;
|
|
13
16
|
function remember(container) {
|
|
@@ -107,7 +110,7 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
|
|
|
107
110
|
try { ({ fields } = mcpForms.describe(request.params)); }
|
|
108
111
|
catch (_) { return '<p role="alert">' + esc(text('This MCP request mode or schema is unsupported. Decline or cancel this request.', '暂不支持此 MCP 请求模式或表单结构,请拒绝或取消请求。')) + '</p>'; }
|
|
109
112
|
if (!fields.length) return '';
|
|
110
|
-
const attr = suffix => ' data-interaction-field="' + esc(key + ':mcp:' + suffix) + '"';
|
|
113
|
+
const attr = suffix => ' name="' + esc(key + ':mcp:' + suffix) + '" data-interaction-field="' + esc(key + ':mcp:' + suffix) + '"';
|
|
111
114
|
const select = (i, choices, value) => '<select data-mcp-field="' + i + '"' + attr(i) + '><option value="">' + esc(text('Choose a value', '请选择')) + '</option>'
|
|
112
115
|
+ choices.map((choice, n) => '<option value="' + n + '"' + (value === choice.value ? ' selected' : '') + '>' + esc(choice.label) + '</option>').join('') + '</select>';
|
|
113
116
|
return '<p>' + esc(text('Required fields are marked *. Optional fields are sent only when selected. Responses are not saved in browser storage.', '带 * 的为必填项;可选项仅在勾选填写后发送。答复不会保存到浏览器存储。')) + '</p>'
|
|
@@ -140,8 +143,8 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
|
|
|
140
143
|
+ (destination.loopback ? '<p>' + esc(text('This address belongs to the executor computer. A browser on another device may not reach it; open this task on the executor computer to authorize.', '此地址位于执行器所在电脑。其他设备的浏览器可能无法访问,请在执行器所在电脑打开本任务完成授权。')) + '</p>' : '');
|
|
141
144
|
}
|
|
142
145
|
if (request.kind === 'mcp') return mcpForm(request, key, esc, text);
|
|
143
|
-
const field = suffix => ' data-interaction-field="' + esc(key + ':' + suffix) + '"';
|
|
144
|
-
if (request.kind === 'userInput') return (request.params?.questions || []).map((q, i) => {
|
|
146
|
+
const field = suffix => ' name="' + esc(key + ':' + suffix) + '" data-interaction-field="' + esc(key + ':' + suffix) + '"';
|
|
147
|
+
if (request.kind === 'userInput') return '<form class="hcc-interaction-form" autocomplete="off">' + (request.params?.questions || []).map((q, i) => {
|
|
145
148
|
const label = '<label>' + esc(q.header || '') + '<p>' + esc(q.question || '') + '</p>';
|
|
146
149
|
const free = '<input data-answer="' + i + '" type="' + (q.isSecret ? 'password' : 'text') + '" maxlength="8192" autocomplete="off"' + field('answer:' + i) + '>';
|
|
147
150
|
if (!q.options?.length) return label + free + '</label>';
|
|
@@ -149,7 +152,7 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
|
|
|
149
152
|
+ q.options.map((o, n) => '<option value="' + n + '">' + esc(o.label + (o.description ? ' — ' + o.description : '')) + '</option>').join('')
|
|
150
153
|
+ (q.isOther ? '<option value="other">' + esc(text('Other (enter below)', '其他(在下方输入)')) + '</option>' : '') + '</select>'
|
|
151
154
|
+ (q.isOther ? free : '') + '</label>';
|
|
152
|
-
}).join('');
|
|
155
|
+
}).join('') + '</form>';
|
|
153
156
|
if (request.kind !== 'permissions') return '';
|
|
154
157
|
const p = request.params?.permissions || {}, fs = p.fileSystem || {};
|
|
155
158
|
const choice = (name, index, label) => '<label><input type="checkbox" data-permission="' + name + '" data-index="' + index + '"' + field(name + ':' + index) + '> ' + esc(label) + '</label>';
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|