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 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 is registered as a **Windows Task Scheduler** task (`OmnikeyDaemon`) that runs at every logon. A wrapper script (`~/.omnikey/start-daemon.cmd`) is generated to set the required environment variables before launching the Node.js backend.
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
- > **Note:** `schtasks` is a built-in Windows command — no third-party tools or administrator rights are required for user-level scheduled tasks.
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 capable of reasoning about user situations and executing shell scripts in a terminal environment. You have full access to the terminal.
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
- **Guidelines for script generation:**
31
- - Create only safe, read-only commands focused on inspection, diagnostics, and information gathering.
32
- - Do not generate commands that install software, modify user data, or change system settings.
33
- - Never ask the user to run commands with \`sudo\` or administrator/root privileges.
34
- - Ensure all commands are compatible with ${!isWindows ? 'macOS and Linux; avoid Windows-specific commands.' : 'Use Windows-specific commands; avoid macOS and Linux-specific commands.'}
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 you generate shell scripts, make them clear, efficient, and focused on gathering the information needed to answer the user's question or complete their request.
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
- **Instruction handling:**
40
- - Treat stored task instructions (if present) as authoritative for how to prioritize, what to examine, and how to format your answer, as long as they do not conflict with system rules or safety guidelines.
41
- - Treat the current user input as the immediate goal or question you must solve, applying the stored instructions to that specific situation.
42
- - If there is a conflict, follow: system rules first, then stored instructions, then ad-hoc guidance in the current input.
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
- **Web tools:**
45
- You have access to web tools, but you must use them sparingly and only when explicitly required:
46
- - \`web_fetch(url)\`: Only call this when the user has provided a specific URL in their current input or stored instructions and you need to retrieve its contents.
47
- - \`web_search(query)\`: Only call this when the user has explicitly asked you to search the web or look something up online.
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
- Do NOT use web tools proactively. Do NOT call them to look up documentation, error references, or general information you could infer from the machine output or your own knowledge. Your primary workflow is to generate shell scripts, wait for the terminal output, and reason from that output. Only reach for web tools when there is a clear, explicit instruction or a URL provided by the user.
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
- **User message tags:**
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
- **Interaction rules:**
58
- - When you need to execute ANY shell command, respond with a single \`<shell_script>\` block that contains the FULL script to run.
59
- - Within that script, include all steps needed to carry out the current diagnostic or information-gathering task as completely as possible (for example, collect all relevant logs, inspect all relevant services, perform all necessary checks), rather than issuing minimal or placeholder commands.
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
- # your commands here
46
+ # commands here
78
47
  </shell_script>
79
- \`\`\`
80
-
81
- - Use a single, self-contained script per turn; do not send multiple \`<shell_script>\` blocks in one response.
82
- - Inside the script, group related commands logically and add brief inline comments ONLY when they clarify non-obvious steps.
83
- - Prefer safe, idempotent commands. Never ask for sudo.`
84
- : windowsShellScriptInstructions}
85
-
86
- **Final answer block structure:**
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
- ...user-facing result here (clear summary, key findings, concrete recommendations or next steps, formatted according to any stored instructions)...
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 existing;
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 entry;
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
- session.history.push({
208
- role: 'user',
209
- content: userContent,
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
- // Tool-call loop: execute any requested tools and feed results back
253
- // until the model emits a non-tool-call response (or we hit the limit).
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
- log.info('Agent LLM raw response summary', {
351
- sessionId,
352
- turn: session.turns,
353
- rawContentLength: content.length,
354
- hasShellScriptTag,
355
- hasFinalAnswerTag,
356
- });
357
- const normalizedContent = !hasShellScriptTag && !hasFinalAnswerTag && session.turns >= MAX_TURNS
358
- ? `<final_answer>\n${content}\n</final_answer>`
359
- : content;
360
- log.info('Agent LLM normalized response summary', {
361
- sessionId,
362
- turn: session.turns,
363
- normalizedContentLength: normalizedContent.length,
364
- });
365
- // Record assistant message back into history for future turns.
366
- session.history.push({
367
- role: 'assistant',
368
- content: normalizedContent,
369
- });
370
- send({
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 });
@@ -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 = '14';
68
- const shortVersion = '1.0.13';
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.2';
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 child_process_2 = require("child_process");
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: creates a wrapper script and registers a Windows Task Scheduler task.
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 startDaemonWindows(opts) {
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
- fs_1.default.mkdirSync(configDir, { recursive: true });
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
- // Register with Windows Task Scheduler so the daemon persists across reboots.
72
- // Use XML-based registration to avoid cmd.exe quoting issues with paths containing spaces.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 &quot;${xmlEscape(wrapperPath)}&quot;</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
- catch (e) {
116
- console.error('Failed to write task XML:', e);
117
- return;
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
- // Delete existing task silently before creating a fresh one
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
- // Task may not exist — that's fine
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
- (0, child_process_2.execSync)(`schtasks /create /tn "${taskName}" /xml "${taskXmlPath}" /f`, { stdio: 'pipe' });
128
- console.log(`Windows Task Scheduler task created: ${taskName}`);
129
- console.log('Omnikey daemon will auto-start on next logon.');
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
- console.error('Failed to create Windows Task Scheduler task:', e);
133
- }
134
- finally {
135
- try {
136
- fs_1.default.rmSync(taskXmlPath);
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, child_process_2.execSync)(`launchctl unload "${plistPath}" || true`);
192
- (0, child_process_2.execSync)(`launchctl load "${plistPath}"`);
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);
@@ -29,29 +29,47 @@ function killLaunchdAgent() {
29
29
  }
30
30
  }
31
31
  function killWindowsTask() {
32
- const taskName = 'OmnikeyDaemon';
32
+ const serviceName = 'OmnikeyDaemon';
33
+ // Try NSSM first (current implementation)
34
+ let nssmPath = null;
33
35
  try {
34
- (0, child_process_1.execSync)(`schtasks /end /tn "${taskName}"`, { stdio: 'pipe' });
36
+ nssmPath = (0, child_process_1.execSync)('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
35
37
  }
36
- catch {
37
- // Task may not be running — that's fine
38
- }
39
- try {
40
- (0, child_process_1.execSync)(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
41
- console.log(`Removed Windows Task Scheduler task: ${taskName}`);
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
- catch {
44
- console.log(`Windows Task Scheduler task does not exist: ${taskName}`);
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
- // Also remove the wrapper script
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.17",
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: creates a wrapper script and registers a Windows Task Scheduler task.
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 startDaemonWindows(opts: DaemonOptions) {
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
- // Write a wrapper .cmd script that sets env vars and launches the backend
69
- const wrapperPath = path.join(configDir, 'start-daemon.cmd');
70
- const envSetLines = Object.entries({ ...configVars, OMNIKEY_PORT: String(port) })
71
- .map(([k, v]) => `set "${k}=${v}"`)
72
- .join('\r\n');
73
- const wrapperContent = [
74
- '@echo off',
75
- envSetLines,
76
- `"${nodePath}" "${backendPath}" >> "${logPath}" 2>> "${errorLogPath}"`,
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
- try {
81
- fs.mkdirSync(configDir, { recursive: true });
82
- fs.writeFileSync(wrapperPath, wrapperContent, 'utf-8');
83
- } catch (e) {
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
- // Register with Windows Task Scheduler so the daemon persists across reboots.
89
- // Use XML-based registration to avoid cmd.exe quoting issues with paths containing spaces.
90
- const taskName = 'OmnikeyDaemon';
91
- const username = process.env.USERNAME || process.env.USER || '';
92
- // Escape characters that are special in XML
93
- const xmlEscape = (s: string) =>
94
- s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
95
- const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
96
- <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
97
- <RegistrationInfo>
98
- <Description>Omnikey API Backend Daemon</Description>
99
- </RegistrationInfo>
100
- <Triggers>
101
- <LogonTrigger>
102
- <Enabled>true</Enabled>
103
- <UserId>${xmlEscape(username)}</UserId>
104
- </LogonTrigger>
105
- </Triggers>
106
- <Principals>
107
- <Principal id="Author">
108
- <UserId>${xmlEscape(username)}</UserId>
109
- <LogonType>InteractiveToken</LogonType>
110
- <RunLevel>LeastPrivilege</RunLevel>
111
- </Principal>
112
- </Principals>
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 &quot;${xmlEscape(wrapperPath)}&quot;</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
- execSync(`schtasks /create /tn "${taskName}" /xml "${taskXmlPath}" /f`, { stdio: 'pipe' });
144
- console.log(`Windows Task Scheduler task created: ${taskName}`);
145
- console.log('Omnikey daemon will auto-start on next logon.');
146
- } catch (e) {
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
- // Also start the backend immediately for the current session
153
- const { out, err } = initLogFiles(logPath, errorLogPath);
154
- const child = spawn(nodePath, [backendPath], {
155
- env: { ...configVars, OMNIKEY_PORT: String(port) },
156
- detached: true,
157
- stdio: ['ignore', out, err],
158
- });
159
- child.unref();
160
- console.log(`Omnikey API backend started as a daemon on port ${port}. PID: ${child.pid}`);
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);
@@ -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 taskName = 'OmnikeyDaemon';
24
- try {
25
- execSync(`schtasks /end /tn "${taskName}"`, { stdio: 'pipe' });
26
- } catch {
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(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
31
- console.log(`Removed Windows Task Scheduler task: ${taskName}`);
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
- // Also remove the wrapper script
37
- const wrapperPath = path.join(getConfigDir(), 'start-daemon.cmd');
38
- if (fs.existsSync(wrapperPath)) {
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
- fs.rmSync(wrapperPath);
43
+ execSync(`schtasks /delete /tn "${serviceName}" /f`, { stdio: 'pipe' });
44
+ console.log(`Removed Windows Task Scheduler task: ${serviceName}`);
41
45
  } catch {
42
- // Ignore
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
  /**