@coda-console/claude 0.0.0 → 1.4.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/agent/auth.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
4
+
5
+ // Variables that make the Claude CLI use something other than the user's normal login.
6
+ export const authOverrides = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS',
7
+ 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CODE_OAUTH_SCOPES',
8
+ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'];
9
+
10
+ // Read only in the SDK driver, never in the product service or host journal.
11
+ export async function sdkEnvironment(env = process.env) {
12
+ const path = env.CODA_CLAUDE_OAUTH_TOKEN_FILE;
13
+ // Let the SDK use the user's normal login or provider configuration. A token
14
+ // file is an explicit override, never a dependency on a maintainer's machine.
15
+ if (path === undefined) return { ...env };
16
+ const fail = reason => new Error(`Claude SDK setup token: ${reason}`);
17
+ if (!isAbsolute(path)) throw fail('file path must be absolute');
18
+ let file;
19
+ try {
20
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
21
+ const info = await file.stat();
22
+ if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o7777) !== 0o600)
23
+ throw fail('file must be a regular file owned by the current user with mode 0600');
24
+ const buffer = Buffer.alloc(8193);
25
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
26
+ const token = buffer.subarray(0, bytesRead).toString('utf8').replace(/\r?\n$/, '');
27
+ if (bytesRead > 8192 || !/^sk-ant-oat01-[A-Za-z0-9_-]+$/.test(token))
28
+ throw fail('file must contain one subscription OAuth token');
29
+ const result = { ...env };
30
+ for (const key of authOverrides) delete result[key];
31
+ result.CLAUDE_CODE_OAUTH_TOKEN = token;
32
+ return result;
33
+ } catch (error) {
34
+ if (error.message.startsWith('Claude SDK setup token:')) throw error;
35
+ throw fail('cannot read CODA_CLAUDE_OAUTH_TOKEN_FILE; choose a readable token file or remove this override to use your normal login');
36
+ } finally { await file?.close(); }
37
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // The Claude agent as Coda starts it (the plugin's `contributes.agents`): Claude Code through the plugin's own Claude Agent SDK
3
+ // (its `@anthropic-ai/claude-agent-sdk` dependency), speaking ACP and Coda's waits over standard input and output
4
+ // (session.mjs). It ends when Coda's side of the pipe closes, when it is told to, or when its session ends.
5
+ import { Readable, Writable } from 'node:stream';
6
+ import { ndJsonStream } from '@agentclientprotocol/sdk';
7
+ import { ClaudeSession } from './session.mjs';
8
+
9
+ if (process.argv.includes('--help')) {
10
+ process.stdout.write('coda-claude: Claude Code as an ACP agent for Coda over standard input and output; no terminal UI.\n');
11
+ } else {
12
+ const sdk = await import('@anthropic-ai/claude-agent-sdk');
13
+ for (const name of ['query', 'getSessionInfo', 'renameSession']) {
14
+ if (typeof sdk[name] !== 'function') throw new Error(`Claude SDK 缺少所需接口:${name}`);
15
+ }
16
+ let stopping = false;
17
+ const stop = async (code = 0) => {
18
+ if (stopping) return;
19
+ stopping = true;
20
+ // A Claude process must not outlive this one even if closing the SDK hangs; the session host has a shorter grace of its own.
21
+ setTimeout(() => process.exit(code || 1), 5000).unref();
22
+ try { await session.close(); } finally { process.stdout.write('', () => process.exit(code)); }
23
+ };
24
+ const session = new ClaudeSession({ sdk, onEnded: () => void stop(1) });
25
+ session.connect(ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin))).closed.then(() => stop());
26
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.once(signal, () => void stop());
27
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
+
4
+ // The quota of the account Claude sessions run as, read from what the sessions themselves report. A setup token has no
5
+ // profile scope, so /usage cannot be asked with it; but the CLI sends a `rate_limit_event` whenever a window's rounded
6
+ // percentage or reset time moves, with the fraction of the window used and when it resets (SDKRateLimitInfo). Each
7
+ // session's entry (session.mjs) writes it to `limits.json` in this plugin's state; the「Claude」panel reads it
8
+ // (scripts/panel.mjs, docs/plugins.md「内置插件:Claude」).
9
+ // `unifiedWindows`, an internal field of newer CLIs, carries every window each time; the top-level fields, which every
10
+ // CLI sends, only the one that limits now.
11
+ const labels = { five_hour: '5 小时', seven_day: '7 天', seven_day_opus: 'Opus · 7 天', seven_day_sonnet: 'Sonnet · 7 天', seven_day_overage_included: 'Fable · 7 天' };
12
+ const used = fraction => Math.round(fraction * 100);
13
+
14
+ function reported(info) {
15
+ const windows = info?.unifiedWindows && typeof info.unifiedWindows === 'object' ? { ...info.unifiedWindows } : {};
16
+ if (info?.rateLimitType && !windows[info.rateLimitType]) windows[info.rateLimitType] = info;
17
+ return Object.entries(windows);
18
+ }
19
+
20
+ // The latest each window was reported at, kept across restarts: events come only as a window moves, so a backend that
21
+ // started just now would otherwise show nothing until one does. A window past its reset is no longer known, not full:
22
+ // what was used since is not seen from here.
23
+ // The account they are of is kept beside them, as a hash of its email (`account`): what Coda's group view compares across
24
+ // machines, the email itself staying here. The file is the account the latest session started as (session.mjs); none
25
+ // where Claude runs on an API key or a cloud provider, or the login does not say.
26
+ export const accountHash = email => createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 32);
27
+ export class ClaudeSessionLimits {
28
+ constructor({ path = null, now = Date.now } = {}) {
29
+ this.path = path; this.now = now; this.windows = new Map(); this.account = null; this.stamp = null;
30
+ this.read();
31
+ }
32
+ read() {
33
+ if (!this.path) return;
34
+ let stamp;
35
+ try { stamp = statSync(this.path).mtimeMs; } catch { return; }
36
+ if (stamp === this.stamp) return;
37
+ this.stamp = stamp; this.windows = new Map(); this.account = null;
38
+ try {
39
+ const saved = JSON.parse(readFileSync(this.path, 'utf8'));
40
+ for (const [key, w] of Object.entries(saved.windows)) this.take(key, w?.utilization, w?.resetsAt);
41
+ if (typeof saved.account === 'string' && /^[0-9a-f]{32}$/.test(saved.account)) this.account = saved.account;
42
+ }
43
+ catch { /* Not readable now: read again when it next changes. */ this.stamp = null; }
44
+ }
45
+ known() { this.read(); return this.windows.size > 0; }
46
+ take(key, utilization, resetsAt) {
47
+ if (!Object.hasOwn(labels, key) || !(Number.isFinite(utilization) && utilization >= 0) || !(Number.isSafeInteger(resetsAt) && resetsAt > 0)) return false;
48
+ const was = this.windows.get(key);
49
+ if (was?.resetsAt === resetsAt && used(was.utilization) === used(utilization)) return false;
50
+ this.windows.set(key, { utilization, resetsAt });
51
+ return true;
52
+ }
53
+ // The account a session started as, a hash or null when it is not known: from then on the file is that one's. Another
54
+ // than before starts the windows afresh, as another account's say nothing of it; an unknown one names none.
55
+ startedAs(account) {
56
+ if (account === this.account) return;
57
+ this.windows = new Map(); this.account = account;
58
+ this.save();
59
+ }
60
+ // What a session reports, as of the account it started as: one that started as another (still running from before a
61
+ // switch) says nothing of the account the file is now.
62
+ observe(info, account = null) {
63
+ if (account !== this.account) return;
64
+ let changed = false;
65
+ for (const [key, w] of reported(info)) changed = this.take(key, w?.utilization, w?.resetsAt) || changed;
66
+ if (changed) this.save();
67
+ }
68
+ save() {
69
+ if (!this.path) return;
70
+ try {
71
+ // Every Claude session writes it: a temporary file of each writer's own.
72
+ const temp = `${this.path}.${process.pid}.tmp`;
73
+ writeFileSync(temp, JSON.stringify({ version: 1, windows: Object.fromEntries(this.windows), ...(this.account ? { account: this.account } : {}) }), { mode: 0o600 });
74
+ renameSync(temp, this.path);
75
+ } catch { /* Not written: the next window that moves writes again. */ }
76
+ }
77
+ // The windows still to reset, in a fixed order: what is left of each and when it resets. None when all have passed.
78
+ quota() {
79
+ this.read();
80
+ const now = this.now();
81
+ return Object.keys(labels).flatMap(key => {
82
+ const w = this.windows.get(key);
83
+ return w && w.resetsAt * 1000 > now ? [{ id: key, label: labels[key], remainingPercent: Math.max(0, 100 - used(w.utilization)),
84
+ resetsAt: new Date(w.resetsAt * 1000).toISOString() }] : [];
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,41 @@
1
+ // Claude's settings as ACP config options. A model is named by the concrete ID it runs as, never by an alias: each row the
2
+ // SDK lists is its resolved wire ID with the context variant it selects (`[1m]`), or its own value when it resolves to
3
+ // none, and rows that come to the same ID are one model. What the row is called is kept, the ID beside it when the name
4
+ // does not say it.
5
+ export const MODES = [['default', '人工审批'], ['acceptEdits', '自动接受编辑'], ['plan', '规划'], ['dontAsk', '未预先允许则拒绝'], ['auto', '自动审批'],
6
+ ['bypassPermissions', '跳过所有审批']].map(([id, name]) => ({ id, name }));
7
+
8
+ export function claudeModels(rows = []) {
9
+ const models = new Map();
10
+ for (const row of rows) {
11
+ if (typeof row?.value !== 'string' || !row.value) continue;
12
+ const variant = row.value.match(/\[[^\]]+\]$/)?.[0] ?? '', resolved = row.resolvedModel?.trim();
13
+ const id = resolved ? resolved + (resolved.endsWith(variant) ? '' : variant) : row.value;
14
+ const label = row.displayName || row.value, name = label.includes(id) ? label : `${label} (${id})`;
15
+ const known = models.get(id);
16
+ // The SDK's `default` row is the model it would pick: the row naming that model says what it is.
17
+ if (!known) models.set(id, { id, name, efforts: row.supportedEffortLevels ?? [], from: row.value });
18
+ else if (known.from === 'default') Object.assign(known, { name, from: row.value });
19
+ }
20
+ return [...models.values()];
21
+ }
22
+
23
+ // The model a native init reports: one of the list, keeping the context variant chosen when its base is the one reported.
24
+ export function reportedModel(models, current, model) {
25
+ if (typeof model !== 'string' || !model) return current;
26
+ if (current === model || current?.replace(/\[[^\]]+\]$/, '') === model) return current;
27
+ return models.find(m => m.id === model)?.id ?? model;
28
+ }
29
+
30
+ const select = (id, name, category, currentValue, choices) => ({ id, name, category, type: 'select', currentValue,
31
+ options: choices.map(c => ({ value: c.id, name: c.name, ...(c._meta ? { _meta: c._meta } : {}) })) });
32
+ // The effort that leaves it to the model, marked as the agent's own default (docs/agent-protocol.md): what Coda goes back to
33
+ // when the session's effort is taken off.
34
+ export const MODEL_EFFORT = 'default';
35
+ // `current`: { mode, model, effort }. An effort not chosen is null: the model's own default.
36
+ export function configOptions(models, current) {
37
+ const efforts = models.find(m => m.id === current.model)?.efforts ?? [];
38
+ return [select('mode', '审批模式', 'mode', current.mode, MODES), select('model', '模型', 'model', current.model, models),
39
+ ...(efforts.length ? [select('effort', '推理强度', 'thought_level', efforts.includes(current.effort) ? current.effort : MODEL_EFFORT,
40
+ [{ id: MODEL_EFFORT, name: '模型默认', _meta: { coda: { default: true } } }, ...efforts.map(id => ({ id, name: id }))])] : [])];
41
+ }
@@ -0,0 +1,68 @@
1
+ // A tool Claude asks to use, as an ACP permission request: allow once, each rule Claude suggests to stop asking (its whole
2
+ // sentence the option's name, the rule kept here), and deny. Why it asks is the tool call's content. Only an option offered
3
+ // can change Claude's permissions.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { homedir } from 'node:os';
6
+
7
+ const deny = { behavior: 'deny', message: '用户未批准此操作' };
8
+
9
+ export function permissionRequest({ sessionId, toolName, input, context = {} }) {
10
+ // Identical suggestions would be two options that do the same thing.
11
+ const suggestions = [...new Map((context.suggestions ?? []).map(change => [JSON.stringify(change), change])).values()];
12
+ const options = [{ optionId: 'allow-once', name: '仅允许本次', kind: 'allow_once' },
13
+ ...suggestions.map((change, i) => ({ optionId: `suggestion-${i}`, name: describeChange(change, input), kind: 'allow_always' })),
14
+ { optionId: 'deny', name: '拒绝', kind: 'reject_once' }];
15
+ const params = { sessionId, options, toolCall: { toolCallId: context.toolUseID ?? randomUUID(), title: toolName, rawInput: input, status: 'pending',
16
+ ...(context.decisionReason ? { content: [{ type: 'content', content: { type: 'text', text: context.decisionReason } }] } : {}) } };
17
+ const answer = response => {
18
+ const { outcome, optionId } = response?.outcome ?? {};
19
+ if (outcome !== 'selected') return deny;
20
+ if (optionId === 'allow-once') return { behavior: 'allow', updatedInput: input };
21
+ const change = suggestions[/^suggestion-(\d+)$/.exec(optionId ?? '')?.[1]];
22
+ return change ? { behavior: 'allow', updatedInput: input, updatedPermissions: [change] } : deny;
23
+ };
24
+ return { params, answer };
25
+ }
26
+
27
+ // Two suggestions for the same tool are different grants; the name says what each allows, and where it is kept.
28
+ const scopes = { session: '本会话', cliArg: '本进程', localSettings: '本项目 · 本机', projectSettings: '本项目 · 共享', userSettings: '所有项目' };
29
+ const verbs = { allow: '允许', deny: '拒绝', ask: '每次询问' };
30
+ const modes = { default: '默认', acceptEdits: '自动允许修改文件', bypassPermissions: '跳过所有审批', plan: '计划', dontAsk: '不询问' };
31
+ const readTools = new Set(['Read', 'Glob', 'Grep']), editTools = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
32
+ const shortPath = path => { const home = homedir(); return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; };
33
+
34
+ function describeRule({ toolName, ruleContent }, input, verb) {
35
+ if (ruleContent === undefined) return [`${verb}所有 ${toolName} 操作`];
36
+ if (toolName === 'Bash') {
37
+ if (ruleContent === input?.command) return [`${verb}这条完全相同的命令`];
38
+ if (ruleContent.endsWith(':*')) return [`${verb}以此开头的命令`, ruleContent.slice(0, -2)];
39
+ return [`${verb}命令`, ruleContent];
40
+ }
41
+ // Claude writes absolute paths in rules as `//abs/path`.
42
+ const path = shortPath(ruleContent.replace(/^\/\//, '/'));
43
+ if (readTools.has(toolName)) return [`${verb}读取`, path];
44
+ if (editTools.has(toolName)) return [`${verb}修改`, path];
45
+ if (toolName === 'WebFetch' && ruleContent.startsWith('domain:')) return [`${verb}访问`, ruleContent.slice(7)];
46
+ return [`${verb} ${toolName}`, ruleContent];
47
+ }
48
+
49
+ export function describeChange(change, input) {
50
+ const scope = scopes[change.destination] ?? change.destination ?? 'Agent 建议的范围', verb = verbs[change.behavior] ?? '允许';
51
+ const rules = change.rules ?? [], listed = rules.map(r => `${r.toolName}(${r.ruleContent ?? ''})`).join('、');
52
+ const directories = (change.directories ?? []).map(shortPath).join('、');
53
+ const [what, target] = (() => {
54
+ switch (change.type) {
55
+ case 'addRules': case 'replaceRules': {
56
+ const [added, target] = rules.length === 1 ? describeRule(rules[0], input, verb) : [`${verb} ${rules.length} 条规则`, listed];
57
+ // Replacing drops the scope's existing rules of this behavior.
58
+ return [change.type === 'replaceRules' ? `替换现有规则为:${added}` : added, target];
59
+ }
60
+ case 'removeRules': return [`移除${verb}规则`, listed];
61
+ case 'addDirectories': return ['允许访问目录', directories];
62
+ case 'removeDirectories': return ['移除目录', directories];
63
+ case 'setMode': return [`切换为「${modes[change.mode] ?? change.mode}」模式`];
64
+ default: return ['应用 Agent 建议的权限调整'];
65
+ }
66
+ })();
67
+ return `${what}${target ? ` ${target}` : ''}(${scope})`;
68
+ }
@@ -0,0 +1,53 @@
1
+ // The model service chosen in Coda (`claude.modelProvider`, Coda's docs/plugins.md「模型服务」): another plugin's service that
2
+ // speaks Anthropic Messages, from CODA_MODEL_PROVIDERS. The models its address lists (`<地址>/models`) join Claude's own, each
3
+ // named by the service and the model; a session on one runs Claude Code with the service as its API (`serviceSettings`), set
4
+ // for that session's process only.
5
+ import { readFileSync } from 'node:fs';
6
+ import { authOverrides } from './auth.mjs';
7
+
8
+ export const SETTING = 'claude.modelProvider';
9
+ const log = text => process.stderr.write(`[claude-agent] ${text}\n`);
10
+ const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
11
+ const LIST_TIMEOUT = 8000;
12
+
13
+ // The chosen service: its address, title and key (read from the file Coda keeps it in). Null when none is chosen, or the
14
+ // one chosen is not on this machine or speaks another API.
15
+ export function chosenService(env = process.env) {
16
+ const ref = parse(env.CODA_PLUGIN_SETTINGS)[SETTING];
17
+ if (!ref) return null;
18
+ const provided = parse(env.CODA_MODEL_PROVIDERS)[ref];
19
+ if (!provided?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return null; }
20
+ if (provided.api !== 'anthropic-messages') { log(`模型服务 ${ref} 讲 ${provided.api},Claude 只接 anthropic-messages 的`); return null; }
21
+ let key;
22
+ try { key = provided.keyFile ? readFileSync(provided.keyFile, 'utf8').trim() || undefined : undefined; }
23
+ catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
24
+ return { ref, title: provided.title || ref, url: provided.url, key };
25
+ }
26
+
27
+ // The models the service lists, asked as an Anthropic client asks (Coda's「检查可用模型」): `<服务>/<模型>`, named by the
28
+ // model and the service. They have no effort to choose.
29
+ export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
30
+ const at = `${url.replace(/\/+$/, '')}/models?limit=1000`;
31
+ const response = await fetcher(at, { headers: key ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
32
+ if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
33
+ const body = await response.json();
34
+ return (Array.isArray(body?.data) ? body.data : []).filter(model => typeof model?.id === 'string' && model.id)
35
+ .map(model => ({ id: `${ref}/${model.id}`, name: `${model.id} (${title})`, efforts: [], model: model.id }));
36
+ }
37
+
38
+ // The flag settings Claude Code runs a service's model with (above the machine's and the project's settings, its environment
39
+ // beneath them): the service as its API, a token of the service's own (`none` without one), and the model for every role,
40
+ // subagents' and background work's included. None of the machine's own ways to the API goes with it: an API key, a key
41
+ // helper, an OAuth token or custom headers would be sent to the service, a cloud provider's would take the requests away.
42
+ export function serviceSettings({ url, key }, model) {
43
+ return { apiKeyHelper: '', env: { ...Object.fromEntries(authOverrides.map(name => [name, ''])), ANTHROPIC_BASE_URL: url, ANTHROPIC_AUTH_TOKEN: key ?? 'none',
44
+ ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
45
+ CLAUDE_CODE_SUBAGENT_MODEL: model } };
46
+ }
47
+
48
+ // The service's models, listed with Claude's own: none when it cannot list them now.
49
+ export async function listedModels(service) {
50
+ if (!service) return [];
51
+ try { return await serviceModels(service); }
52
+ catch (e) { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }
53
+ }
@@ -0,0 +1,39 @@
1
+ // What Claude asks the user, as ACP forms in plain JSON Schema: its own questions (AskUserQuestion) and those of the MCP
2
+ // servers it runs (a form, or a link to open).
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ // The choice that says the answer is the one written beside it: ACP forms carry no rule that one of two fields be filled,
6
+ // so the question stays required and this is one of its choices.
7
+ export const OTHER = 'coda:other';
8
+
9
+ export function questionForm({ sessionId, toolCallId, input }) {
10
+ const questions = Array.isArray(input?.questions) ? input.questions : [];
11
+ const properties = {};
12
+ questions.forEach((q, i) => {
13
+ const choices = [...(q.options ?? []).map(o => ({ const: o.label, title: o.label + (o.description ? ` — ${o.description}` : '') })),
14
+ { const: OTHER, title: '其他(写在下面)' }];
15
+ properties[`q${i}`] = { title: q.question, description: q.header ?? '', ...(q.multiSelect
16
+ ? { type: 'array', items: { anyOf: choices }, minItems: 1 } : { type: 'string', oneOf: choices }) };
17
+ properties[`q${i}_other`] = { type: 'string', title: '其他' };
18
+ });
19
+ const params = { sessionId, ...(toolCallId ? { toolCallId } : {}), mode: 'form', message: 'Claude 需要你的回答',
20
+ requestedSchema: { type: 'object', properties, required: questions.map((_, i) => `q${i}`) } };
21
+ const answer = response => {
22
+ if (response?.action !== 'accept') return { behavior: 'deny', message: '用户取消了问答' };
23
+ const content = response.content ?? {};
24
+ return { behavior: 'allow', updatedInput: { ...input, answers: Object.fromEntries(questions.map((q, i) => {
25
+ const other = String(content[`q${i}_other`] ?? '').trim() || '其他', pick = value => value === OTHER ? other : value;
26
+ const value = content[`q${i}`];
27
+ return [q.question, Array.isArray(value) ? value.map(pick).join(', ') : pick(value)];
28
+ })) } };
29
+ };
30
+ return { params, answer };
31
+ }
32
+
33
+ // An MCP server's request (SDK onElicitation): its form as it is, or its link with nothing to fill in.
34
+ export function elicitationForm({ sessionId, request }) {
35
+ if (request.mode === 'url') return { params: { sessionId, mode: 'url', message: request.message ?? '', url: request.url,
36
+ elicitationId: request.elicitationId ?? randomUUID() }, answer: response => ({ action: response?.action ?? 'cancel' }) };
37
+ return { params: { sessionId, mode: 'form', message: request.message ?? '', requestedSchema: request.requestedSchema ?? { type: 'object', properties: {} } },
38
+ answer: response => ['accept', 'decline', 'cancel'].includes(response?.action) ? response : { action: 'cancel' } };
39
+ }