openyida 2026.10.13 → 2026.10.14

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.
Files changed (62) hide show
  1. package/README.md +76 -23
  2. package/bin/yida.js +32 -2
  3. package/lib/agent/PROTOCOL.md +201 -0
  4. package/lib/agent/ask-human-help.js +19 -0
  5. package/lib/agent/ask-human.js +222 -0
  6. package/lib/agent/cmd.js +405 -0
  7. package/lib/agent/command-receipt.js +84 -0
  8. package/lib/agent/connection-store.js +148 -0
  9. package/lib/agent/diagnostic-log.js +133 -0
  10. package/lib/agent/frozen-bundle.js +298 -0
  11. package/lib/agent/managed-command-map.js +47 -0
  12. package/lib/agent/managed-context.js +40 -0
  13. package/lib/agent/managed-run.js +84 -0
  14. package/lib/agent/mutation-receipt.js +82 -0
  15. package/lib/agent/private-directory.js +63 -0
  16. package/lib/agent/provider-preflight.js +257 -0
  17. package/lib/agent/providers.js +4 -0
  18. package/lib/agent/runtime-catalog.json +42 -0
  19. package/lib/agent/runtime-manifest.js +96 -0
  20. package/lib/agent/runtime-package.js +155 -0
  21. package/lib/agent/stdio.js +321 -0
  22. package/lib/app/check-prd-completeness.js +15 -4
  23. package/lib/app/create-form/batch.js +30 -9
  24. package/lib/app/create-form.js +7 -2
  25. package/lib/app/custom-theme.js +13 -3
  26. package/lib/app/get-schema.js +6 -1
  27. package/lib/app/update-app.js +13 -1
  28. package/lib/app/update-form-config.js +71 -80
  29. package/lib/asset/asset-plan.js +6 -4
  30. package/lib/auth/profile-lock.js +89 -0
  31. package/lib/auth/token-auth.js +57 -6
  32. package/lib/auth/token-store.js +26 -2
  33. package/lib/core/command-manifest.js +202 -118
  34. package/lib/core/locales/en.js +48 -4
  35. package/lib/core/locales/zh.js +48 -4
  36. package/lib/core/managed-policy.js +88 -0
  37. package/lib/core/query-data.js +22 -3
  38. package/lib/core/sample.js +1 -0
  39. package/lib/core/update.js +3 -1
  40. package/lib/core/utils.js +34 -1
  41. package/lib/design-plan/materialize.js +4 -21
  42. package/lib/design-plan/render-html.js +299 -0
  43. package/lib/page-config/save-share-config.js +16 -5
  44. package/lib/samples/yida-data-management/empty-query.js +69 -0
  45. package/package.json +10 -1
  46. package/scripts/postinstall.js +2 -2
  47. package/yida-skills/SKILL.md +32 -2
  48. package/yida-skills/references/local-agent-diagnostics.md +154 -0
  49. package/yida-skills/references/local-agent-preflight.md +56 -0
  50. package/yida-skills/references/query-condition-guide.md +223 -195
  51. package/yida-skills/skills/yida-app/workflow/plan/step-4-deliver.md +11 -3
  52. package/yida-skills/skills/yida-canvas-data-binding/SKILL.md +8 -5
  53. package/yida-skills/skills/yida-data-management/SKILL.md +64 -19
  54. package/yida-skills/skills/yida-data-management/references/data-format-guide.md +61 -25
  55. package/yida-skills/skills/yida-data-source-connectors/SKILL.md +6 -0
  56. package/yida-skills/skills/yida-design/SKILL.md +2 -0
  57. package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +2 -0
  58. package/yida-skills/skills/yida-design/templates/design-themes/README.md +1 -1
  59. package/yida-skills/skills/yida-document-markdown/SKILL.md +6 -0
  60. package/yida-skills/skills/yida-image-assets/SKILL.md +1 -1
  61. package/yida-skills/skills/yida-tingji/SKILL.md +4 -0
  62. package/yida-skills/skills-index.json +52 -7
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { CliError } = require('../core/cli-error');
7
+ const { readManagedContext } = require('./managed-context');
8
+
9
+ const SCHEMA = 'openyida.ask-human.v2';
10
+ const MAX_FILE_BYTES = 32 * 1024;
11
+ const HELP = require('./ask-human-help');
12
+ const USAGE = `用法:${HELP.usage}`;
13
+
14
+ function fail(code, message) { throw new CliError(message, { code, details: { retryable: false } }); }
15
+ function text(value, max) { return typeof value === 'string' && value === value.trim() && value.length > 0 && Buffer.byteLength(value) <= max && !/\p{Cc}/u.test(value); }
16
+
17
+ function parseArgs(argv) {
18
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { return { help: true }; }
19
+ const result = { json: false };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const name = argv[i];
22
+ if (name === '--json' && !result.json) { result.json = true; continue; }
23
+ const key = name === '--request-key' ? 'requestKey' : name === '--questions-file' ? 'questionsFile' : null;
24
+ if (!key || result[key] !== undefined || !argv[i + 1] || argv[i + 1].startsWith('--')) {
25
+ fail('AGENT_ASK_HUMAN_INVALID', USAGE);
26
+ }
27
+ result[key] = argv[++i];
28
+ }
29
+ if (!text(result.requestKey, 128) || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(result.requestKey) ||
30
+ !text(result.questionsFile, 512) || path.isAbsolute(result.questionsFile)) {
31
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 参数无效。');
32
+ }
33
+ return result;
34
+ }
35
+
36
+ function readQuestions(relativePath, cwd) {
37
+ let root;
38
+ let candidate;
39
+ let realCandidate;
40
+ let stat;
41
+ try {
42
+ root = fs.realpathSync(cwd);
43
+ candidate = path.resolve(root, relativePath);
44
+ stat = fs.lstatSync(candidate);
45
+ realCandidate = fs.realpathSync(candidate);
46
+ } catch { fail('AGENT_ASK_HUMAN_INVALID', '问题文件不可读取。'); }
47
+ if (candidate === root || !candidate.startsWith(root + path.sep) || realCandidate === root || !realCandidate.startsWith(root + path.sep)) {
48
+ fail('AGENT_ASK_HUMAN_INVALID', '问题文件必须位于当前工作目录。');
49
+ }
50
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) { fail('AGENT_ASK_HUMAN_INVALID', '问题文件必须是 32 KiB 以内的普通文件。'); }
51
+ let document;
52
+ try { document = JSON.parse(fs.readFileSync(candidate, 'utf8')); } catch { fail('AGENT_ASK_HUMAN_INVALID', '问题文件不是有效 JSON。'); }
53
+ if (!document || typeof document !== 'object' || Array.isArray(document) || !text(document.title, 256) ||
54
+ !Array.isArray(document.questions) || document.questions.length < 1 || document.questions.length > 3) {
55
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 问题结构无效。');
56
+ }
57
+ const ids = new Set();
58
+ for (const question of document.questions) {
59
+ const allowed = new Set(['id', 'header', 'question', 'type', 'required', 'options']);
60
+ if (!question || typeof question !== 'object' || Array.isArray(question) || Object.keys(question).some(key => !allowed.has(key)) ||
61
+ !text(question.id, 128) || ids.has(question.id) || !text(question.header, 128) || !text(question.question, 2048) ||
62
+ !['text', 'single_select', 'multi_select'].includes(question.type) ||
63
+ (question.required !== undefined && typeof question.required !== 'boolean')) {
64
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 问题结构无效。');
65
+ }
66
+ ids.add(question.id);
67
+ const options = question.options === undefined ? [] : question.options;
68
+ if (!Array.isArray(options)) { fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 选项结构无效。'); }
69
+ if ((question.type === 'text' && options.length) || (question.type !== 'text' && (!Array.isArray(options) || options.length < 1 || options.length > 12)) ||
70
+ options.some(option => !option || typeof option !== 'object' || Array.isArray(option) || Object.keys(option).some(key => !['label', 'value', 'description'].includes(key)) || !text(option.label, 512) || (option.value !== undefined && !text(option.value, 512)) ||
71
+ (option.description !== undefined && option.description !== '' && !text(option.description, 1024)))) {
72
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 选项结构无效。');
73
+ }
74
+ if (new Set(options.map(option => option.value || option.label)).size !== options.length) { fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 选项不能重复。'); }
75
+ }
76
+ if (Object.keys(document).some(key => !['title', 'questions', 'revision', 'attachments'].includes(key)) ||
77
+ (document.revision !== undefined && !text(document.revision, 256)) ||
78
+ (document.attachments !== undefined && (!Array.isArray(document.attachments) || document.attachments.length > 10))) {
79
+ fail('AGENT_ASK_HUMAN_INVALID', 'Ask Human 附件或方案版本无效。');
80
+ }
81
+ return { title: document.title,
82
+ ...(document.revision ? { contextRevision: document.revision } : {}),
83
+ attachments: document.attachments || [],
84
+ questions: document.questions.map(question => ({ ...question, required: question.required !== false,
85
+ ...(question.options ? { options: question.options.map(option => ({ ...option, value: option.value || option.label })) } : {}) })) };
86
+ }
87
+
88
+ // Explicit output only. Freeze bytes before publishing a question receipt.
89
+ function snapshotAttachments(attachments, cwd, directory) {
90
+ const root = fs.realpathSync(cwd);
91
+ const files = path.join(directory, '.files');
92
+ fs.mkdirSync(files, { recursive: true, mode: 0o700 });
93
+ if (fs.lstatSync(files).isSymbolicLink() || fs.realpathSync(files) !== files) { fail('AGENT_ASK_HUMAN_INVALID', '附件目录无效。'); }
94
+ const seen = new Set();
95
+ let total = 0;
96
+ return attachments.map(attachment => {
97
+ if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment) ||
98
+ Object.keys(attachment).some(key => !['name', 'path'].includes(key)) ||
99
+ !text(attachment.name, 256) || !text(attachment.path, 1024)) {
100
+ fail('AGENT_ASK_HUMAN_INVALID', '附件格式无效。');
101
+ }
102
+ const source = path.resolve(root, attachment.path);
103
+ const relative = path.relative(root, source);
104
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) ||
105
+ relative.split(path.sep).some(part => /^(?:\.env(?:\..*)?|\.git|node_modules|\.openyida|credentials?|auth|tokens?)$/i.test(part))) {
106
+ fail('AGENT_ASK_HUMAN_INVALID', '附件必须是任务工作目录中的交付文件。');
107
+ }
108
+ const extension = path.extname(source).toLowerCase();
109
+ if (!['.html', '.htm', '.md', '.txt'].includes(extension)) { fail('AGENT_ASK_HUMAN_INVALID', '附件仅支持 HTML、Markdown 或文本。'); }
110
+ let bytes;
111
+ try {
112
+ // Reject symlink/reparse traversal, including linked parent directories.
113
+ if (fs.realpathSync(source) !== source || !fs.lstatSync(source).isFile() || fs.lstatSync(source).isSymbolicLink()) { throw new Error(); }
114
+ const fd = fs.openSync(source, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
115
+ try {
116
+ const info = fs.fstatSync(fd);
117
+ if (!info.isFile() || info.size > 1024 * 1024) { throw new Error(); }
118
+ const bounded = Buffer.alloc(1024 * 1024 + 1);
119
+ let count = 0;
120
+ while (count < bounded.length) {
121
+ const read = fs.readSync(fd, bounded, count, bounded.length - count, null);
122
+ if (!read) { break; }
123
+ count += read;
124
+ }
125
+ bytes = bounded.subarray(0, count);
126
+ } finally { fs.closeSync(fd); }
127
+ if (bytes.length > 1024 * 1024 || bytes.includes(0) || !Buffer.from(bytes.toString('utf8')).equals(bytes)) { throw new Error(); }
128
+ } catch { fail('AGENT_ASK_HUMAN_INVALID', '附件不可读取,或超过 1 MiB/不是 UTF-8 文本。'); }
129
+ total += bytes.length;
130
+ if (total > 10 * 1024 * 1024) { fail('AGENT_ASK_HUMAN_LIMIT', '附件总大小超过限制。'); }
131
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
132
+ const file = `${sha256}${extension}`;
133
+ if (seen.has(file)) { fail('AGENT_ASK_HUMAN_INVALID', '附件不能重复。'); }
134
+ seen.add(file);
135
+ const target = path.join(files, file);
136
+ if (fs.existsSync(target)) {
137
+ if (fs.lstatSync(target).isSymbolicLink() || !fs.readFileSync(target).equals(bytes)) { fail('AGENT_ASK_HUMAN_CONFLICT', '附件快照冲突。'); }
138
+ } else {
139
+ const temporary = path.join(files, `.${crypto.randomUUID()}.tmp`);
140
+ const fd = fs.openSync(temporary, 'wx', 0o600);
141
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
142
+ retryWindowsFileOperation(() => fs.renameSync(temporary, target));
143
+ }
144
+ return { name: attachment.name, path: `.files/${file}`, sha256, sizeBytes: bytes.length };
145
+ });
146
+ }
147
+
148
+ function ensureReceiptDir(env) {
149
+ const value = env.OPENYIDA_AGENT_INTERACTIONS_DIR;
150
+ if (!value || !path.isAbsolute(value)) { fail('AGENT_ASK_HUMAN_UNAVAILABLE', '当前任务未启用 Ask Human。'); }
151
+ fs.mkdirSync(value, { recursive: true, mode: 0o700 });
152
+ const stat = fs.lstatSync(value);
153
+ if (!stat.isDirectory() || stat.isSymbolicLink()) { fail('AGENT_ASK_HUMAN_UNAVAILABLE', 'Ask Human 私有目录不可用。'); }
154
+ if (fs.readdirSync(value).filter(name => name.endsWith('.json')).length >= 64) { fail('AGENT_ASK_HUMAN_LIMIT', '本轮 Ask Human 请求过多。'); }
155
+ return fs.realpathSync(value);
156
+ }
157
+
158
+ function retryWindowsFileOperation(operation) {
159
+ const delays = [20, 40, 80, 160, 320];
160
+ for (let attempt = 0; ; attempt++) {
161
+ try { return operation(); } catch (error) {
162
+ if (process.platform !== 'win32' || attempt === delays.length || !['EBUSY', 'EPERM', 'EACCES'].includes(error.code)) { throw error; }
163
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
164
+ }
165
+ }
166
+ }
167
+
168
+ function writeReceipt(directory, requestId, receipt) {
169
+ const temporary = path.join(directory, `.${requestId}.tmp`);
170
+ const target = path.join(directory, `${requestId}.json`);
171
+ let fd;
172
+ try {
173
+ fd = fs.openSync(temporary, 'wx', 0o600);
174
+ fs.writeFileSync(fd, JSON.stringify(receipt));
175
+ fs.fsyncSync(fd);
176
+ fs.closeSync(fd);
177
+ fd = undefined;
178
+ retryWindowsFileOperation(() => fs.renameSync(temporary, target));
179
+ if (process.platform !== 'win32') {
180
+ const parent = fs.openSync(directory, 'r');
181
+ try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
182
+ }
183
+ } catch (error) {
184
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* Preserve the original write failure if cleanup also fails. */ } }
185
+ if (fs.existsSync(temporary)) { try { retryWindowsFileOperation(() => fs.unlinkSync(temporary)); } catch { /* Preserve the original write failure if cleanup also fails. */ } }
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ function run(argv = [], options = {}) {
191
+ const env = options.env || process.env;
192
+ const context = readManagedContext(env);
193
+ if (!context) { fail('AGENT_ASK_HUMAN_MANAGED_ONLY', 'Ask Human 仅可在宜搭云端发起的本地 Agent 任务中使用。'); }
194
+ const args = parseArgs(argv);
195
+ if (args.help) {
196
+ (options.stdout || process.stdout).write([USAGE, HELP.description, ...HELP.examples].join('\n') + '\n');
197
+ return { ok: true, help: true };
198
+ }
199
+ const document = readQuestions(args.questionsFile, options.cwd || process.cwd());
200
+ const directory = ensureReceiptDir(env);
201
+ document.attachments = snapshotAttachments(document.attachments, options.cwd || process.cwd(), directory);
202
+ const digest = crypto.createHash('sha256').update(JSON.stringify(document)).digest('hex');
203
+ for (const name of fs.readdirSync(directory).filter(item => item.endsWith('.json'))) {
204
+ let prior;
205
+ try { prior = JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')); } catch { continue; }
206
+ if (prior.requestKey === args.requestKey) {
207
+ if (prior.contentDigest !== digest) { fail('AGENT_ASK_HUMAN_CONFLICT', '同一 request-key 不能提交不同问题。'); }
208
+ const output = { ok: true, requestId: prior.requestId, status: 'queued' };
209
+ (options.stdout || process.stdout).write(JSON.stringify(output) + '\n');
210
+ return output;
211
+ }
212
+ }
213
+ const requestId = crypto.randomUUID();
214
+ const receipt = { schemaVersion: SCHEMA, requestId, requestKey: args.requestKey, contentDigest: digest,
215
+ runId: context.runId, attemptId: context.attemptId, createdAt: new Date().toISOString(), ...document };
216
+ writeReceipt(directory, requestId, receipt);
217
+ const output = { ok: true, requestId, status: 'queued' };
218
+ (options.stdout || process.stdout).write(JSON.stringify(output) + '\n');
219
+ return output;
220
+ }
221
+
222
+ module.exports = { run, SCHEMA };
@@ -0,0 +1,405 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const fs = require('fs');
6
+ const crypto = require('crypto');
7
+ const { EventEmitter } = require('events');
8
+ const { CliError } = require('../core/cli-error');
9
+ const { t } = require('../core/i18n');
10
+ const { resolveRuntime, PROTOCOL_VERSION } = require('./runtime-package');
11
+ const { materializeBundle } = require('./frozen-bundle');
12
+ const { launchRuntime, launchBackgroundRuntime, BACKGROUND_LOG_NAME } = require('./stdio');
13
+ const { prepareConnection, listConnections } = require('./connection-store');
14
+ const { createDiagnosticLog, defaultDiagnosticDir, readRecentCredentialFailures } = require('./diagnostic-log');
15
+ const { version, openyidaAgent = {} } = require('../../package.json');
16
+
17
+ const COMMANDS = ['doctor', 'diagnose', 'status', 'connect', 'run', 'disconnect', 'logout', 'logs'];
18
+ const VALUE_OPTIONS = {
19
+ '--runtime-path': 'runtimePath', '--runtime-manifest': 'manifestPath',
20
+ '--endpoint': 'endpoint', '--endpoint-id': 'endpointId', '--enroll': 'enrollmentToken',
21
+ '--provider': 'provider', '--provider-path': 'providerPath', '--state-dir': 'stateDir',
22
+ '--session': 'diagnosticSession', '--output': 'diagnosticOutput',
23
+ };
24
+
25
+ function safeUrl(value) {
26
+ let url;
27
+ try {url = new URL(value);} catch {throw new CliError(t('agent.identity_invalid'), { code: 'AGENT_ENDPOINT_INVALID' });}
28
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
29
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash) {
30
+ throw new CliError(t('agent.identity_invalid'), { code: 'AGENT_ENDPOINT_INVALID' });
31
+ }
32
+ return url.toString().replace(/\/+$/, '');
33
+ }
34
+
35
+ const PROVIDERS = require('./providers');
36
+
37
+ const { discoverProviders, preflight } = require('./provider-preflight');
38
+
39
+ function discoverProvider(env = process.env, platform = process.platform) {
40
+ return discoverProviders(env, platform)[0];
41
+ }
42
+
43
+ function parseArgs(args = []) {
44
+ const result = { command: args[0] || 'help', json: false };
45
+ if (args.includes('--help') || args.includes('-h')) {return { command: 'help' };}
46
+ if (result.command === 'help') {return result;}
47
+ if (!COMMANDS.includes(result.command)) {
48
+ throw new CliError(t('agent.usage'), { code: 'INVALID_ARGUMENTS' });
49
+ }
50
+ for (let i = 1; i < args.length; i++) {
51
+ const name = args[i];
52
+ if (['--json', '--development-runtime', '--deep'].includes(name)) {
53
+ const key = name === '--json' ? 'json' : name === '--deep' ? 'diagnosticDeep' : 'developmentRuntime';
54
+ if (result[key] === true) {throw new CliError(t('agent.usage'), { code: 'INVALID_ARGUMENTS' });}
55
+ result[key] = true;
56
+ continue;
57
+ }
58
+ const key = VALUE_OPTIONS[name];
59
+ if (!key || result[key] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) {
60
+ throw new CliError(t('agent.usage'), { code: 'INVALID_ARGUMENTS' });
61
+ }
62
+ result[key] = args[++i];
63
+ }
64
+ if (result.enrollmentToken && result.command !== 'connect') {throw new CliError(t('agent.input_invalid'), { code: 'AGENT_INPUT_INVALID' });}
65
+ if ((result.diagnosticSession || result.diagnosticOutput || result.diagnosticDeep) && result.command !== 'diagnose') {
66
+ throw new CliError(t('agent.input_invalid'), { code: 'AGENT_INPUT_INVALID' });
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function parseDiagnosticSession(value) {
72
+ if (!value) {return null;}
73
+ const direct = /^local_[a-zA-Z0-9_-]{1,200}$/.test(value) ? value : null;
74
+ if (direct) {return { sessionId: direct, appType: null };}
75
+ let parsed;
76
+ try {parsed = new URL(value);} catch {parsed = null;}
77
+ if (!parsed || parsed.protocol !== 'https:' || parsed.username || parsed.password) {
78
+ throw new CliError(t('agent.diagnostic_session_invalid'), { code: 'AGENT_DIAGNOSTIC_SESSION_INVALID' });
79
+ }
80
+ const sessionId = parsed.searchParams.get('sessionId') || '';
81
+ const appType = parsed.pathname.split('/').find(part => /^APP_[a-zA-Z0-9_-]{1,200}$/.test(part)) || null;
82
+ if (!/^local_[a-zA-Z0-9_-]{1,200}$/.test(sessionId)) {
83
+ throw new CliError(t('agent.diagnostic_session_invalid'), { code: 'AGENT_DIAGNOSTIC_SESSION_INVALID' });
84
+ }
85
+ return { sessionId, appType };
86
+ }
87
+
88
+ function writeDiagnosticSummary(args, config, results, stdout) {
89
+ const diagnosticId = config.diagnosticId || `diag_${crypto.randomUUID()}`;
90
+ const summary = { type: 'diagnostic', schemaVersion: 1, diagnosticId,
91
+ phase: config.diagnosticSession ? 'session' : 'connection',
92
+ sessionId: config.diagnosticSession || undefined, appType: config.diagnosticAppType || undefined,
93
+ connections: results, findings: results.flatMap(item => Array.isArray(item.findings) ? item.findings : []) };
94
+ // Connection-stage failures can predate any paired Connection or Session.
95
+ // Do not attribute installation-wide history to an unrelated session.
96
+ if (!config.diagnosticSession && config.diagnosticsDir) {
97
+ summary.recentConnectionFailures = readRecentCredentialFailures(config.diagnosticsDir);
98
+ if (summary.recentConnectionFailures.length) {
99
+ summary.findings.push({ severity: 'warning', code: 'RECENT_DEVICE_CREDENTIAL_REJECTION',
100
+ historical: true, action: 'CHECK_RECENT_CONNECTION_FAILURE_DETAILS' });
101
+ }
102
+ }
103
+ const serialized = `${JSON.stringify(summary, null, args.json ? 0 : 2)}\n`;
104
+ if (args.diagnosticOutput) {
105
+ const output = path.resolve(args.diagnosticOutput);
106
+ fs.mkdirSync(output, { recursive: true, mode: 0o700 });
107
+ const target = path.join(output, `openyida-local-agent-diagnostic-${diagnosticId}.json`);
108
+ fs.writeFileSync(target, serialized, { mode: 0o600, flag: 'wx' });
109
+ summary.outputPath = target;
110
+ }
111
+ stdout.write(`${JSON.stringify(summary, null, args.json ? 0 : 2)}\n`);
112
+ }
113
+
114
+ function buildLaunchConfig(args, options = {}) {
115
+ const packageRoot = options.packageRoot || path.resolve(__dirname, '..', '..');
116
+ const stateDir = args.stateDir || path.join(options.homedir || os.homedir(), '.openyida', 'local-agent');
117
+ const env = options.env === undefined ? process.env : options.env;
118
+ if (!path.isAbsolute(stateDir)) {
119
+ throw new CliError(t('agent.absolute_path_required'), { code: 'AGENT_STATE_PATH_INVALID' });
120
+ }
121
+ const config = {
122
+ protocolVersion: PROTOCOL_VERSION,
123
+ command: args.command,
124
+ stateDir,
125
+ sessionStateDir: path.join(stateDir, 'sessions'),
126
+ node: { executable: process.execPath, cliEntry: path.join(packageRoot, 'bin', 'yida.js'), packageRoot, version },
127
+ providers: [],
128
+ diagnosticsDir: path.join(stateDir, 'logs'),
129
+ };
130
+ if (args.diagnosticId) {config.diagnosticId = args.diagnosticId;}
131
+ if (args.developmentRuntime === true) {config.developmentRuntime = true;}
132
+ const endpoint = args.endpoint || env.OPENYIDA_AGENT_CONTROL_ENDPOINT ||
133
+ (options.env === undefined ? openyidaAgent.endpoint : undefined);
134
+ const endpointId = args.endpointId || env.OPENYIDA_AGENT_ENDPOINT_ID ||
135
+ (options.env === undefined ? openyidaAgent.endpointId : undefined);
136
+ if (endpoint) {config.endpoint = safeUrl(endpoint);}
137
+ if (endpointId) {
138
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(endpointId)) {
139
+ throw new CliError(t('agent.identity_invalid'), { code: 'AGENT_ENDPOINT_INVALID' });
140
+ }
141
+ config.endpointId = endpointId;
142
+ }
143
+ if (args.enrollmentToken) {
144
+ if (args.enrollmentToken.length < 32 || args.enrollmentToken.length > 4096 || /\s/.test(args.enrollmentToken)) {
145
+ throw new CliError(t('agent.input_invalid'), { code: 'AGENT_ENROLLMENT_TOKEN_INVALID' });
146
+ }
147
+ config.enrollmentToken = args.enrollmentToken;
148
+ }
149
+ if (args.diagnosticSession) {
150
+ const diagnostic = parseDiagnosticSession(args.diagnosticSession);
151
+ config.diagnosticSession = diagnostic.sessionId;
152
+ if (diagnostic.appType) {config.diagnosticAppType = diagnostic.appType;}
153
+ }
154
+ if (args.diagnosticDeep === true) {config.diagnosticDeep = true;}
155
+ if (['connect', 'run'].includes(args.command) && (!config.endpoint || !config.endpointId) && !options.allowStoredConnections) {
156
+ throw new CliError(t('agent.connection_options_required'), { code: 'AGENT_CONNECTION_OPTIONS_REQUIRED' });
157
+ }
158
+ if (args.provider || args.providerPath) {
159
+ if (!PROVIDERS.includes(args.provider) || (args.providerPath && !path.isAbsolute(args.providerPath))) {
160
+ throw new CliError(t('agent.provider_options_required'), { code: 'AGENT_PROVIDER_OPTIONS_INVALID' });
161
+ }
162
+ if (args.providerPath) { config.providers.push({ profileId: `openyida.${args.provider}`, provider: args.provider, executable: args.providerPath }); }
163
+ }
164
+ if (['connect', 'run', 'doctor', 'diagnose'].includes(args.command) && config.providers.length === 0) {
165
+ const discovered = discoverProviders(env, options.platform || process.platform, options.homedir).filter(p => !args.provider || p.provider === args.provider);
166
+ if (discovered.length === 0) {
167
+ if (args.command === 'run') {
168
+ throw new CliError(t('agent.provider_not_found'), { code: 'AGENT_PROVIDER_NOT_FOUND' });
169
+ }
170
+ }
171
+ config.providers.push(...discovered);
172
+ }
173
+ return config;
174
+ }
175
+
176
+ function readLogTail(logPath, maxBytes = 64 * 1024) {
177
+ let fd;
178
+ try {fd = fs.openSync(logPath, 'r');} catch {return null;}
179
+ try {
180
+ const { size } = fs.fstatSync(fd);
181
+ const start = size > maxBytes ? size - maxBytes : 0;
182
+ const length = size - start;
183
+ if (length <= 0) {return '';}
184
+ const buffer = Buffer.alloc(length);
185
+ fs.readSync(fd, buffer, 0, length, start);
186
+ return buffer.toString('utf8');
187
+ } finally {fs.closeSync(fd);}
188
+ }
189
+
190
+ // `logs` is a Node-only inspection command. It never launches the Go runtime;
191
+ // it surfaces the per-connection background daemon log so the user can review a
192
+ // connection that is running detached after the terminal already returned.
193
+ function emitLogs(args, options, stdout) {
194
+ const baseStateDir = args.stateDir || path.join(options.homedir || os.homedir(), '.openyida', 'local-agent');
195
+ if (!path.isAbsolute(baseStateDir)) {
196
+ throw new CliError(t('agent.absolute_path_required'), { code: 'AGENT_STATE_PATH_INVALID' });
197
+ }
198
+ const connections = options.disableConnectionManager === true ? [] : listConnections(baseStateDir);
199
+ if (connections.length === 0) {
200
+ stdout.write(`${JSON.stringify({ type: 'logs', connections: [] })}\n`);
201
+ return;
202
+ }
203
+ for (const connection of connections) {
204
+ const logPath = path.join(connection.stateDir, 'logs', BACKGROUND_LOG_NAME);
205
+ const content = readLogTail(logPath);
206
+ stdout.write(`${JSON.stringify({ type: 'logs', connectionId: connection.connectionId, endpointId: connection.endpointId, logPath, available: content !== null })}\n`);
207
+ if (content) {stdout.write(content.endsWith('\n') ? content : `${content}\n`);}
208
+ }
209
+ }
210
+
211
+ async function run(argv = [], options = {}) {
212
+ if (argv[0] === 'ask-human') {
213
+ return require('./ask-human').run(argv.slice(1), options);
214
+ }
215
+ const args = parseArgs(argv);
216
+ const stdout = options.stdout || process.stdout;
217
+ if (args.command === 'help') {stdout.write(`${t('agent.usage')}\n`); return;}
218
+ if (args.command === 'logs') {return emitLogs(args, options, stdout);}
219
+ let providerCheck;
220
+ // A normal `run` must resolve the same discovered executable that connect/doctor
221
+ // validated. A stale earlier PATH entry must not replace a working bundled
222
+ // CLI after restart. An explicit absolute provider path is verified by Go.
223
+ if (['connect', 'doctor'].includes(args.command) || (args.command === 'run' && !args.providerPath)) {
224
+ if ((args.provider || args.providerPath) && (!PROVIDERS.includes(args.provider) || (args.providerPath && !path.isAbsolute(args.providerPath)))) {
225
+ throw new CliError(t('agent.provider_options_required'), { code: 'AGENT_PROVIDER_OPTIONS_INVALID' });
226
+ }
227
+ providerCheck = await preflight(args, options);
228
+ if (args.command === 'connect') {
229
+ if (args.json) { stdout.write(JSON.stringify(providerCheck) + '\n'); }
230
+ else {
231
+ const stderr = options.stderr || process.stderr;
232
+ stderr.write(t('agent.preflight_advisory') + '\n');
233
+ for (const item of providerCheck.providers) {
234
+ stderr.write(item.provider + ': ' + item.message + (item.desktopDetected ? ' (' + t('agent.preflight_desktop') + ')' : '') + '\n');
235
+ }
236
+ stderr.write(t(providerCheck.readyForEnrollment ? 'agent.preflight_continue' : 'agent.preflight_retry') + '\n');
237
+ }
238
+ if (!providerCheck.readyForEnrollment) {
239
+ throw new CliError(t('agent.preflight_blocked'), { code: 'AGENT_PREFLIGHT_BLOCKED', details: providerCheck });
240
+ }
241
+ }
242
+ if (args.command === 'run' && !providerCheck.readyForEnrollment) {
243
+ throw new CliError(t('agent.preflight_blocked'), { code: 'AGENT_PREFLIGHT_BLOCKED', details: providerCheck });
244
+ }
245
+ }
246
+ const diagnosticsDir = args.stateDir
247
+ ? path.join(args.stateDir, 'logs')
248
+ : defaultDiagnosticDir(options.homedir || os.homedir());
249
+ const diagnostic = createDiagnosticLog(diagnosticsDir);
250
+ diagnostic('info', 'launcher_started', { command: args.command, component: 'node', runtimeVersion: version });
251
+ options = { ...options, diagnostic };
252
+ if (args.command === 'diagnose') {args.diagnosticId = `diag_${crypto.randomUUID()}`;}
253
+ let runtime;
254
+ try {
255
+ runtime = resolveRuntime({ ...options, runtimePath: args.runtimePath, manifestPath: args.manifestPath, developmentRuntime: args.developmentRuntime === true });
256
+ } catch (error) {
257
+ diagnostic('error', 'runtime_unavailable', { command: args.command, component: 'node', errorCode: error.code || 'AGENT_RUNTIME_NOT_AVAILABLE' });
258
+ if (!['doctor', 'diagnose'].includes(args.command)) {throw error;}
259
+ if (args.command === 'diagnose') {
260
+ const correlation = parseDiagnosticSession(args.diagnosticSession);
261
+ writeDiagnosticSummary(args, {
262
+ diagnosticId: args.diagnosticId,
263
+ diagnosticsDir,
264
+ diagnosticSession: correlation?.sessionId,
265
+ diagnosticAppType: correlation?.appType,
266
+ }, [{ type: 'diagnostic', ready: false, phase: 'connection', runtime: { installed: false, code: error.code }, providers: [], findings: [{ severity: 'error', code: 'RUNTIME_NOT_AVAILABLE', action: 'INSTALL_OR_UPDATE_OPENYIDA' }] }], stdout);
267
+ return;
268
+ }
269
+ stdout.write(JSON.stringify({ ...providerCheck, type: 'doctor', providerReady: providerCheck.ready, ready: false, authKnown: providerCheck.providers.some(p => p.authKnown), runtime: { installed: false, code: error.code } }) + '\n');
270
+ return;
271
+ }
272
+ if (args.command === 'doctor') {
273
+ stdout.write(JSON.stringify({ ...providerCheck, type: 'doctor', providerReady: providerCheck.ready,
274
+ readyForEnrollment: providerCheck.readyForEnrollment, ready: false, providerAcceptanceRequired: true,
275
+ runtime: { installed: true } }) + '\n');
276
+ return;
277
+ }
278
+ // An unsigned development Runtime still uses the same installation and
279
+ // per-organization Connection state as a production Runtime. Without this
280
+ // preparation Go rejects `connect` before it can create an enrollment because
281
+ // its state store correctly requires an installation identity.
282
+ const managedConnections = options.disableConnectionManager !== true;
283
+ let config = buildLaunchConfig(args, { ...options, allowStoredConnections: managedConnections && args.command === 'run' });
284
+ if (providerCheck) {
285
+ // Inventory includes signed-out CLIs. Runtime reports their auth status;
286
+ // the control plane independently enforces task readiness.
287
+ config.providers = providerCheck.providers.filter(p => p.registrable).map(p => ({
288
+ profileId: 'openyida.' + p.provider, provider: p.provider, executable: p.executable,
289
+ }));
290
+ }
291
+ if (managedConnections && args.command === 'connect') {
292
+ const prepared = prepareConnection(config.stateDir, config.enrollmentToken);
293
+ config = { ...config, stateDir: prepared.stateDir, installationId: prepared.installationId };
294
+ }
295
+ if (managedConnections && ['run', 'diagnose', 'status', 'disconnect', 'logout'].includes(args.command)) {
296
+ const connections = listConnections(config.stateDir);
297
+ const diagnosticResults = [];
298
+ const diagnosticWriter = {write(value) {
299
+ try {diagnosticResults.push(JSON.parse(String(value)));} catch { /* Runtime output is validated by launchRuntime. */ }
300
+ }};
301
+ if (connections.length === 0) {
302
+ // Development `status` has historically been a no-side-effect Runtime
303
+ // probe before the first enrollment. Keep that useful empty-state
304
+ // diagnostic while requiring a real Connection for all mutations/runs.
305
+ if (args.command === 'diagnose' || args.developmentRuntime === true && args.command === 'status') {
306
+ await launchRuntime(runtime, config, { ...options, stdout: args.command === 'diagnose' ? diagnosticWriter : stdout });
307
+ if (args.command === 'diagnose') {writeDiagnosticSummary(args, config, diagnosticResults, stdout);}
308
+ return;
309
+ }
310
+ throw new CliError(t('agent.identity_invalid'), { code: 'AGENT_CONNECTION_NOT_FOUND' });
311
+ }
312
+ // Bundle preparation performs synchronous filesystem work. Finish it before
313
+ // spawning children so their launch-config stdin can flush without blocking.
314
+ const preparedConnections = connections.map((connection) => {
315
+ const connectionConfig = {
316
+ ...config,
317
+ stateDir: connection.stateDir,
318
+ installationId: connection.installationId,
319
+ endpoint: connection.endpoint,
320
+ endpointId: connection.endpointId,
321
+ };
322
+ return args.command === 'run'
323
+ ? materializeBundle(runtime, connectionConfig, options)
324
+ : { runtime, config: connectionConfig };
325
+ });
326
+ if (args.command === 'run') {
327
+ // Re-online mirrors connect: each Connection's serving loop runs as its own
328
+ // detached background daemon and the terminal returns immediately, persisting
329
+ // until `disconnect`. Organization Connections are independent security and
330
+ // execution domains, so one failing to launch must not take the others
331
+ // offline; only an all-Connection failure is fatal.
332
+ const results = await Promise.allSettled(preparedConnections.map((prepared, index) => {
333
+ const runConfig = { ...prepared.config, command: 'run' };
334
+ return launchBackgroundRuntime(prepared.runtime, runConfig, { ...options, stdout }).then((background) => {
335
+ stdout.write(`${JSON.stringify({
336
+ type: 'connected', background: true,
337
+ connectionId: connections[index].connectionId, endpointId: connections[index].endpointId,
338
+ stateDir: runConfig.stateDir, logPath: background.logPath,
339
+ })}\n`);
340
+ });
341
+ }));
342
+ const failures = results
343
+ .map((result, index) => ({ result, connection: connections[index] }))
344
+ .filter(({ result }) => result.status === 'rejected');
345
+ for (const { result, connection } of failures) {
346
+ diagnostic('error', 'connection_runtime_failed', {
347
+ command: args.command,
348
+ component: 'node',
349
+ connectionId: connection.connectionId,
350
+ endpointId: connection.endpointId,
351
+ errorCode: result.reason?.code || 'RUNTIME_ACTION_FAILED',
352
+ });
353
+ }
354
+ if (failures.length === preparedConnections.length) {throw failures[0].result.reason;}
355
+ return;
356
+ }
357
+ // status/disconnect/logout/diagnose stay in the foreground: they are short
358
+ // queries or graceful stops that must report their result to this terminal.
359
+ const upstreamSignals = options.signals || process;
360
+ const supervisorSignals = new EventEmitter();
361
+ // Each Connection owns one Go child and therefore one pair of signal
362
+ // listeners. Multi-organization installations can legitimately exceed the
363
+ // EventEmitter default of ten listeners; this emitter is private and all
364
+ // listeners are removed when their child exits.
365
+ supervisorSignals.setMaxListeners(0);
366
+ const forwardInterrupt = () => supervisorSignals.emit('SIGINT');
367
+ const forwardTerminate = () => supervisorSignals.emit('SIGTERM');
368
+ upstreamSignals.on('SIGINT', forwardInterrupt);
369
+ upstreamSignals.on('SIGTERM', forwardTerminate);
370
+ const launches = preparedConnections.map((prepared) => launchRuntime(prepared.runtime, prepared.config, {
371
+ ...options, stdout: args.command === 'diagnose' ? diagnosticWriter : stdout, signals: supervisorSignals,
372
+ }));
373
+ try {
374
+ await Promise.all(launches);
375
+ } catch (error) {
376
+ supervisorSignals.emit('SIGTERM');
377
+ await Promise.allSettled(launches);
378
+ throw error;
379
+ } finally {
380
+ upstreamSignals.removeListener('SIGINT', forwardInterrupt);
381
+ upstreamSignals.removeListener('SIGTERM', forwardTerminate);
382
+ }
383
+ if (args.command === 'diagnose') {
384
+ writeDiagnosticSummary(args, config, diagnosticResults, stdout);
385
+ }
386
+ return;
387
+ }
388
+ if (['connect', 'run'].includes(args.command)) {
389
+ const frozen = materializeBundle(runtime, config, options);
390
+ runtime = frozen.runtime;
391
+ config = frozen.config;
392
+ }
393
+ await launchRuntime(runtime, config, { ...options, stdout });
394
+ if (args.command === 'connect') {
395
+ // Pairing has completed in the foreground above. The serving loop now runs
396
+ // as a detached background daemon so the terminal returns immediately and
397
+ // the connection persists across terminal/desktop close until `disconnect`.
398
+ const runConfig = { ...config, command: 'run' };
399
+ delete runConfig.enrollmentToken;
400
+ const background = await launchBackgroundRuntime(runtime, runConfig, { ...options, stdout });
401
+ stdout.write(`${JSON.stringify({ type: 'connected', background: true, stateDir: runConfig.stateDir, logPath: background.logPath })}\n`);
402
+ }
403
+ }
404
+
405
+ module.exports = { parseArgs, parseDiagnosticSession, buildLaunchConfig, discoverProvider, discoverProviders, run };