osai-agent 4.0.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/LICENSE +7 -0
- package/package.json +72 -0
- package/src/agent/context.js +141 -0
- package/src/agent/loop/context-summary.js +196 -0
- package/src/agent/loop/directory-utils.js +102 -0
- package/src/agent/loop/local.js +196 -0
- package/src/agent/loop/loop-detection.js +288 -0
- package/src/agent/loop/stream-parser.js +515 -0
- package/src/agent/loop/tool-executor.js +470 -0
- package/src/agent/loop/verification.js +263 -0
- package/src/agent/loop/websocket.js +80 -0
- package/src/agent/prompt.js +259 -0
- package/src/agent/react-loop.js +697 -0
- package/src/agent/subagent.js +263 -0
- package/src/commands/config.js +53 -0
- package/src/commands/connect.js +190 -0
- package/src/commands/devices.js +121 -0
- package/src/commands/login.js +77 -0
- package/src/commands/logout.js +31 -0
- package/src/commands/mcp.js +258 -0
- package/src/commands/provider.js +633 -0
- package/src/commands/register.js +74 -0
- package/src/commands/run.js +150 -0
- package/src/commands/search.js +64 -0
- package/src/commands/session.js +57 -0
- package/src/commands/skills.js +54 -0
- package/src/commands/stop-subagent.js +58 -0
- package/src/index.js +208 -0
- package/src/llm/direct.js +317 -0
- package/src/memory/store.js +215 -0
- package/src/mock-readline.js +27 -0
- package/src/parser/dependencies.js +71 -0
- package/src/parser/markdown.js +505 -0
- package/src/parser/stream.js +96 -0
- package/src/prompts/modes/CODING.js +160 -0
- package/src/prompts/modes/GENERAL.js +105 -0
- package/src/prompts/modes/NETWORK.js +69 -0
- package/src/prompts/modes/SSH.js +53 -0
- package/src/prompts/systemPrompt.js +85 -0
- package/src/safety/check.js +210 -0
- package/src/services/crypto.js +78 -0
- package/src/services/executor.js +68 -0
- package/src/services/history.js +58 -0
- package/src/services/server-url.js +11 -0
- package/src/services/session.js +194 -0
- package/src/services/ssh.js +176 -0
- package/src/services/websocket.js +112 -0
- package/src/skills/loader.js +231 -0
- package/src/tools/browser.js +434 -0
- package/src/tools/local.js +1254 -0
- package/src/tools/mcp-client.js +209 -0
- package/src/tools/registry.js +132 -0
- package/src/tools/search-providers.js +237 -0
- package/src/tools/ssh.js +74 -0
- package/src/ui/App.js +2031 -0
- package/src/ui/animation.js +47 -0
- package/src/ui/components/AskUserDialog.js +33 -0
- package/src/ui/components/ConfirmationDialog.js +45 -0
- package/src/ui/components/DiffView.js +201 -0
- package/src/ui/components/Header.js +157 -0
- package/src/ui/components/HistoryPicker.js +130 -0
- package/src/ui/components/InputShell.js +22 -0
- package/src/ui/components/MessageHistory.js +1200 -0
- package/src/ui/components/ModalPanel.js +40 -0
- package/src/ui/components/ModePicker.js +161 -0
- package/src/ui/components/PlanDialog.js +48 -0
- package/src/ui/components/ProviderMenu.js +1095 -0
- package/src/ui/components/SavePicker.js +106 -0
- package/src/ui/components/SelectMenu.js +194 -0
- package/src/ui/components/SlashMenu.js +168 -0
- package/src/ui/components/SubagentPanel.js +138 -0
- package/src/ui/components/TextInputSafe.js +117 -0
- package/src/ui/components/TodoPanel.js +54 -0
- package/src/ui/components/ToolExecution.js +261 -0
- package/src/ui/components/TranscriptViewport.js +99 -0
- package/src/ui/diff.js +249 -0
- package/src/ui/h.js +7 -0
- package/src/ui/mouse-scroll.js +63 -0
- package/src/ui/slash-picker.js +58 -0
- package/src/ui/terminal.js +41 -0
- package/src/ui/theme.js +5 -0
- package/src/ui/welcome.js +12 -0
- package/src/utils/constants.js +231 -0
- package/src/utils/helpers.js +154 -0
- package/src/utils/logger.js +81 -0
- package/src/utils/sound.js +33 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// OS AI Agent — Subagent Runner (max 1 concurrent)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Spawns a read-only child agent for exploration. Results are returned to the
|
|
5
|
+
// parent via structured [SUBAGENT_RESULT] tool output.
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
import { AgentLoop } from './react-loop.js';
|
|
9
|
+
import { SUBAGENT_MAX_ITERATIONS } from '../utils/constants.js';
|
|
10
|
+
import { logger } from '../utils/logger.js';
|
|
11
|
+
|
|
12
|
+
let _subagentCounter = 0;
|
|
13
|
+
const STREAM_SUBAGENT_TO_PARENT = process.env.OSAI_SUBAGENT_STREAM === '1';
|
|
14
|
+
|
|
15
|
+
function nextSubagentId() {
|
|
16
|
+
_subagentCounter += 1;
|
|
17
|
+
return `subagent-${Date.now()}-${_subagentCounter}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function emitSubagentObservation(parent, payload) {
|
|
21
|
+
if (typeof parent.onObservation === 'function') {
|
|
22
|
+
parent.onObservation(payload);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run a single read-only subagent and return findings to the parent agent.
|
|
28
|
+
* @param {import('./react-loop.js').AgentLoop} parent
|
|
29
|
+
* @param {{ prompt: string, description?: string }} task
|
|
30
|
+
*/
|
|
31
|
+
export async function runSubagent(parent, { prompt, description = '' }) {
|
|
32
|
+
const cleanPrompt = String(prompt || '').trim();
|
|
33
|
+
const cleanDescription = String(description || '').trim();
|
|
34
|
+
|
|
35
|
+
if (!cleanPrompt) {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
output: '',
|
|
39
|
+
error: 'Subagent task is empty. Provide a concrete exploration prompt.',
|
|
40
|
+
subagentLaunched: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (parent.isSubagent) {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
output: '',
|
|
48
|
+
error: 'Nested subagents are not allowed. The parent agent must handle this task directly.',
|
|
49
|
+
subagentLaunched: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parent._subagentActive) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
output: '',
|
|
57
|
+
error: 'A subagent is already running. Wait for it to finish before launching another (max 1 subagent).',
|
|
58
|
+
subagentLaunched: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const subagentId = nextSubagentId();
|
|
63
|
+
const label = cleanDescription || cleanPrompt.slice(0, 80);
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
|
|
66
|
+
parent._subagentActive = true;
|
|
67
|
+
parent._escapeFirstPress = false;
|
|
68
|
+
parent._subagentState = {
|
|
69
|
+
id: subagentId,
|
|
70
|
+
description: label,
|
|
71
|
+
status: 'running',
|
|
72
|
+
startedAt,
|
|
73
|
+
endedAt: null,
|
|
74
|
+
iterations: 0,
|
|
75
|
+
toolsUsed: 0,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
emitSubagentObservation(parent, {
|
|
79
|
+
type: 'subagent_start',
|
|
80
|
+
id: subagentId,
|
|
81
|
+
description: label,
|
|
82
|
+
prompt: cleanPrompt.slice(0, 500),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let subLoop = null;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
let completionPayload = null;
|
|
89
|
+
const transcript = [];
|
|
90
|
+
let toolsUsed = 0;
|
|
91
|
+
let estimatedTokens = 0;
|
|
92
|
+
const parentBaseTokens = Number(parent.totalTokensUsed || 0);
|
|
93
|
+
|
|
94
|
+
subLoop = new AgentLoop({
|
|
95
|
+
device: parent.device,
|
|
96
|
+
mode: parent.mode,
|
|
97
|
+
executionMode: 'PLAN',
|
|
98
|
+
server: parent.server,
|
|
99
|
+
token: parent.token,
|
|
100
|
+
noConfirm: true,
|
|
101
|
+
isSubagent: true,
|
|
102
|
+
maxIterations: SUBAGENT_MAX_ITERATIONS,
|
|
103
|
+
readline: parent.readline,
|
|
104
|
+
onMarkdown: (text) => {
|
|
105
|
+
transcript.push(text);
|
|
106
|
+
if (STREAM_SUBAGENT_TO_PARENT) parent.onMarkdown(text);
|
|
107
|
+
},
|
|
108
|
+
onUpdateLastText: (text) => {
|
|
109
|
+
if (STREAM_SUBAGENT_TO_PARENT) parent.onUpdateLastText(text);
|
|
110
|
+
},
|
|
111
|
+
onThought: (text) => {
|
|
112
|
+
if (STREAM_SUBAGENT_TO_PARENT) parent.onThought(text);
|
|
113
|
+
},
|
|
114
|
+
onToolStart: (tc, safety) => {
|
|
115
|
+
toolsUsed += 1;
|
|
116
|
+
if (parent._subagentState) parent._subagentState.toolsUsed = toolsUsed;
|
|
117
|
+
emitSubagentObservation(parent, {
|
|
118
|
+
type: 'subagent_progress',
|
|
119
|
+
id: subagentId,
|
|
120
|
+
tool: tc.tool,
|
|
121
|
+
target: tc.path || tc.cmd || tc.query || tc.url || '',
|
|
122
|
+
});
|
|
123
|
+
parent.onToolStart({ ...tc, _subagent: true, _subagentId: subagentId }, safety);
|
|
124
|
+
},
|
|
125
|
+
onToolResult: (tc, res) => parent.onToolResult({ ...tc, _subagent: true, _subagentId: subagentId }, res),
|
|
126
|
+
onObservation: (observation) => {
|
|
127
|
+
if (STREAM_SUBAGENT_TO_PARENT) parent.onObservation(observation);
|
|
128
|
+
},
|
|
129
|
+
onError: (msg) => {
|
|
130
|
+
transcript.push(`[ERROR] ${msg}`);
|
|
131
|
+
if (STREAM_SUBAGENT_TO_PARENT) parent.onError(`Subagent: ${msg}`);
|
|
132
|
+
},
|
|
133
|
+
onComplete: (stats) => {
|
|
134
|
+
completionPayload = stats;
|
|
135
|
+
},
|
|
136
|
+
onTodos: () => {},
|
|
137
|
+
onBlocked: (msg) => transcript.push(`[BLOCKED] ${msg}`),
|
|
138
|
+
onThinkingStart: () => {},
|
|
139
|
+
onThinkingEnd: () => {},
|
|
140
|
+
onBadge: () => {},
|
|
141
|
+
onStats: (tokens) => {
|
|
142
|
+
estimatedTokens = Number(tokens || 0);
|
|
143
|
+
if (parent._subagentState) parent._subagentState.estimatedTokens = estimatedTokens;
|
|
144
|
+
emitSubagentObservation(parent, {
|
|
145
|
+
type: 'subagent_stats',
|
|
146
|
+
id: subagentId,
|
|
147
|
+
estimatedTokens,
|
|
148
|
+
totalEstimatedTokens: parentBaseTokens + estimatedTokens,
|
|
149
|
+
});
|
|
150
|
+
parent.onStats(parentBaseTokens + estimatedTokens);
|
|
151
|
+
},
|
|
152
|
+
onConfirmPrompt: () => {},
|
|
153
|
+
onAskUserPrompt: () => {},
|
|
154
|
+
onPlanPrompt: () => {},
|
|
155
|
+
onConnectionChange: () => {},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Store reference to subagent loop for cancellation
|
|
159
|
+
parent._subagentLoop = subLoop;
|
|
160
|
+
|
|
161
|
+
await subLoop.run(
|
|
162
|
+
`${cleanPrompt}\n\n[SUBAGENT RULES]
|
|
163
|
+
- You are a read-only exploration subagent working on behalf of the parent agent.
|
|
164
|
+
- Do NOT modify files, run write commands, create skills, or spawn another subagent.
|
|
165
|
+
- Do NOT ask the user for confirmation. If a file/path/tool requires confirmation or is blocked as sensitive, skip it, continue with other non-sensitive read-only exploration, and mention the skipped target in your final summary.
|
|
166
|
+
- Allowed: READ_FILE, GLOB, GREP, LIST_DIR, TREE_VIEW, FILE_INFO, FETCH_URL, WEB_SEARCH, LOCAL_CMD (read-only), GIT (status/diff/log/branch only).
|
|
167
|
+
- You must use tools to gather facts. Do not answer from assumptions.
|
|
168
|
+
- When finished, write a clear summary with file paths and key facts.
|
|
169
|
+
- End with [DONE] and a concise summary the parent agent can act on.`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const history = subLoop.getConversationHistory();
|
|
173
|
+
const lastAssistant = [...history].reverse().find((m) => m.role === 'assistant');
|
|
174
|
+
const findings = lastAssistant?.content?.trim() || transcript.join('\n').trim() || 'Subagent finished with no summary.';
|
|
175
|
+
const iterations = completionPayload?.iterations || subLoop.iteration;
|
|
176
|
+
const completionSignal = completionPayload?.completionSignal || 'DONE';
|
|
177
|
+
const endedAt = Date.now();
|
|
178
|
+
|
|
179
|
+
parent._subagentState = {
|
|
180
|
+
...parent._subagentState,
|
|
181
|
+
status: 'completed',
|
|
182
|
+
endedAt,
|
|
183
|
+
iterations,
|
|
184
|
+
toolsUsed,
|
|
185
|
+
estimatedTokens,
|
|
186
|
+
completionSignal,
|
|
187
|
+
findingsPreview: findings.slice(0, 200),
|
|
188
|
+
};
|
|
189
|
+
parent._lastSubagentResult = { id: subagentId, findings, iterations, completionSignal, estimatedTokens };
|
|
190
|
+
|
|
191
|
+
emitSubagentObservation(parent, {
|
|
192
|
+
type: 'subagent_end',
|
|
193
|
+
id: subagentId,
|
|
194
|
+
success: true,
|
|
195
|
+
iterations,
|
|
196
|
+
toolsUsed,
|
|
197
|
+
estimatedTokens,
|
|
198
|
+
totalEstimatedTokens: parentBaseTokens + estimatedTokens,
|
|
199
|
+
completionSignal,
|
|
200
|
+
durationMs: endedAt - startedAt,
|
|
201
|
+
findingsPreview: findings.slice(0, 200),
|
|
202
|
+
findings: findings.slice(0, 8000),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const output = [
|
|
206
|
+
`[SUBAGENT_COMPLETE] id=${subagentId}`,
|
|
207
|
+
`Status: SUCCESS`,
|
|
208
|
+
`Iterations: ${iterations}`,
|
|
209
|
+
`Tools used: ${toolsUsed}`,
|
|
210
|
+
`Estimated tokens: ${estimatedTokens}`,
|
|
211
|
+
`Completion: ${completionSignal}`,
|
|
212
|
+
label ? `Task: ${label}` : '',
|
|
213
|
+
'',
|
|
214
|
+
'--- FINDINGS (use these to continue your work) ---',
|
|
215
|
+
findings.slice(0, 12000),
|
|
216
|
+
'',
|
|
217
|
+
'The subagent has finished. Review the findings above and continue as the parent agent.',
|
|
218
|
+
].filter(Boolean).join('\n');
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
success: true,
|
|
222
|
+
output,
|
|
223
|
+
subagentLaunched: true,
|
|
224
|
+
subagentId,
|
|
225
|
+
iterations,
|
|
226
|
+
toolsUsed,
|
|
227
|
+
estimatedTokens,
|
|
228
|
+
completionSignal,
|
|
229
|
+
findings,
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
logger.error('Subagent failed', { error: err.message });
|
|
233
|
+
|
|
234
|
+
parent._subagentState = {
|
|
235
|
+
...parent._subagentState,
|
|
236
|
+
status: 'failed',
|
|
237
|
+
endedAt: Date.now(),
|
|
238
|
+
error: err.message,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
emitSubagentObservation(parent, {
|
|
242
|
+
type: 'subagent_end',
|
|
243
|
+
id: subagentId,
|
|
244
|
+
success: false,
|
|
245
|
+
error: err.message,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
output: '',
|
|
251
|
+
error: `Subagent failed: ${err.message}`,
|
|
252
|
+
subagentLaunched: true,
|
|
253
|
+
subagentId,
|
|
254
|
+
};
|
|
255
|
+
} finally {
|
|
256
|
+
parent._subagentActive = false;
|
|
257
|
+
parent._subagentLoop = null;
|
|
258
|
+
parent._escapeFirstPress = false;
|
|
259
|
+
if (subLoop) {
|
|
260
|
+
try { await subLoop.cleanup(); } catch {}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { printInfo, printSuccess } from '../ui/terminal.js';
|
|
5
|
+
import { APP_VERSION } from '../utils/constants.js';
|
|
6
|
+
|
|
7
|
+
export const detectDefaultOS = () => {
|
|
8
|
+
const p = process.platform;
|
|
9
|
+
if (p === 'win32') return 'windows';
|
|
10
|
+
if (p === 'darwin') return 'macos';
|
|
11
|
+
return 'linux';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const showConfig = () => {
|
|
15
|
+
const config = new Conf({ projectName: 'osai-agent' });
|
|
16
|
+
const os = config.get('os') || `${detectDefaultOS()} (auto-detected)`;
|
|
17
|
+
printInfo(`Version: ${APP_VERSION}`);
|
|
18
|
+
printInfo(`Server: ${config.get('server') || 'not configured'}`);
|
|
19
|
+
printInfo(`Token: ${config.get('token') ? 'present' : 'absent'}`);
|
|
20
|
+
printInfo(`User ID: ${config.get('userId') || 'absent'}`);
|
|
21
|
+
printInfo(`Plan: ${config.get('plan') || 'absent'}`);
|
|
22
|
+
printInfo(`OS: ${os}`);
|
|
23
|
+
printInfo(`Auto-cont: ${process.env.OSAI_AUTO_CONTINUE || '3 (default)'}`);
|
|
24
|
+
printInfo(`Max iter: ${process.env.OSAI_MAX_ITERATIONS || '30 (default)'}`);
|
|
25
|
+
printInfo(`Debug: ${process.env.OSAI_DEBUG || 'false (default)'}`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const setOS = async () => {
|
|
29
|
+
const config = new Conf({ projectName: 'osai-agent' });
|
|
30
|
+
const current = config.get('os');
|
|
31
|
+
const answer = await inquirer.prompt({
|
|
32
|
+
type: 'list', name: 'os', message: 'Select your operating system',
|
|
33
|
+
default: current || detectDefaultOS(),
|
|
34
|
+
choices: [{ name: 'Windows', value: 'windows' }, { name: 'macOS', value: 'macos' }, { name: 'Linux', value: 'linux' }]
|
|
35
|
+
});
|
|
36
|
+
config.set('os', answer.os);
|
|
37
|
+
printSuccess(`OS configured: ${answer.os}`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const showOS = () => {
|
|
41
|
+
const config = new Conf({ projectName: 'osai-agent' });
|
|
42
|
+
const os = config.get('os');
|
|
43
|
+
if (!os) {
|
|
44
|
+
printInfo(`OS: not configured (auto-detected: ${detectDefaultOS()})`);
|
|
45
|
+
printInfo('To set explicitly: osai-agent config set-os');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
printInfo(`OS: ${os}`);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const showVersion = () => {
|
|
52
|
+
console.log(chalk.hex('#7aa2f7').bold(`OS AI Agent v${APP_VERSION}`));
|
|
53
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import pkg from 'node-machine-id';
|
|
2
|
+
const { machineIdSync } = pkg;
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { render } from 'ink';
|
|
7
|
+
import { App } from '../ui/App.js';
|
|
8
|
+
import { h } from '../ui/h.js';
|
|
9
|
+
import { AgentLoop } from '../agent/react-loop.js';
|
|
10
|
+
import { decrypt, deriveKey } from '../services/crypto.js';
|
|
11
|
+
import { toHttpUrl, toWsUrl } from '../services/server-url.js';
|
|
12
|
+
import { MODES } from '../utils/constants.js';
|
|
13
|
+
import { printNotLoggedIn } from '../ui/terminal.js';
|
|
14
|
+
import ora from 'ora';
|
|
15
|
+
import { connectSSH } from '../services/ssh.js';
|
|
16
|
+
|
|
17
|
+
export const connect = async ({ device: deviceOverride, server: serverOverride, noConfirm = false } = {}) => {
|
|
18
|
+
const config = new Conf({ projectName: 'osai-agent' });
|
|
19
|
+
const token = config.get('token');
|
|
20
|
+
const server = toWsUrl(serverOverride ? `ws://${serverOverride}` : config.get('server'));
|
|
21
|
+
const httpServer = toHttpUrl(server);
|
|
22
|
+
|
|
23
|
+
if (!token) { printNotLoggedIn(); return; }
|
|
24
|
+
|
|
25
|
+
let devices = [];
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(`${httpServer}/devices`, { headers: { Authorization: `Bearer ${token}` } });
|
|
28
|
+
devices = await response.json();
|
|
29
|
+
if (!devices.length) { console.error('No devices. Add one with: osai-agent devices add'); return; }
|
|
30
|
+
} catch (err) { console.error(err.message); return; }
|
|
31
|
+
|
|
32
|
+
let deviceId = deviceOverride;
|
|
33
|
+
if (!deviceId) {
|
|
34
|
+
const answer = await inquirer.prompt({
|
|
35
|
+
type: 'input', name: 'deviceName', message: 'Device name',
|
|
36
|
+
validate: (v) => Boolean(v.trim()) || 'Device name required'
|
|
37
|
+
});
|
|
38
|
+
deviceId = answer.deviceName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const search = String(deviceId).trim();
|
|
42
|
+
const device = devices.find((item, index) => {
|
|
43
|
+
const id = String(item._id);
|
|
44
|
+
return id === search
|
|
45
|
+
|| id.startsWith(search)
|
|
46
|
+
|| String(index + 1) === search
|
|
47
|
+
|| item.name.toLowerCase() === search.toLowerCase()
|
|
48
|
+
|| item.name.toLowerCase().includes(search.toLowerCase());
|
|
49
|
+
});
|
|
50
|
+
if (!device) {
|
|
51
|
+
console.log(`Device "${deviceId}" not found.\n`);
|
|
52
|
+
console.log('Available devices:');
|
|
53
|
+
devices.forEach((d, i) => {
|
|
54
|
+
console.log(` ${i+1}. ${d.name} (${d.type}) ${d.ip}`);
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const machineId = machineIdSync();
|
|
60
|
+
const key = deriveKey(machineId);
|
|
61
|
+
let auth = {};
|
|
62
|
+
try {
|
|
63
|
+
auth = device.auth_encrypted ? JSON.parse(decrypt(device.auth_encrypted, key)) : {};
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`Failed to decrypt device credentials. Try re-adding the device.`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const deviceWithAuth = { ...device, auth_decrypted: auth };
|
|
69
|
+
|
|
70
|
+
// Test SSH connection before launching UI
|
|
71
|
+
const sshUser = auth.username || 'root';
|
|
72
|
+
let sshPassword = auth.password;
|
|
73
|
+
let connected = false;
|
|
74
|
+
|
|
75
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
76
|
+
const spinner = ora({
|
|
77
|
+
text: `Connecting to ${sshUser}@${device.ip}...`,
|
|
78
|
+
spinner: 'dots'
|
|
79
|
+
}).start();
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const conn = await connectSSH({
|
|
83
|
+
host: device.ip,
|
|
84
|
+
port: device.port || 22,
|
|
85
|
+
username: sshUser,
|
|
86
|
+
password: sshPassword,
|
|
87
|
+
}, device.type);
|
|
88
|
+
|
|
89
|
+
conn.end();
|
|
90
|
+
spinner.succeed(`Connected to ${sshUser}@${device.ip}`);
|
|
91
|
+
connected = true;
|
|
92
|
+
break;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
spinner.fail(`Connection failed: ${err.message}`);
|
|
95
|
+
|
|
96
|
+
if (attempt >= 2) {
|
|
97
|
+
console.error('Maximum connection attempts reached.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const answer = await inquirer.prompt({
|
|
102
|
+
type: 'password',
|
|
103
|
+
name: 'password',
|
|
104
|
+
message: `Password for ${sshUser}@${device.ip}:`,
|
|
105
|
+
mask: '*',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
sshPassword = answer.password;
|
|
109
|
+
auth.password = sshPassword;
|
|
110
|
+
deviceWithAuth.auth_decrypted = { ...auth };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!connected) return;
|
|
115
|
+
|
|
116
|
+
const agentConfig = {
|
|
117
|
+
mode: /cisco|mikrotik|pfsense|junos/.test(device.type?.toLowerCase() || '') ? MODES.NETWORK : MODES.SSH,
|
|
118
|
+
server: httpServer,
|
|
119
|
+
token,
|
|
120
|
+
noConfirm,
|
|
121
|
+
device: deviceWithAuth,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const createAgentLoop = (options) => {
|
|
125
|
+
return new AgentLoop(options);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Disable XON/XOFF immediately (prevents Ctrl+S from freezing the terminal)
|
|
129
|
+
if (process.stdout.isTTY) {
|
|
130
|
+
try { execSync('stty sane', { stdio: 'ignore' }); } catch {}
|
|
131
|
+
try { execSync('stty -ixon', { stdio: 'ignore' }); } catch {}
|
|
132
|
+
try { execSync('stty discard undef', { stdio: 'ignore' }); } catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Restore terminal state on unexpected signals
|
|
136
|
+
const restoreTerminal = () => {
|
|
137
|
+
try {
|
|
138
|
+
process.stdout.write('\x1B[?25h');
|
|
139
|
+
process.stdout.write('\x1B[?7h');
|
|
140
|
+
if (process.stdin.isTTY) process.stdin.setRawMode?.(false);
|
|
141
|
+
if (process.stdout.isTTY) {
|
|
142
|
+
try { execSync('stty sane', { stdio: 'ignore' }); } catch {}
|
|
143
|
+
try { execSync('stty -ixon', { stdio: 'ignore' }); } catch {}
|
|
144
|
+
try { execSync('stty discard undef', { stdio: 'ignore' }); } catch {}
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const sigHandler = () => {
|
|
150
|
+
restoreTerminal();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
};
|
|
153
|
+
process.prependListener('SIGINT', sigHandler);
|
|
154
|
+
process.prependListener('SIGTERM', sigHandler);
|
|
155
|
+
|
|
156
|
+
process.stdout.write('\x1Bc');
|
|
157
|
+
await new Promise(r => setTimeout(r, 100));
|
|
158
|
+
const isLinux = process.platform === 'linux';
|
|
159
|
+
const envFps = Number.parseInt(process.env.OSAI_MAX_FPS || '', 10);
|
|
160
|
+
const maxFps = Number.isFinite(envFps) && envFps > 0 ? envFps : (isLinux ? 10 : 15);
|
|
161
|
+
|
|
162
|
+
let clear, waitUntilExit;
|
|
163
|
+
try {
|
|
164
|
+
({ clear, waitUntilExit } = render(h(App, {
|
|
165
|
+
createAgentLoop,
|
|
166
|
+
agentConfig,
|
|
167
|
+
onExit: () => {
|
|
168
|
+
clear();
|
|
169
|
+
restoreTerminal();
|
|
170
|
+
process.removeListener('SIGINT', sigHandler);
|
|
171
|
+
process.removeListener('SIGTERM', sigHandler);
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
}), {
|
|
175
|
+
incrementalRendering: true,
|
|
176
|
+
maxFps,
|
|
177
|
+
}));
|
|
178
|
+
} catch {
|
|
179
|
+
restoreTerminal();
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Re-disable XON/XOFF after Ink init
|
|
184
|
+
if (process.stdout.isTTY) {
|
|
185
|
+
try { execSync('stty -ixon', { stdio: 'ignore' }); } catch {}
|
|
186
|
+
try { execSync('stty discard undef', { stdio: 'ignore' }); } catch {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await waitUntilExit;
|
|
190
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import pkg from 'node-machine-id';
|
|
2
|
+
const { machineIdSync } = pkg;
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import { encrypt, decrypt, deriveKey } from '../services/crypto.js';
|
|
8
|
+
import { clearScreen, printDeviceList, printError, printShell, printSuccess, printInfo, printNotLoggedIn } from '../ui/terminal.js';
|
|
9
|
+
import { toHttpUrl } from '../services/server-url.js';
|
|
10
|
+
|
|
11
|
+
const getConfig = () => new Conf({ projectName: 'osai-agent' });
|
|
12
|
+
const DEVICE_TYPES = ['Cisco IOS', 'Cisco IOS-XE', 'Cisco ASA', 'Cisco NX-OS', 'MikroTik RouterOS', 'Linux', 'pfSense', 'Juniper JunOS', 'Windows Server'];
|
|
13
|
+
const getServer = () => toHttpUrl(getConfig().get('server'));
|
|
14
|
+
const getToken = () => getConfig().get('token');
|
|
15
|
+
|
|
16
|
+
const isLocalMode = () => {
|
|
17
|
+
const argv = process.argv.slice(2);
|
|
18
|
+
return argv.includes('--local');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function getLocalDevices() {
|
|
22
|
+
const config = getConfig();
|
|
23
|
+
return config.get('localDevices', []);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveLocalDevices(devices) {
|
|
27
|
+
const config = getConfig();
|
|
28
|
+
config.set('localDevices', devices);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const listDevices = async () => {
|
|
32
|
+
if (isLocalMode()) {
|
|
33
|
+
const devices = getLocalDevices();
|
|
34
|
+
if (!devices.length) {
|
|
35
|
+
printInfo('No local devices configured. Add one with: osai-agent devices add --local');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
clearScreen(); printShell('osai-agent - local devices'); console.log();
|
|
39
|
+
printDeviceList(devices); console.log();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const token = getToken();
|
|
44
|
+
if (!token) { printNotLoggedIn(); return; }
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(`${getServer()}/devices`, { headers: { 'Authorization': `Bearer ${token}` } });
|
|
47
|
+
const devices = await response.json();
|
|
48
|
+
if (!devices.length) { printInfo('No devices configured. Add one with: osai-agent devices add'); return; }
|
|
49
|
+
clearScreen(); printShell('osai-agent - devices'); console.log();
|
|
50
|
+
printDeviceList(devices); console.log();
|
|
51
|
+
} catch (err) { printError(err.message); }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const addDevice = async () => {
|
|
55
|
+
clearScreen(); printShell('osai-agent - new device'); console.log();
|
|
56
|
+
|
|
57
|
+
const answers = await inquirer.prompt([
|
|
58
|
+
{ type: 'input', name: 'name', message: 'Device name', validate: (v) => Boolean(v.trim()) || 'Name required' },
|
|
59
|
+
{ type: 'list', name: 'type', message: 'Type', choices: DEVICE_TYPES },
|
|
60
|
+
{ type: 'input', name: 'ip', message: 'IP address or hostname', validate: (v) => Boolean(v.trim()) || 'Address required' },
|
|
61
|
+
{ type: 'number', name: 'port', message: 'SSH Port', default: 22 },
|
|
62
|
+
{ type: 'input', name: 'username', message: 'SSH Username', validate: (v) => Boolean(v.trim()) || 'Username required' },
|
|
63
|
+
{ type: 'password', name: 'password', message: 'SSH Password (or key passphrase)', mask: '*' }
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Normalize device type (case-insensitive) before sending
|
|
67
|
+
const normalizedType = DEVICE_TYPES.find(t => t.toLowerCase() === answers.type.toLowerCase()) || answers.type;
|
|
68
|
+
|
|
69
|
+
const machineId = machineIdSync();
|
|
70
|
+
const key = deriveKey(machineId);
|
|
71
|
+
const authEncrypted = encrypt(JSON.stringify({ username: answers.username, password: answers.password }), key);
|
|
72
|
+
|
|
73
|
+
if (isLocalMode()) {
|
|
74
|
+
const devices = getLocalDevices();
|
|
75
|
+
const id = `local_${Date.now()}`;
|
|
76
|
+
devices.push({ id, name: answers.name, type: normalizedType, ip: answers.ip, port: answers.port || 22, auth_encrypted: authEncrypted });
|
|
77
|
+
saveLocalDevices(devices);
|
|
78
|
+
printSuccess(`Device saved locally: ${id}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const token = getToken();
|
|
83
|
+
if (!token) { printNotLoggedIn(); return; }
|
|
84
|
+
|
|
85
|
+
const spinner = ora('Registering device...').start();
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`${getServer()}/devices`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ name: answers.name, type: normalizedType, ip: answers.ip, port: answers.port || 22, auth_encrypted: authEncrypted })
|
|
91
|
+
});
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
if (!response.ok) { spinner.fail('Registration refused'); printError(data.error || 'Error during creation'); return; }
|
|
94
|
+
spinner.succeed('Device registered');
|
|
95
|
+
printSuccess(`Device created: ${data.id}`);
|
|
96
|
+
} catch (err) { spinner.fail('Unable to register'); printError(err.message); }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const removeDevice = async (deviceId) => {
|
|
100
|
+
if (!deviceId) { printError('Usage: osai-agent devices remove <id>'); return; }
|
|
101
|
+
|
|
102
|
+
if (isLocalMode()) {
|
|
103
|
+
const devices = getLocalDevices();
|
|
104
|
+
const filtered = devices.filter(d => d.id !== deviceId);
|
|
105
|
+
if (filtered.length === devices.length) {
|
|
106
|
+
printError(`Device not found: ${deviceId}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
saveLocalDevices(filtered);
|
|
110
|
+
printSuccess('Local device deleted');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const token = getToken();
|
|
115
|
+
if (!token) { printNotLoggedIn(); return; }
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetch(`${getServer()}/devices/${deviceId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
|
|
118
|
+
if (!response.ok) { const data = await response.json(); printError(data.error || 'Error during deletion'); return; }
|
|
119
|
+
printSuccess('Device deleted');
|
|
120
|
+
} catch (err) { printError(err.message); }
|
|
121
|
+
};
|