omnikey-cli 1.0.17 → 1.0.19
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/README.md +42 -2
- package/backend-dist/agent/agentPrompts.js +40 -73
- package/backend-dist/agent/agentServer.js +145 -126
- package/backend-dist/index.js +3 -3
- package/dist/daemon.js +97 -94
- package/dist/index.js +4 -4
- package/dist/removeConfig.js +32 -14
- package/package.json +1 -1
- package/src/daemon.ts +107 -94
- package/src/index.ts +4 -4
- package/src/removeConfig.ts +27 -17
package/README.md
CHANGED
|
@@ -81,9 +81,49 @@ The daemon is registered as a **launchd agent** (`~/Library/LaunchAgents/com.omn
|
|
|
81
81
|
|
|
82
82
|
### Windows
|
|
83
83
|
|
|
84
|
-
The daemon
|
|
84
|
+
The daemon runs as a **Windows Service** managed by [NSSM (Non-Sucking Service Manager)](https://nssm.cc/). This gives it production-grade persistence:
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
| Behaviour | Detail |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Starts on boot | Runs as `SERVICE_AUTO_START` — no login required |
|
|
89
|
+
| Auto-restarts on crash | Restarts after a 3-second delay on any unexpected exit |
|
|
90
|
+
| Runs in the background | No console window, no logged-in user needed |
|
|
91
|
+
| Log rotation | stdout/stderr written to `~/.omnikey/daemon.log` and `daemon-error.log` with rotation enabled |
|
|
92
|
+
|
|
93
|
+
#### Prerequisites
|
|
94
|
+
|
|
95
|
+
NSSM must be installed and the command must be run from an **elevated (Administrator) terminal**.
|
|
96
|
+
|
|
97
|
+
If NSSM is not found, `omnikey daemon` will prompt you:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
? NSSM is required but not found. Install it now via winget? (Y/n)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Answering **Y** runs `winget install nssm` automatically and continues setup. Alternatively install it manually:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
winget install nssm # Windows Package Manager (built-in on Win 10/11)
|
|
107
|
+
scoop install nssm # Scoop
|
|
108
|
+
choco install nssm # Chocolatey
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### Service management
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
# View service status in Services console
|
|
115
|
+
services.msc
|
|
116
|
+
|
|
117
|
+
# Or via the command line
|
|
118
|
+
sc query OmnikeyDaemon
|
|
119
|
+
|
|
120
|
+
# Stop / start manually
|
|
121
|
+
nssm stop OmnikeyDaemon
|
|
122
|
+
nssm start OmnikeyDaemon
|
|
123
|
+
|
|
124
|
+
# Uninstall (also done automatically by omnikey kill-daemon / remove-config)
|
|
125
|
+
nssm remove OmnikeyDaemon confirm
|
|
126
|
+
```
|
|
87
127
|
|
|
88
128
|
Commands that query process state use `netstat` (instead of `lsof`) on Windows, and process termination uses `taskkill` (instead of `SIGTERM`).
|
|
89
129
|
|
|
@@ -2,96 +2,63 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getAgentPrompt = getAgentPrompt;
|
|
4
4
|
const config_1 = require("../config");
|
|
5
|
-
function getAgentPrompt(platform) {
|
|
5
|
+
function getAgentPrompt(platform, hasTaskInstructions) {
|
|
6
6
|
const isWindows = config_1.config.terminalPlatform?.toLowerCase() === 'windows' || platform?.toLowerCase() === 'windows';
|
|
7
|
-
const windowsShellScriptInstructions = `
|
|
8
|
-
\`\`\`
|
|
9
|
-
<shell_script>
|
|
10
|
-
# your commands here
|
|
11
|
-
</shell_script>
|
|
12
|
-
\`\`\`
|
|
13
|
-
|
|
14
|
-
Follow these guidelines:
|
|
15
|
-
|
|
16
|
-
- Use a single, self-contained PowerShell script per response; do not send multiple \`<shell_script>\` blocks in one turn.
|
|
17
|
-
- Inside the script, group related commands logically and add brief inline comments only when they clarify non-obvious or complex steps.
|
|
18
|
-
- Prefer safe, idempotent commands that can be run multiple times without unintended side effects.
|
|
19
|
-
- Never use elevated privileges (do not use \`sudo\`, \`Run as Administrator\`, or equivalent).
|
|
20
|
-
- Use PowerShell cmdlets and syntax (for example, \`Get-ChildItem\`, \`Select-Object\`, \`Where-Object\`) rather than cmd.exe or bash equivalents.`;
|
|
21
7
|
return `
|
|
22
|
-
You are an AI assistant
|
|
23
|
-
|
|
24
|
-
Your responsibilities are:
|
|
25
|
-
1. **Read and respect stored instructions**: When provided with \`<stored_instructions>\`, follow them carefully regarding behavior, focus areas, and output style.
|
|
26
|
-
2. **Process user input**: Analyze what the user has typed or requested.
|
|
27
|
-
3. **Gather context when needed**: Decide if additional machine-level information is required. If so, generate appropriate shell scripts to collect it.
|
|
28
|
-
4. **Produce a complete answer**: Combine results from any previously executed scripts, the stored instructions, and the user input to deliver a helpful final response.
|
|
8
|
+
You are an AI assistant with full terminal access. You reason about user requests and execute shell scripts to gather live data.
|
|
29
9
|
|
|
30
|
-
**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
- Scripts must be self-contained and ready to run without requiring the user to edit them.
|
|
10
|
+
**Input:**
|
|
11
|
+
${hasTaskInstructions
|
|
12
|
+
? `- Follow \`<stored_instructions>\` for behavior, priorities, and output style. Apply them to the goal in \`<user_input>\`.`
|
|
13
|
+
: `- \`<user_input>\` contains \`@omniAgent <question/command>\`. Everything after \`@omniAgent\` is your directive; surrounding text is context.`}
|
|
14
|
+
- Priority order for conflicts: system rules > stored instructions > user input.
|
|
36
15
|
|
|
37
|
-
When
|
|
16
|
+
**When to use shell scripts:**
|
|
17
|
+
- Default to a \`<shell_script>\` for anything involving the machine, network, files, processes, env vars, or system state — never answer these from training data alone.
|
|
18
|
+
- Scripts must be safe and read-only (inspection/diagnostics only). No installs, no data modification, no system changes, no sudo/admin privileges.
|
|
19
|
+
- Use ${!isWindows ? 'bash (macOS/Linux)' : 'PowerShell'}. Scripts must be self-contained and ready to run as-is.
|
|
20
|
+
- One comprehensive script per turn; wait for output only if you genuinely need it to proceed.
|
|
21
|
+
- Skip the script only for purely factual/conversational requests with no live data dependency (e.g. "what is 2+2").
|
|
38
22
|
|
|
39
|
-
**
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
- If
|
|
23
|
+
**When to use web tools:**
|
|
24
|
+
- Use the built-in \`web_fetch\` tool when the user provides a URL that must be retrieved.
|
|
25
|
+
- Use the built-in \`web_search\` tool when the user asks to search online, or when current information (prices, docs, recent events) is needed.
|
|
26
|
+
- If a request needs BOTH machine data AND web search: emit a \`<shell_script>\` first → wait for \`TERMINAL OUTPUT:\` → then call the web tool with concrete values. Never use placeholders like "my IP" in a web query.
|
|
43
27
|
|
|
44
|
-
**
|
|
45
|
-
|
|
46
|
-
- \`
|
|
47
|
-
-
|
|
28
|
+
**Incoming message tags:**
|
|
29
|
+
- \`TERMINAL OUTPUT:\` — stdout/stderr from a prior script. Use it to continue reasoning or emit a follow-up.
|
|
30
|
+
- \`COMMAND ERROR:\` — script failed. Diagnose and emit a corrected \`<shell_script>\` or explain in \`<final_answer>\`.
|
|
31
|
+
- No prefix — direct user message; treat as the primary request.
|
|
48
32
|
|
|
49
|
-
|
|
33
|
+
**Response format — every response must be exactly one of:**
|
|
34
|
+
1. \`<shell_script>...</shell_script>\` — to run commands.
|
|
35
|
+
2. A \`web_search\` or \`web_fetch\` tool call — to fetch web context (use native tool calling, not XML tags).
|
|
36
|
+
3. \`<final_answer>...</final_answer>\` — when done.
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
User messages may be prefixed with special tags that indicate their origin:
|
|
53
|
-
- \`TERMINAL OUTPUT:\` — the content is stdout/stderr returned from a previously requested \`<shell_script>\`. Parse it as machine output and use it to continue your reasoning toward a \`<final_answer>\` or a follow-up \`<shell_script>\`.
|
|
54
|
-
- \`COMMAND ERROR:\` — the shell script failed or the terminal returned a non-zero exit code. Treat the content as error output: diagnose the failure, then either emit a corrected \`<shell_script>\` or explain the issue in a \`<final_answer>\`.
|
|
55
|
-
- No prefix — the content is a direct message from the user; treat it as the primary request or question to address.
|
|
38
|
+
No plain text, reasoning, or other tags outside these blocks. Never wrap in additional XML/JSON.
|
|
56
39
|
|
|
57
|
-
**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- Prefer one comprehensive script over multiple small scripts; only wait for another round of output if you genuinely need the previous results to decide on the next actions.
|
|
61
|
-
- If further machine-level investigation is unnecessary, skip the shell script and respond directly with a \`<final_answer>\`.
|
|
62
|
-
- Every response MUST be exactly one of:
|
|
63
|
-
- A single \`<shell_script>...</shell_script>\` block, and nothing else; or
|
|
64
|
-
- A single \`<final_answer>...</final_answer>\` block, and nothing else.
|
|
65
|
-
- Never send plain text or explanation outside of these tags. If you are not emitting a \`<shell_script>\`, you MUST emit a \`<final_answer>\`.
|
|
66
|
-
- When you are completely finished and ready to present the result back to the user, respond with a single \`<final_answer>\` block.
|
|
67
|
-
- Do NOT include reasoning, commentary, or any other tags outside of \`<shell_script>...</shell_script>\` or \`<final_answer>...</final_answer>\`.
|
|
68
|
-
- Never wrap your entire response in other XML or JSON structures.
|
|
69
|
-
|
|
70
|
-
**Shell script block structure:**
|
|
71
|
-
Always emit exactly this structure when you want to run commands: ${!isWindows
|
|
72
|
-
? `
|
|
73
|
-
\`\`\`bash
|
|
40
|
+
**Shell script structure:**
|
|
41
|
+
${!isWindows
|
|
42
|
+
? `\`\`\`bash
|
|
74
43
|
<shell_script>
|
|
75
44
|
#!/usr/bin/env bash
|
|
76
45
|
set -euo pipefail
|
|
77
|
-
#
|
|
46
|
+
# commands here
|
|
78
47
|
</shell_script>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
When you have gathered enough information and completed the requested work, respond once with:
|
|
48
|
+
\`\`\``
|
|
49
|
+
: `\`\`\`
|
|
50
|
+
<shell_script>
|
|
51
|
+
# PowerShell commands here
|
|
52
|
+
# Use cmdlets (Get-ChildItem, Select-Object, etc.), not cmd.exe/bash equivalents
|
|
53
|
+
# No Run as Administrator
|
|
54
|
+
</shell_script>
|
|
55
|
+
\`\`\``}
|
|
88
56
|
|
|
57
|
+
**Final answer structure:**
|
|
89
58
|
\`\`\`
|
|
90
59
|
<final_answer>
|
|
91
|
-
...
|
|
60
|
+
...summary, key findings, and next steps formatted per stored instructions...
|
|
92
61
|
</final_answer>
|
|
93
62
|
\`\`\`
|
|
94
|
-
|
|
95
|
-
- Do not emit any text before or after the \`<final_answer>\` block; the entire response must be inside the \`<final_answer>\` tags.
|
|
96
63
|
`;
|
|
97
64
|
}
|
|
@@ -49,6 +49,82 @@ const featureRoutes_1 = require("../featureRoutes");
|
|
|
49
49
|
const authMiddleware_1 = require("../authMiddleware");
|
|
50
50
|
const web_search_provider_1 = require("../web-search-provider");
|
|
51
51
|
const ai_client_1 = require("../ai-client");
|
|
52
|
+
async function runToolLoop(initialResult, session, sessionId, send, log, tools, onUsage) {
|
|
53
|
+
const MAX_TOOL_ITERATIONS = 10;
|
|
54
|
+
let toolIterations = 0;
|
|
55
|
+
let result = initialResult;
|
|
56
|
+
while (result.finish_reason === 'tool_calls' && toolIterations < MAX_TOOL_ITERATIONS) {
|
|
57
|
+
toolIterations++;
|
|
58
|
+
const toolCalls = result.tool_calls ?? [];
|
|
59
|
+
// If the model claims tool_calls but sent none, treat it as a normal text
|
|
60
|
+
// response — pushing an assistant message with no following tool results
|
|
61
|
+
// would leave the history ending with an assistant turn, causing a 400.
|
|
62
|
+
if (!toolCalls.length)
|
|
63
|
+
break;
|
|
64
|
+
session.history.push(result.assistantMessage);
|
|
65
|
+
log.info('Agent executing tool calls', {
|
|
66
|
+
sessionId,
|
|
67
|
+
turn: session.turns,
|
|
68
|
+
toolIteration: toolIterations,
|
|
69
|
+
tools: toolCalls.map((tc) => tc.name),
|
|
70
|
+
});
|
|
71
|
+
const toolResults = await Promise.all(toolCalls.map(async (tc) => {
|
|
72
|
+
const args = tc.arguments;
|
|
73
|
+
// Notify the frontend that a web tool call is about to execute.
|
|
74
|
+
const webCallContent = tc.name === 'web_search'
|
|
75
|
+
? `Searching the web for: "${args.query ?? ''}"`
|
|
76
|
+
: `Fetching URL: ${args.url ?? ''}`;
|
|
77
|
+
send({
|
|
78
|
+
session_id: sessionId,
|
|
79
|
+
sender: 'agent',
|
|
80
|
+
content: webCallContent,
|
|
81
|
+
is_terminal_output: false,
|
|
82
|
+
is_error: false,
|
|
83
|
+
is_web_call: true,
|
|
84
|
+
});
|
|
85
|
+
const toolResult = await (0, web_search_provider_1.executeTool)(tc.name, args, log);
|
|
86
|
+
log.info('Tool call completed', {
|
|
87
|
+
sessionId,
|
|
88
|
+
tool: tc.name,
|
|
89
|
+
resultLength: toolResult.length,
|
|
90
|
+
});
|
|
91
|
+
return { id: tc.id, name: tc.name, result: toolResult };
|
|
92
|
+
}));
|
|
93
|
+
for (const { id, name, result: toolResult } of toolResults) {
|
|
94
|
+
session.history.push({
|
|
95
|
+
role: 'tool',
|
|
96
|
+
tool_call_id: id,
|
|
97
|
+
tool_name: name,
|
|
98
|
+
content: toolResult,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// Call the AI again with the tool results in history to get the next response.
|
|
102
|
+
result = await ai_client_1.aiClient.complete(aiModel, session.history, {
|
|
103
|
+
tools: tools.length ? tools : undefined,
|
|
104
|
+
temperature: 0.2,
|
|
105
|
+
});
|
|
106
|
+
await onUsage(result);
|
|
107
|
+
}
|
|
108
|
+
// If we exhausted the iteration cap and the model still wants to call tools,
|
|
109
|
+
// force a final text response by calling again without tools.
|
|
110
|
+
if (result.finish_reason === 'tool_calls') {
|
|
111
|
+
log.warn('Tool loop hit MAX_TOOL_ITERATIONS; forcing final conclusion', { sessionId });
|
|
112
|
+
session.history.push(result.assistantMessage);
|
|
113
|
+
session.history.push({
|
|
114
|
+
role: 'user',
|
|
115
|
+
content: 'You have reached the maximum number of tool calls. Based on all the information gathered so far, provide a single, final, concise answer. Do not call any more tools.',
|
|
116
|
+
});
|
|
117
|
+
result = await ai_client_1.aiClient.complete(aiModel, session.history, {
|
|
118
|
+
tools: undefined,
|
|
119
|
+
temperature: 0.2,
|
|
120
|
+
});
|
|
121
|
+
await onUsage(result);
|
|
122
|
+
}
|
|
123
|
+
log.info('Finished reasoning and tool calls: ', {
|
|
124
|
+
reason: result.finish_reason,
|
|
125
|
+
});
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
52
128
|
function buildAvailableTools() {
|
|
53
129
|
// web_search is always available — DuckDuckGo is used as free fallback
|
|
54
130
|
return [web_search_provider_1.WEB_FETCH_TOOL, web_search_provider_1.WEB_SEARCH_TOOL];
|
|
@@ -64,14 +140,19 @@ async function getOrCreateSession(sessionId, subscription, platform, log) {
|
|
|
64
140
|
subscriptionId: existing.subscription.id,
|
|
65
141
|
turns: existing.turns,
|
|
66
142
|
});
|
|
67
|
-
return
|
|
143
|
+
return {
|
|
144
|
+
sessionState: existing,
|
|
145
|
+
hasStoredPrompt: existing.history
|
|
146
|
+
.filter((h) => h.role === 'user')
|
|
147
|
+
.some((h) => h.content.includes('<stored_instructions>')),
|
|
148
|
+
};
|
|
68
149
|
}
|
|
69
|
-
const systemPrompt = (0, agentPrompts_1.getAgentPrompt)(platform);
|
|
70
150
|
// use these instructions as user instructions
|
|
71
151
|
const prompt = await (0, featureRoutes_1.getPromptForCommand)(log, 'task', subscription).catch((err) => {
|
|
72
152
|
log.error('Failed to get system prompt for new agent session', { error: err });
|
|
73
153
|
return '';
|
|
74
154
|
});
|
|
155
|
+
const systemPrompt = (0, agentPrompts_1.getAgentPrompt)(platform, !!prompt);
|
|
75
156
|
const entry = {
|
|
76
157
|
subscription,
|
|
77
158
|
history: [
|
|
@@ -102,7 +183,10 @@ ${prompt}
|
|
|
102
183
|
subscriptionId: subscription.id,
|
|
103
184
|
hasCustomPrompt: Boolean(prompt),
|
|
104
185
|
});
|
|
105
|
-
return
|
|
186
|
+
return {
|
|
187
|
+
sessionState: entry,
|
|
188
|
+
hasStoredPrompt: !!prompt,
|
|
189
|
+
};
|
|
106
190
|
}
|
|
107
191
|
async function authenticateFromAuthHeader(authHeader, log) {
|
|
108
192
|
if (config_1.config.isSelfHosted) {
|
|
@@ -169,8 +253,11 @@ async function authenticateFromAuthHeader(authHeader, log) {
|
|
|
169
253
|
return null;
|
|
170
254
|
}
|
|
171
255
|
}
|
|
256
|
+
function createUserContent(content, hasStoredPrompt) {
|
|
257
|
+
return hasStoredPrompt ? content.replace(/@omniAgent/g, '').trim() : content;
|
|
258
|
+
}
|
|
172
259
|
async function runAgentTurn(sessionId, subscription, clientMessage, send, log) {
|
|
173
|
-
const session = await getOrCreateSession(sessionId, subscription, clientMessage.platform, log);
|
|
260
|
+
const { sessionState: session, hasStoredPrompt } = await getOrCreateSession(sessionId, subscription, clientMessage.platform, log);
|
|
174
261
|
// Count this call as one agent iteration.
|
|
175
262
|
session.turns += 1;
|
|
176
263
|
log.info('Starting agent turn', {
|
|
@@ -204,10 +291,15 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log) {
|
|
|
204
291
|
rawContentLength: (clientMessage.content || '').length,
|
|
205
292
|
userContentLength: userContent.length,
|
|
206
293
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
294
|
+
const isAssistance = isTerminalOutput || isErrorFlag;
|
|
295
|
+
if (!clientMessage?.is_web_call) {
|
|
296
|
+
session.history.push({
|
|
297
|
+
role: 'user',
|
|
298
|
+
content: isAssistance
|
|
299
|
+
? userContent
|
|
300
|
+
: `<user_input>${createUserContent(userContent, hasStoredPrompt)}</user_input>`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
211
303
|
// On the final turn we omit tools so the model is forced to emit a
|
|
212
304
|
// plain text <final_answer> rather than issuing another tool call.
|
|
213
305
|
const isFinalTurn = session.turns >= MAX_TURNS;
|
|
@@ -249,88 +341,8 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log) {
|
|
|
249
341
|
temperature: 0.2,
|
|
250
342
|
});
|
|
251
343
|
await recordUsage(result);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const MAX_TOOL_ITERATIONS = 10;
|
|
255
|
-
let toolIterations = 0;
|
|
256
|
-
while (result.finish_reason === 'tool_calls' && toolIterations < MAX_TOOL_ITERATIONS) {
|
|
257
|
-
toolIterations++;
|
|
258
|
-
const toolCalls = result.tool_calls ?? [];
|
|
259
|
-
// If the model claims tool_calls but sent none, treat it as a normal text
|
|
260
|
-
// response — pushing an assistant message with no following tool results
|
|
261
|
-
// would leave the history ending with an assistant turn, causing a 400.
|
|
262
|
-
if (!toolCalls.length)
|
|
263
|
-
break;
|
|
264
|
-
session.history.push(result.assistantMessage);
|
|
265
|
-
log.info('Agent executing tool calls', {
|
|
266
|
-
sessionId,
|
|
267
|
-
turn: session.turns,
|
|
268
|
-
toolIteration: toolIterations,
|
|
269
|
-
tools: toolCalls.map((tc) => tc.name),
|
|
270
|
-
});
|
|
271
|
-
const toolResults = await Promise.all(toolCalls.map(async (tc) => {
|
|
272
|
-
const args = tc.arguments;
|
|
273
|
-
// Notify the frontend that a web tool call is about to execute.
|
|
274
|
-
const webCallContent = tc.name === 'web_search'
|
|
275
|
-
? `Searching the web for: "${args.query ?? ''}"`
|
|
276
|
-
: `Fetching URL: ${args.url ?? ''}`;
|
|
277
|
-
send({
|
|
278
|
-
session_id: sessionId,
|
|
279
|
-
sender: 'agent',
|
|
280
|
-
content: webCallContent,
|
|
281
|
-
is_terminal_output: false,
|
|
282
|
-
is_error: false,
|
|
283
|
-
is_web_call: true,
|
|
284
|
-
});
|
|
285
|
-
const toolResult = await (0, web_search_provider_1.executeTool)(tc.name, args, log);
|
|
286
|
-
log.info('Tool call completed', {
|
|
287
|
-
sessionId,
|
|
288
|
-
tool: tc.name,
|
|
289
|
-
resultLength: toolResult.length,
|
|
290
|
-
});
|
|
291
|
-
return { id: tc.id, name: tc.name, result: toolResult };
|
|
292
|
-
}));
|
|
293
|
-
for (const { id, name, result: toolResult } of toolResults) {
|
|
294
|
-
session.history.push({
|
|
295
|
-
role: 'tool',
|
|
296
|
-
tool_call_id: id,
|
|
297
|
-
tool_name: name,
|
|
298
|
-
content: toolResult,
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
result = await ai_client_1.aiClient.complete(aiModel, session.history, {
|
|
302
|
-
tools: tools?.length ? tools : undefined,
|
|
303
|
-
temperature: 0.2,
|
|
304
|
-
});
|
|
305
|
-
await recordUsage(result);
|
|
306
|
-
}
|
|
307
|
-
log.info('Finished reasoning and tool calls: ', {
|
|
308
|
-
reason: result.finish_reason,
|
|
309
|
-
});
|
|
310
|
-
const content = result.content.trim();
|
|
311
|
-
// If the tool loop was exhausted while the model still wants more tool calls,
|
|
312
|
-
// the last result has empty content. Force one final no-tools call so the model
|
|
313
|
-
// must synthesize a text answer from everything gathered so far.
|
|
314
|
-
if (result.finish_reason === 'tool_calls' || !content) {
|
|
315
|
-
log.warn('Tool iteration limit reached with pending tool calls; forcing final text response', {
|
|
316
|
-
sessionId,
|
|
317
|
-
turn: session.turns,
|
|
318
|
-
});
|
|
319
|
-
// Do NOT push result.assistantMessage here — it contains tool_use blocks that
|
|
320
|
-
// require corresponding tool_result blocks (Anthropic API constraint). Since we
|
|
321
|
-
// are not executing those tool calls, just inject a plain user nudge so the model
|
|
322
|
-
// synthesizes a text answer from the history already accumulated.
|
|
323
|
-
session.history.push({
|
|
324
|
-
role: 'user',
|
|
325
|
-
content: 'You have reached the maximum number of tool calls. Based on all information gathered so far, please provide your best answer now.',
|
|
326
|
-
});
|
|
327
|
-
result = await ai_client_1.aiClient.complete(aiModel, session.history, {
|
|
328
|
-
tools: undefined,
|
|
329
|
-
temperature: 0.2,
|
|
330
|
-
});
|
|
331
|
-
await recordUsage(result);
|
|
332
|
-
}
|
|
333
|
-
if (!content) {
|
|
344
|
+
let content = result.content.trim();
|
|
345
|
+
if (!content && result.finish_reason !== 'tool_calls') {
|
|
334
346
|
log.warn('Agent LLM returned empty content; sending generic error to client.');
|
|
335
347
|
const errorMessage = 'The agent returned an empty response. Please try again.';
|
|
336
348
|
sendFinalAnswer(send, sessionId, errorMessage, true);
|
|
@@ -339,6 +351,23 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log) {
|
|
|
339
351
|
sessionMessages.delete(sessionId);
|
|
340
352
|
return;
|
|
341
353
|
}
|
|
354
|
+
// If the model requested web tool calls, execute them and get a follow-up
|
|
355
|
+
// response before deciding what to send to the client.
|
|
356
|
+
if (!isFinalTurn && result.finish_reason === 'tool_calls') {
|
|
357
|
+
log.info('Running web tool calls to gather information', {
|
|
358
|
+
sessionId,
|
|
359
|
+
subscriptionId: subscription.id,
|
|
360
|
+
turn: session.turns,
|
|
361
|
+
});
|
|
362
|
+
result = await runToolLoop(result, session, sessionId, send, log, buildAvailableTools(), recordUsage);
|
|
363
|
+
content = result.content.trim();
|
|
364
|
+
if (!content) {
|
|
365
|
+
log.warn('Agent returned empty content after tool loop; sending generic error.');
|
|
366
|
+
sendFinalAnswer(send, sessionId, 'The agent returned an empty response. Please try again.', true);
|
|
367
|
+
sessionMessages.delete(sessionId);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
342
371
|
// Ensure that a proper <final_answer> block is produced for the
|
|
343
372
|
// desktop clients once we reach the final turn. If the model did
|
|
344
373
|
// not emit either a <shell_script> or <final_answer> tag on the
|
|
@@ -347,50 +376,40 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log) {
|
|
|
347
376
|
// waiting and paste the result.
|
|
348
377
|
const hasShellScriptTag = content.includes('<shell_script>');
|
|
349
378
|
const hasFinalAnswerTag = content.includes('<final_answer>');
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
session_id: sessionId,
|
|
372
|
-
sender: 'agent',
|
|
373
|
-
content: normalizedContent,
|
|
374
|
-
is_terminal_output: false,
|
|
375
|
-
is_error: false,
|
|
376
|
-
});
|
|
377
|
-
// After the MAX_TURNS iteration or if a final answer tag is present, treat this as the final answer
|
|
378
|
-
// and clear the session from memory while marking it completed.
|
|
379
|
-
if (session.turns >= MAX_TURNS || hasFinalAnswerTag) {
|
|
379
|
+
if (hasShellScriptTag && !isFinalTurn) {
|
|
380
|
+
log.info('Completed agent turn. Sending back scripts, waiting for results.', {
|
|
381
|
+
sessionId,
|
|
382
|
+
subscriptionId: subscription.id,
|
|
383
|
+
turn: session.turns,
|
|
384
|
+
responseLength: result.content.length,
|
|
385
|
+
});
|
|
386
|
+
session.history.push({
|
|
387
|
+
role: 'assistant',
|
|
388
|
+
content,
|
|
389
|
+
});
|
|
390
|
+
send({
|
|
391
|
+
session_id: sessionId,
|
|
392
|
+
sender: 'agent',
|
|
393
|
+
content,
|
|
394
|
+
is_terminal_output: false,
|
|
395
|
+
is_error: false,
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (isFinalTurn || hasFinalAnswerTag) {
|
|
380
400
|
log.info('Finalizing agent session after max turns or final answer tag', {
|
|
381
401
|
sessionId,
|
|
382
402
|
subscriptionId: subscription.id,
|
|
383
403
|
turns: session.turns,
|
|
384
404
|
hasFinalAnswerTag,
|
|
385
405
|
});
|
|
406
|
+
send({
|
|
407
|
+
session_id: sessionId,
|
|
408
|
+
sender: 'agent',
|
|
409
|
+
content: hasFinalAnswerTag ? content : `<final_answer>\n${content}\n</final_answer>`,
|
|
410
|
+
});
|
|
386
411
|
sessionMessages.delete(sessionId);
|
|
387
412
|
}
|
|
388
|
-
log.info('Completed agent turn', {
|
|
389
|
-
sessionId,
|
|
390
|
-
subscriptionId: subscription.id,
|
|
391
|
-
turn: session.turns,
|
|
392
|
-
responseLength: normalizedContent.length,
|
|
393
|
-
});
|
|
394
413
|
}
|
|
395
414
|
catch (err) {
|
|
396
415
|
log.error('Agent LLM call failed', { error: err });
|
package/backend-dist/index.js
CHANGED
|
@@ -64,8 +64,8 @@ app.get('/macos/appcast', (req, res) => {
|
|
|
64
64
|
const appcastUrl = `${baseUrl}/macos/appcast`;
|
|
65
65
|
// These should match the values embedded into the macOS app
|
|
66
66
|
// Info.plist in macOS/build_release_dmg.sh.
|
|
67
|
-
const bundleVersion = '
|
|
68
|
-
const shortVersion = '1.0.
|
|
67
|
+
const bundleVersion = '16';
|
|
68
|
+
const shortVersion = '1.0.15';
|
|
69
69
|
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
|
70
70
|
<rss version="2.0"
|
|
71
71
|
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
|
@@ -93,7 +93,7 @@ app.get('/macos/appcast', (req, res) => {
|
|
|
93
93
|
// ── Windows distribution endpoints ───────────────────────────────────────────
|
|
94
94
|
// These should match the values in windows/OmniKey.Windows.csproj
|
|
95
95
|
// <Version> and windows/build_release_zip.ps1 $APP_VERSION.
|
|
96
|
-
const WIN_VERSION = '1.
|
|
96
|
+
const WIN_VERSION = '1.3';
|
|
97
97
|
const WIN_ZIP_FILENAME = 'OmniKeyAI-windows-win-x64.zip';
|
|
98
98
|
const WIN_ZIP_PATH = path_1.default.join(process.cwd(), 'windows', WIN_ZIP_FILENAME);
|
|
99
99
|
// Serves the pre-built ZIP produced by windows/build_release_zip.ps1.
|
package/dist/daemon.js
CHANGED
|
@@ -4,18 +4,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.startDaemon = startDaemon;
|
|
7
|
-
const child_process_1 = require("child_process");
|
|
8
7
|
const path_1 = __importDefault(require("path"));
|
|
9
8
|
const fs_1 = __importDefault(require("fs"));
|
|
10
|
-
const
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
11
11
|
const utils_1 = require("./utils");
|
|
12
12
|
/**
|
|
13
13
|
* Start the Omnikey API backend as a daemon on the specified port.
|
|
14
14
|
* On macOS: creates and registers a launchd agent for persistence.
|
|
15
|
-
* On Windows:
|
|
15
|
+
* On Windows: installs an NSSM Windows service for boot-time persistence.
|
|
16
16
|
* @param port The port to run the backend on
|
|
17
17
|
*/
|
|
18
|
-
function startDaemon(port = 7071) {
|
|
18
|
+
async function startDaemon(port = 7071) {
|
|
19
19
|
const backendPath = path_1.default.resolve(__dirname, '../backend-dist/index.js');
|
|
20
20
|
const configDir = (0, utils_1.getConfigDir)();
|
|
21
21
|
const configPath = (0, utils_1.getConfigPath)();
|
|
@@ -33,7 +33,7 @@ function startDaemon(port = 7071) {
|
|
|
33
33
|
const logPath = path_1.default.join(configDir, 'daemon.log');
|
|
34
34
|
const errorLogPath = path_1.default.join(configDir, 'daemon-error.log');
|
|
35
35
|
if (utils_1.isWindows) {
|
|
36
|
-
startDaemonWindows({
|
|
36
|
+
await startDaemonWindows({
|
|
37
37
|
port,
|
|
38
38
|
configDir,
|
|
39
39
|
configVars,
|
|
@@ -47,105 +47,108 @@ function startDaemon(port = 7071) {
|
|
|
47
47
|
startDaemonMacOS({ port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath });
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
function
|
|
51
|
-
const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
|
|
52
|
-
// Write a wrapper .cmd script that sets env vars and launches the backend
|
|
53
|
-
const wrapperPath = path_1.default.join(configDir, 'start-daemon.cmd');
|
|
54
|
-
const envSetLines = Object.entries({ ...configVars, OMNIKEY_PORT: String(port) })
|
|
55
|
-
.map(([k, v]) => `set "${k}=${v}"`)
|
|
56
|
-
.join('\r\n');
|
|
57
|
-
const wrapperContent = [
|
|
58
|
-
'@echo off',
|
|
59
|
-
envSetLines,
|
|
60
|
-
`"${nodePath}" "${backendPath}" >> "${logPath}" 2>> "${errorLogPath}"`,
|
|
61
|
-
'',
|
|
62
|
-
].join('\r\n');
|
|
50
|
+
function resolveNssm() {
|
|
63
51
|
try {
|
|
64
|
-
|
|
65
|
-
fs_1.default.writeFileSync(wrapperPath, wrapperContent, 'utf-8');
|
|
66
|
-
}
|
|
67
|
-
catch (e) {
|
|
68
|
-
console.error('Failed to write start-daemon.cmd:', e);
|
|
69
|
-
return;
|
|
52
|
+
return (0, child_process_1.execSync)('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
|
|
70
53
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const taskName = 'OmnikeyDaemon';
|
|
74
|
-
const username = process.env.USERNAME || process.env.USER || '';
|
|
75
|
-
// Escape characters that are special in XML
|
|
76
|
-
const xmlEscape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
77
|
-
const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
|
78
|
-
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
79
|
-
<RegistrationInfo>
|
|
80
|
-
<Description>Omnikey API Backend Daemon</Description>
|
|
81
|
-
</RegistrationInfo>
|
|
82
|
-
<Triggers>
|
|
83
|
-
<LogonTrigger>
|
|
84
|
-
<Enabled>true</Enabled>
|
|
85
|
-
<UserId>${xmlEscape(username)}</UserId>
|
|
86
|
-
</LogonTrigger>
|
|
87
|
-
</Triggers>
|
|
88
|
-
<Principals>
|
|
89
|
-
<Principal id="Author">
|
|
90
|
-
<UserId>${xmlEscape(username)}</UserId>
|
|
91
|
-
<LogonType>InteractiveToken</LogonType>
|
|
92
|
-
<RunLevel>LeastPrivilege</RunLevel>
|
|
93
|
-
</Principal>
|
|
94
|
-
</Principals>
|
|
95
|
-
<Settings>
|
|
96
|
-
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
97
|
-
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
98
|
-
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
99
|
-
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
100
|
-
<Hidden>true</Hidden>
|
|
101
|
-
</Settings>
|
|
102
|
-
<Actions Context="Author">
|
|
103
|
-
<Exec>
|
|
104
|
-
<Command>cmd.exe</Command>
|
|
105
|
-
<Arguments>/c "${xmlEscape(wrapperPath)}"</Arguments>
|
|
106
|
-
<WorkingDirectory>${xmlEscape(configDir)}</WorkingDirectory>
|
|
107
|
-
</Exec>
|
|
108
|
-
</Actions>
|
|
109
|
-
</Task>`;
|
|
110
|
-
const taskXmlPath = path_1.default.join(configDir, 'task.xml');
|
|
111
|
-
try {
|
|
112
|
-
// Task Scheduler XML must be UTF-16 LE encoded
|
|
113
|
-
fs_1.default.writeFileSync(taskXmlPath, '\ufeff' + taskXml, 'utf16le');
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
114
56
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
57
|
+
}
|
|
58
|
+
async function startDaemonWindows(opts) {
|
|
59
|
+
const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
|
|
60
|
+
const serviceName = 'OmnikeyDaemon';
|
|
61
|
+
let nssmPath = resolveNssm();
|
|
62
|
+
if (!nssmPath) {
|
|
63
|
+
const { install } = await inquirer_1.default.prompt([
|
|
64
|
+
{
|
|
65
|
+
type: 'confirm',
|
|
66
|
+
name: 'install',
|
|
67
|
+
message: 'NSSM is required but not found. Install it now via winget?',
|
|
68
|
+
default: true,
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
if (!install) {
|
|
72
|
+
console.log('Aborted. Install NSSM manually and re-run in an elevated (Administrator) terminal.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.log('Installing NSSM via winget...');
|
|
76
|
+
try {
|
|
77
|
+
(0, child_process_1.execSync)('winget install nssm --accept-package-agreements --accept-source-agreements', {
|
|
78
|
+
stdio: 'inherit',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
console.error('winget install failed:', e?.message ?? e);
|
|
83
|
+
console.log('Try manually: scoop install nssm or choco install nssm');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// winget updates the machine PATH in the registry but the current process
|
|
87
|
+
// won't see it — spawn a fresh cmd to resolve the new location.
|
|
88
|
+
try {
|
|
89
|
+
nssmPath = (0, child_process_1.execSync)('cmd /c where nssm', { stdio: 'pipe' })
|
|
90
|
+
.toString().trim().split('\n')[0].trim();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
nssmPath = null;
|
|
94
|
+
}
|
|
95
|
+
if (!nssmPath) {
|
|
96
|
+
console.log('NSSM installed successfully.');
|
|
97
|
+
console.log('Please open a new elevated (Administrator) terminal and re-run this command.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
118
100
|
}
|
|
101
|
+
(0, utils_1.initLogFiles)(logPath, errorLogPath);
|
|
102
|
+
// Remove any existing service (stop first, then remove)
|
|
119
103
|
try {
|
|
120
|
-
|
|
121
|
-
(0, child_process_2.execSync)(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
|
|
104
|
+
(0, child_process_1.execFileSync)(nssmPath, ['stop', serviceName], { stdio: 'pipe' });
|
|
122
105
|
}
|
|
123
|
-
catch {
|
|
124
|
-
|
|
106
|
+
catch { /* not running */ }
|
|
107
|
+
try {
|
|
108
|
+
(0, child_process_1.execFileSync)(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' });
|
|
125
109
|
}
|
|
110
|
+
catch { /* didn't exist */ }
|
|
111
|
+
// NSSM services run as LocalSystem; pass USERPROFILE so the backend's
|
|
112
|
+
// getHomeDir() resolves to the correct user config directory.
|
|
113
|
+
const env = {
|
|
114
|
+
...configVars,
|
|
115
|
+
OMNIKEY_PORT: String(port),
|
|
116
|
+
USERPROFILE: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
|
|
117
|
+
HOME: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
|
|
118
|
+
};
|
|
126
119
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
120
|
+
// Install: nssm install <name> <application> [args...]
|
|
121
|
+
(0, child_process_1.execFileSync)(nssmPath, ['install', serviceName, nodePath, backendPath], { stdio: 'pipe' });
|
|
122
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppDirectory', configDir], { stdio: 'pipe' });
|
|
123
|
+
// Pass all env vars in a single call (replaces the entire AppEnvironmentExtra key)
|
|
124
|
+
const envEntries = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
125
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppEnvironmentExtra', ...envEntries], { stdio: 'pipe' });
|
|
126
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppStdout', logPath], { stdio: 'pipe' });
|
|
127
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppStderr', errorLogPath], { stdio: 'pipe' });
|
|
128
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppRotateFiles', '1'], { stdio: 'pipe' });
|
|
129
|
+
// Restart automatically after a 3-second delay on any exit
|
|
130
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppExit', 'Default', 'Restart'], { stdio: 'pipe' });
|
|
131
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppRestartDelay', '3000'], { stdio: 'pipe' });
|
|
132
|
+
// Start automatically at boot (no login required)
|
|
133
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'Start', 'SERVICE_AUTO_START'], { stdio: 'pipe' });
|
|
134
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'DisplayName', 'Omnikey API Backend'], { stdio: 'pipe' });
|
|
135
|
+
(0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'Description', 'Omnikey API Backend Daemon'], { stdio: 'pipe' });
|
|
136
|
+
(0, child_process_1.execFileSync)(nssmPath, ['start', serviceName], { stdio: 'pipe' });
|
|
137
|
+
console.log(`NSSM service installed and started: ${serviceName}`);
|
|
138
|
+
console.log('Omnikey daemon runs on boot, without login, and auto-restarts on crash.');
|
|
139
|
+
console.log(`Logs: ${logPath}`);
|
|
140
|
+
console.log(` ${errorLogPath}`);
|
|
130
141
|
}
|
|
131
142
|
catch (e) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
143
|
+
const msg = e?.stderr?.toString() || e?.message || String(e);
|
|
144
|
+
if (msg.toLowerCase().includes('access') || msg.toLowerCase().includes('privilege')) {
|
|
145
|
+
console.error('Failed to install NSSM service: administrator privileges are required.');
|
|
146
|
+
console.error('Re-run this command in an elevated (Administrator) terminal.');
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
console.error('Failed to install NSSM service:', msg);
|
|
137
150
|
}
|
|
138
|
-
catch { /* ignore */ }
|
|
139
151
|
}
|
|
140
|
-
// Also start the backend immediately for the current session
|
|
141
|
-
const { out, err } = (0, utils_1.initLogFiles)(logPath, errorLogPath);
|
|
142
|
-
const child = (0, child_process_1.spawn)(nodePath, [backendPath], {
|
|
143
|
-
env: { ...configVars, OMNIKEY_PORT: String(port) },
|
|
144
|
-
detached: true,
|
|
145
|
-
stdio: ['ignore', out, err],
|
|
146
|
-
});
|
|
147
|
-
child.unref();
|
|
148
|
-
console.log(`Omnikey API backend started as a daemon on port ${port}. PID: ${child.pid}`);
|
|
149
152
|
}
|
|
150
153
|
function startDaemonMacOS(opts) {
|
|
151
154
|
const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
|
|
@@ -188,8 +191,8 @@ function startDaemonMacOS(opts) {
|
|
|
188
191
|
fs_1.default.mkdirSync(launchAgentsDir, { recursive: true });
|
|
189
192
|
fs_1.default.writeFileSync(plistPath, plistContent, 'utf-8');
|
|
190
193
|
(0, utils_1.initLogFiles)(logPath, errorLogPath);
|
|
191
|
-
(0,
|
|
192
|
-
(0,
|
|
194
|
+
(0, child_process_1.execSync)(`launchctl unload "${plistPath}" || true`);
|
|
195
|
+
(0, child_process_1.execSync)(`launchctl load "${plistPath}"`);
|
|
193
196
|
console.log(`Launch agent created and loaded: ${plistPath}`);
|
|
194
197
|
console.log('Omnikey daemon will auto-restart and persist across reboots.');
|
|
195
198
|
// launchd starts the process via RunAtLoad — no manual spawn needed here.
|
package/dist/index.js
CHANGED
|
@@ -25,9 +25,9 @@ program
|
|
|
25
25
|
.command('daemon')
|
|
26
26
|
.description('Start the Omnikey API backend as a daemon on a specified port')
|
|
27
27
|
.option('--port <port>', 'Port to run the backend on', '7071')
|
|
28
|
-
.action((options) => {
|
|
28
|
+
.action(async (options) => {
|
|
29
29
|
const port = Number(options.port) || 7071;
|
|
30
|
-
(0, daemon_1.startDaemon)(port);
|
|
30
|
+
await (0, daemon_1.startDaemon)(port);
|
|
31
31
|
});
|
|
32
32
|
program
|
|
33
33
|
.command('kill-daemon')
|
|
@@ -74,9 +74,9 @@ program
|
|
|
74
74
|
.command('restart-daemon')
|
|
75
75
|
.description('Restart the Omnikey API backend daemon')
|
|
76
76
|
.option('--port <port>', 'Port to run the backend on', '7071')
|
|
77
|
-
.action((options) => {
|
|
77
|
+
.action(async (options) => {
|
|
78
78
|
(0, killDaemon_1.killDaemon)();
|
|
79
79
|
const port = Number(options.port) || 7071;
|
|
80
|
-
(0, daemon_1.startDaemon)(port);
|
|
80
|
+
await (0, daemon_1.startDaemon)(port);
|
|
81
81
|
});
|
|
82
82
|
program.parseAsync(process.argv);
|
package/dist/removeConfig.js
CHANGED
|
@@ -29,29 +29,47 @@ function killLaunchdAgent() {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
function killWindowsTask() {
|
|
32
|
-
const
|
|
32
|
+
const serviceName = 'OmnikeyDaemon';
|
|
33
|
+
// Try NSSM first (current implementation)
|
|
34
|
+
let nssmPath = null;
|
|
33
35
|
try {
|
|
34
|
-
(0, child_process_1.execSync)(
|
|
36
|
+
nssmPath = (0, child_process_1.execSync)('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
|
|
35
37
|
}
|
|
36
|
-
catch {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
catch { /* NSSM not installed */ }
|
|
39
|
+
if (nssmPath) {
|
|
40
|
+
try {
|
|
41
|
+
(0, child_process_1.execFileSync)(nssmPath, ['stop', serviceName], { stdio: 'pipe' });
|
|
42
|
+
}
|
|
43
|
+
catch { /* not running */ }
|
|
44
|
+
try {
|
|
45
|
+
(0, child_process_1.execFileSync)(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' });
|
|
46
|
+
console.log(`Removed NSSM service: ${serviceName}`);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
console.log(`NSSM service does not exist: ${serviceName}`);
|
|
50
|
+
}
|
|
42
51
|
}
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
else {
|
|
53
|
+
// Fallback: remove legacy Task Scheduler task from previous installs
|
|
54
|
+
try {
|
|
55
|
+
(0, child_process_1.execSync)(`schtasks /end /tn "${serviceName}"`, { stdio: 'pipe' });
|
|
56
|
+
}
|
|
57
|
+
catch { /* not running */ }
|
|
58
|
+
try {
|
|
59
|
+
(0, child_process_1.execSync)(`schtasks /delete /tn "${serviceName}" /f`, { stdio: 'pipe' });
|
|
60
|
+
console.log(`Removed Windows Task Scheduler task: ${serviceName}`);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
console.log(`Windows Task Scheduler task does not exist: ${serviceName}`);
|
|
64
|
+
}
|
|
45
65
|
}
|
|
46
|
-
//
|
|
66
|
+
// Remove legacy wrapper script if present
|
|
47
67
|
const wrapperPath = path_1.default.join((0, utils_1.getConfigDir)(), 'start-daemon.cmd');
|
|
48
68
|
if (fs_1.default.existsSync(wrapperPath)) {
|
|
49
69
|
try {
|
|
50
70
|
fs_1.default.rmSync(wrapperPath);
|
|
51
71
|
}
|
|
52
|
-
catch {
|
|
53
|
-
// Ignore
|
|
54
|
-
}
|
|
72
|
+
catch { /* ignore */ }
|
|
55
73
|
}
|
|
56
74
|
}
|
|
57
75
|
/**
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.0.
|
|
7
|
+
"version": "1.0.19",
|
|
8
8
|
"description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=14.0.0",
|
package/src/daemon.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import path from 'path';
|
|
3
2
|
import fs from 'fs';
|
|
4
|
-
import { execSync } from 'child_process';
|
|
3
|
+
import { execSync, execFileSync } from 'child_process';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
5
|
import {
|
|
6
6
|
isWindows,
|
|
7
7
|
getHomeDir,
|
|
@@ -14,10 +14,10 @@ import {
|
|
|
14
14
|
/**
|
|
15
15
|
* Start the Omnikey API backend as a daemon on the specified port.
|
|
16
16
|
* On macOS: creates and registers a launchd agent for persistence.
|
|
17
|
-
* On Windows:
|
|
17
|
+
* On Windows: installs an NSSM Windows service for boot-time persistence.
|
|
18
18
|
* @param port The port to run the backend on
|
|
19
19
|
*/
|
|
20
|
-
export function startDaemon(port: number = 7071) {
|
|
20
|
+
export async function startDaemon(port: number = 7071) {
|
|
21
21
|
const backendPath = path.resolve(__dirname, '../backend-dist/index.js');
|
|
22
22
|
|
|
23
23
|
const configDir = getConfigDir();
|
|
@@ -38,7 +38,7 @@ export function startDaemon(port: number = 7071) {
|
|
|
38
38
|
const errorLogPath = path.join(configDir, 'daemon-error.log');
|
|
39
39
|
|
|
40
40
|
if (isWindows) {
|
|
41
|
-
startDaemonWindows({
|
|
41
|
+
await startDaemonWindows({
|
|
42
42
|
port,
|
|
43
43
|
configDir,
|
|
44
44
|
configVars,
|
|
@@ -62,102 +62,115 @@ interface DaemonOptions {
|
|
|
62
62
|
errorLogPath: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function
|
|
65
|
+
function resolveNssm(): string | null {
|
|
66
|
+
try {
|
|
67
|
+
return execSync('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function startDaemonWindows(opts: DaemonOptions) {
|
|
66
74
|
const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
|
|
75
|
+
const serviceName = 'OmnikeyDaemon';
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
].join('\r\n');
|
|
77
|
+
let nssmPath = resolveNssm();
|
|
78
|
+
if (!nssmPath) {
|
|
79
|
+
const { install } = await inquirer.prompt<{ install: boolean }>([
|
|
80
|
+
{
|
|
81
|
+
type: 'confirm',
|
|
82
|
+
name: 'install',
|
|
83
|
+
message: 'NSSM is required but not found. Install it now via winget?',
|
|
84
|
+
default: true,
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
console.error('Failed to write start-daemon.cmd:', e);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
88
|
+
if (!install) {
|
|
89
|
+
console.log('Aborted. Install NSSM manually and re-run in an elevated (Administrator) terminal.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<Settings>
|
|
114
|
-
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
115
|
-
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
116
|
-
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
117
|
-
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
118
|
-
<Hidden>true</Hidden>
|
|
119
|
-
</Settings>
|
|
120
|
-
<Actions Context="Author">
|
|
121
|
-
<Exec>
|
|
122
|
-
<Command>cmd.exe</Command>
|
|
123
|
-
<Arguments>/c "${xmlEscape(wrapperPath)}"</Arguments>
|
|
124
|
-
<WorkingDirectory>${xmlEscape(configDir)}</WorkingDirectory>
|
|
125
|
-
</Exec>
|
|
126
|
-
</Actions>
|
|
127
|
-
</Task>`;
|
|
128
|
-
const taskXmlPath = path.join(configDir, 'task.xml');
|
|
129
|
-
try {
|
|
130
|
-
// Task Scheduler XML must be UTF-16 LE encoded
|
|
131
|
-
fs.writeFileSync(taskXmlPath, '\ufeff' + taskXml, 'utf16le');
|
|
132
|
-
} catch (e) {
|
|
133
|
-
console.error('Failed to write task XML:', e);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
try {
|
|
137
|
-
// Delete existing task silently before creating a fresh one
|
|
138
|
-
execSync(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
|
|
139
|
-
} catch {
|
|
140
|
-
// Task may not exist — that's fine
|
|
93
|
+
console.log('Installing NSSM via winget...');
|
|
94
|
+
try {
|
|
95
|
+
execSync('winget install nssm --accept-package-agreements --accept-source-agreements', {
|
|
96
|
+
stdio: 'inherit',
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.error('winget install failed:', (e as any)?.message ?? e);
|
|
100
|
+
console.log('Try manually: scoop install nssm or choco install nssm');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// winget updates the machine PATH in the registry but the current process
|
|
105
|
+
// won't see it — spawn a fresh cmd to resolve the new location.
|
|
106
|
+
try {
|
|
107
|
+
nssmPath = execSync('cmd /c where nssm', { stdio: 'pipe' })
|
|
108
|
+
.toString().trim().split('\n')[0].trim();
|
|
109
|
+
} catch {
|
|
110
|
+
nssmPath = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!nssmPath) {
|
|
114
|
+
console.log('NSSM installed successfully.');
|
|
115
|
+
console.log('Please open a new elevated (Administrator) terminal and re-run this command.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
141
118
|
}
|
|
119
|
+
|
|
120
|
+
initLogFiles(logPath, errorLogPath);
|
|
121
|
+
|
|
122
|
+
// Remove any existing service (stop first, then remove)
|
|
123
|
+
try { execFileSync(nssmPath, ['stop', serviceName], { stdio: 'pipe' }); } catch { /* not running */ }
|
|
124
|
+
try { execFileSync(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' }); } catch { /* didn't exist */ }
|
|
125
|
+
|
|
126
|
+
// NSSM services run as LocalSystem; pass USERPROFILE so the backend's
|
|
127
|
+
// getHomeDir() resolves to the correct user config directory.
|
|
128
|
+
const env: Record<string, string> = {
|
|
129
|
+
...configVars,
|
|
130
|
+
OMNIKEY_PORT: String(port),
|
|
131
|
+
USERPROFILE: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
|
|
132
|
+
HOME: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
|
|
133
|
+
};
|
|
134
|
+
|
|
142
135
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
console.error('Failed to create Windows Task Scheduler task:', e);
|
|
148
|
-
} finally {
|
|
149
|
-
try { fs.rmSync(taskXmlPath); } catch { /* ignore */ }
|
|
150
|
-
}
|
|
136
|
+
// Install: nssm install <name> <application> [args...]
|
|
137
|
+
execFileSync(nssmPath, ['install', serviceName, nodePath, backendPath], { stdio: 'pipe' });
|
|
138
|
+
|
|
139
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppDirectory', configDir], { stdio: 'pipe' });
|
|
151
140
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
141
|
+
// Pass all env vars in a single call (replaces the entire AppEnvironmentExtra key)
|
|
142
|
+
const envEntries = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
143
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppEnvironmentExtra', ...envEntries], { stdio: 'pipe' });
|
|
144
|
+
|
|
145
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppStdout', logPath], { stdio: 'pipe' });
|
|
146
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppStderr', errorLogPath], { stdio: 'pipe' });
|
|
147
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppRotateFiles', '1'], { stdio: 'pipe' });
|
|
148
|
+
|
|
149
|
+
// Restart automatically after a 3-second delay on any exit
|
|
150
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppExit', 'Default', 'Restart'], { stdio: 'pipe' });
|
|
151
|
+
execFileSync(nssmPath, ['set', serviceName, 'AppRestartDelay', '3000'], { stdio: 'pipe' });
|
|
152
|
+
|
|
153
|
+
// Start automatically at boot (no login required)
|
|
154
|
+
execFileSync(nssmPath, ['set', serviceName, 'Start', 'SERVICE_AUTO_START'], { stdio: 'pipe' });
|
|
155
|
+
|
|
156
|
+
execFileSync(nssmPath, ['set', serviceName, 'DisplayName', 'Omnikey API Backend'], { stdio: 'pipe' });
|
|
157
|
+
execFileSync(nssmPath, ['set', serviceName, 'Description', 'Omnikey API Backend Daemon'], { stdio: 'pipe' });
|
|
158
|
+
|
|
159
|
+
execFileSync(nssmPath, ['start', serviceName], { stdio: 'pipe' });
|
|
160
|
+
|
|
161
|
+
console.log(`NSSM service installed and started: ${serviceName}`);
|
|
162
|
+
console.log('Omnikey daemon runs on boot, without login, and auto-restarts on crash.');
|
|
163
|
+
console.log(`Logs: ${logPath}`);
|
|
164
|
+
console.log(` ${errorLogPath}`);
|
|
165
|
+
} catch (e: any) {
|
|
166
|
+
const msg: string = e?.stderr?.toString() || e?.message || String(e);
|
|
167
|
+
if (msg.toLowerCase().includes('access') || msg.toLowerCase().includes('privilege')) {
|
|
168
|
+
console.error('Failed to install NSSM service: administrator privileges are required.');
|
|
169
|
+
console.error('Re-run this command in an elevated (Administrator) terminal.');
|
|
170
|
+
} else {
|
|
171
|
+
console.error('Failed to install NSSM service:', msg);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
function startDaemonMacOS(opts: DaemonOptions) {
|
package/src/index.ts
CHANGED
|
@@ -28,9 +28,9 @@ program
|
|
|
28
28
|
.command('daemon')
|
|
29
29
|
.description('Start the Omnikey API backend as a daemon on a specified port')
|
|
30
30
|
.option('--port <port>', 'Port to run the backend on', '7071')
|
|
31
|
-
.action((options) => {
|
|
31
|
+
.action(async (options) => {
|
|
32
32
|
const port = Number(options.port) || 7071;
|
|
33
|
-
startDaemon(port);
|
|
33
|
+
await startDaemon(port);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
program
|
|
@@ -84,10 +84,10 @@ program
|
|
|
84
84
|
.command('restart-daemon')
|
|
85
85
|
.description('Restart the Omnikey API backend daemon')
|
|
86
86
|
.option('--port <port>', 'Port to run the backend on', '7071')
|
|
87
|
-
.action((options) => {
|
|
87
|
+
.action(async (options) => {
|
|
88
88
|
killDaemon();
|
|
89
89
|
const port = Number(options.port) || 7071;
|
|
90
|
-
startDaemon(port);
|
|
90
|
+
await startDaemon(port);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
program.parseAsync(process.argv);
|
package/src/removeConfig.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { execSync } from 'child_process';
|
|
3
|
+
import { execSync, execFileSync } from 'child_process';
|
|
4
4
|
import { isWindows, getHomeDir, getConfigDir, readConfig } from './utils';
|
|
5
5
|
|
|
6
6
|
export function killLaunchdAgent() {
|
|
@@ -20,28 +20,38 @@ export function killLaunchdAgent() {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function killWindowsTask() {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// Task may not be running — that's fine
|
|
28
|
-
}
|
|
23
|
+
const serviceName = 'OmnikeyDaemon';
|
|
24
|
+
|
|
25
|
+
// Try NSSM first (current implementation)
|
|
26
|
+
let nssmPath: string | null = null;
|
|
29
27
|
try {
|
|
30
|
-
execSync(
|
|
31
|
-
|
|
32
|
-
} catch {
|
|
33
|
-
console.log(`Windows Task Scheduler task does not exist: ${taskName}`);
|
|
34
|
-
}
|
|
28
|
+
nssmPath = execSync('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
|
|
29
|
+
} catch { /* NSSM not installed */ }
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
if (nssmPath) {
|
|
32
|
+
try { execFileSync(nssmPath, ['stop', serviceName], { stdio: 'pipe' }); } catch { /* not running */ }
|
|
33
|
+
try {
|
|
34
|
+
execFileSync(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' });
|
|
35
|
+
console.log(`Removed NSSM service: ${serviceName}`);
|
|
36
|
+
} catch {
|
|
37
|
+
console.log(`NSSM service does not exist: ${serviceName}`);
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
// Fallback: remove legacy Task Scheduler task from previous installs
|
|
41
|
+
try { execSync(`schtasks /end /tn "${serviceName}"`, { stdio: 'pipe' }); } catch { /* not running */ }
|
|
39
42
|
try {
|
|
40
|
-
|
|
43
|
+
execSync(`schtasks /delete /tn "${serviceName}" /f`, { stdio: 'pipe' });
|
|
44
|
+
console.log(`Removed Windows Task Scheduler task: ${serviceName}`);
|
|
41
45
|
} catch {
|
|
42
|
-
|
|
46
|
+
console.log(`Windows Task Scheduler task does not exist: ${serviceName}`);
|
|
43
47
|
}
|
|
44
48
|
}
|
|
49
|
+
|
|
50
|
+
// Remove legacy wrapper script if present
|
|
51
|
+
const wrapperPath = path.join(getConfigDir(), 'start-daemon.cmd');
|
|
52
|
+
if (fs.existsSync(wrapperPath)) {
|
|
53
|
+
try { fs.rmSync(wrapperPath); } catch { /* ignore */ }
|
|
54
|
+
}
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/**
|