@mmmbuto/nexuscrew 0.2.0 → 0.2.1

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.
@@ -0,0 +1,155 @@
1
+ const { EventEmitter } = require('events');
2
+ const LogWatcher = require('./log-watcher');
3
+
4
+ function normalizeSnapshot(text = '') {
5
+ return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
6
+ }
7
+
8
+ function commonPrefixLength(a = '', b = '') {
9
+ const max = Math.min(a.length, b.length);
10
+ let index = 0;
11
+ while (index < max && a[index] === b[index]) {
12
+ index += 1;
13
+ }
14
+ return index;
15
+ }
16
+
17
+ class RemotePaneWatcher extends EventEmitter {
18
+ constructor(tmux, options = {}) {
19
+ super();
20
+ this.tmux = tmux;
21
+ this.parserAdapter = new LogWatcher();
22
+ this.watchers = new Map();
23
+ this.pollMs = options.pollMs || 400;
24
+ this.idleMs = options.idleMs || 4500;
25
+ this.timeoutMs = options.timeoutMs || 120000;
26
+
27
+ this.parserAdapter.on('data', (event) => this.emit('data', event));
28
+ }
29
+
30
+ watch({ conversationId, engine, host, windowName, promptText = '', initialSnapshot = '' }) {
31
+ const key = `${host?.name || host || 'local'}:${windowName}:${conversationId}`;
32
+ if (this.watchers.has(key)) return key;
33
+
34
+ const parserState = this.parserAdapter.createParserState(engine, conversationId);
35
+ const state = {
36
+ key,
37
+ conversationId,
38
+ engine,
39
+ host,
40
+ windowName,
41
+ parserState,
42
+ promptText: String(promptText || ''),
43
+ lastSnapshot: normalizeSnapshot(initialSnapshot),
44
+ startedAt: Date.now(),
45
+ lastChangeAt: Date.now(),
46
+ lastEmitAt: 0,
47
+ stopped: false,
48
+ sawAnyOutput: false
49
+ };
50
+
51
+ const intervalId = setInterval(() => this._poll(state), this.pollMs);
52
+ state.intervalId = intervalId;
53
+ this.watchers.set(key, state);
54
+ return key;
55
+ }
56
+
57
+ unwatch(key) {
58
+ const state = this.watchers.get(key);
59
+ if (!state) return;
60
+ clearInterval(state.intervalId);
61
+ state.stopped = true;
62
+ this.watchers.delete(key);
63
+ }
64
+
65
+ unwatchByConversation(conversationId) {
66
+ for (const [key, state] of this.watchers.entries()) {
67
+ if (state.conversationId === conversationId) {
68
+ this.unwatch(key);
69
+ }
70
+ }
71
+ }
72
+
73
+ unwatchAll() {
74
+ for (const key of this.watchers.keys()) {
75
+ this.unwatch(key);
76
+ }
77
+ }
78
+
79
+ _poll(state) {
80
+ if (state.stopped) return;
81
+
82
+ if (Date.now() - state.startedAt > this.timeoutMs) {
83
+ this._finish(state, true);
84
+ return;
85
+ }
86
+
87
+ const snapshot = normalizeSnapshot(
88
+ this.tmux.capturePane(state.windowName, state.host, 250)
89
+ );
90
+
91
+ if (!snapshot) {
92
+ if (Date.now() - state.lastChangeAt > this.idleMs) {
93
+ this._finish(state, true);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (snapshot === state.lastSnapshot) {
99
+ if (state.sawAnyOutput && Date.now() - state.lastChangeAt > this.idleMs) {
100
+ this._finish(state, true);
101
+ }
102
+ return;
103
+ }
104
+
105
+ const prefixLength = commonPrefixLength(state.lastSnapshot, snapshot);
106
+ let delta = snapshot.slice(prefixLength);
107
+ state.lastSnapshot = snapshot;
108
+ state.lastChangeAt = Date.now();
109
+
110
+ if (!delta.trim()) {
111
+ return;
112
+ }
113
+
114
+ delta = this._stripPromptEcho(delta, state);
115
+ if (!delta.trim()) {
116
+ return;
117
+ }
118
+
119
+ state.sawAnyOutput = true;
120
+ this.parserAdapter.processTextChunk(delta, state.parserState);
121
+ state.lastEmitAt = Date.now();
122
+ }
123
+
124
+ _stripPromptEcho(delta, state) {
125
+ if (!state.promptText) return delta;
126
+
127
+ const prompt = state.promptText.trim();
128
+ if (!prompt) return delta;
129
+
130
+ if (delta.includes(prompt)) {
131
+ return delta.replace(prompt, '');
132
+ }
133
+
134
+ const compact = delta.trim();
135
+ if (prompt.startsWith(compact) && compact.length > 10) {
136
+ return '';
137
+ }
138
+
139
+ return delta;
140
+ }
141
+
142
+ _finish(state, emitDone = false) {
143
+ this.parserAdapter.flushState(state.parserState);
144
+ if (emitDone) {
145
+ this.emit('data', {
146
+ type: 'done',
147
+ conversationId: state.conversationId,
148
+ engine: state.engine
149
+ });
150
+ }
151
+ this.unwatch(state.key);
152
+ }
153
+ }
154
+
155
+ module.exports = RemotePaneWatcher;
@@ -81,8 +81,8 @@ class SessionStore {
81
81
  }
82
82
 
83
83
  /** Get conversations grouped by date */
84
- listGroupedByDate({ workspace, limit = 100 } = {}) {
85
- const convs = this.listConversations({ workspace, limit });
84
+ listGroupedByDate({ workspace, host, limit = 100 } = {}) {
85
+ const convs = this.listConversations({ workspace, host, limit });
86
86
  const grouped = {};
87
87
  for (const c of convs) {
88
88
  const date = new Date(c.updated_at * 1000).toISOString().split('T')[0];
@@ -155,7 +155,7 @@ class SessionStore {
155
155
  const active = stmt.all();
156
156
  for (const conv of active) {
157
157
  if (conv.tmux_window) {
158
- const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host !== 'local' ? conv.host : null);
158
+ const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host || 'local');
159
159
  if (!exists) {
160
160
  this.updateConversation(conv.id, { status: 'dead' });
161
161
  }
@@ -5,35 +5,67 @@
5
5
  * Every AI session = one tmux window inside the master session.
6
6
  */
7
7
 
8
- const { execSync, exec } = require('child_process');
8
+ const { execSync } = require('child_process');
9
9
  const path = require('path');
10
10
  const fs = require('fs');
11
+ const { readHosts, getHostByName } = require('../config/hosts');
12
+ const { PATHS } = require('../utils/paths');
11
13
 
12
14
  const TMUX_SESSION = 'nexuscrew';
13
15
 
16
+ function shellEscape(value) {
17
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
18
+ }
19
+
14
20
  class TmuxManager {
15
21
  constructor(config = {}) {
16
22
  this.sessionName = config.tmuxSession || TMUX_SESSION;
17
- this.logDir = config.logDir || path.join(process.env.HOME, '.nexuscrew', 'logs');
18
- this.hosts = config.hosts || [{ name: 'local', type: 'local' }];
23
+ this.logDir = config.logDir || PATHS.LOGS_DIR;
19
24
 
20
- // Ensure log dir
21
25
  if (!fs.existsSync(this.logDir)) {
22
26
  fs.mkdirSync(this.logDir, { recursive: true });
23
27
  }
24
28
  }
25
29
 
26
- // --- Session management ---
30
+ getAllHosts() {
31
+ return readHosts();
32
+ }
33
+
34
+ getHost(name) {
35
+ return this.resolveHost(name);
36
+ }
27
37
 
28
- /** Ensure master tmux session exists */
29
- ensureSession(host = null) {
38
+ resolveHost(hostRef) {
39
+ if (!hostRef || hostRef === 'local') {
40
+ return getHostByName('local');
41
+ }
42
+
43
+ if (typeof hostRef === 'string') {
44
+ return getHostByName(hostRef);
45
+ }
46
+
47
+ if (typeof hostRef === 'object') {
48
+ if (hostRef.type === 'local' || hostRef.name === 'local') {
49
+ return getHostByName('local');
50
+ }
51
+ if (hostRef.name) {
52
+ return { ...getHostByName(hostRef.name), ...hostRef };
53
+ }
54
+ return hostRef;
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ ensureSession(hostRef = null) {
61
+ const host = this.resolveHost(hostRef);
30
62
  const cmd = this._cmd('tmux new-session -d -s ${SESSION} -x 200 -y 50 2>/dev/null || true', host);
31
63
  this._exec(cmd);
32
64
  }
33
65
 
34
- /** Check if master session exists */
35
- hasSession(host = null) {
66
+ hasSession(hostRef = null) {
36
67
  try {
68
+ const host = this.resolveHost(hostRef);
37
69
  this._exec(this._cmd('tmux has-session -t ${SESSION} 2>/dev/null', host));
38
70
  return true;
39
71
  } catch {
@@ -41,98 +73,68 @@ class TmuxManager {
41
73
  }
42
74
  }
43
75
 
44
- // --- Window management ---
45
-
46
- /**
47
- * Create a new tmux window running a CLI command
48
- * @param {Object} opts
49
- * @param {string} opts.windowName - Unique window name (e.g. "claude-abc123")
50
- * @param {string} opts.command - Full command to run
51
- * @param {string} opts.cwd - Working directory
52
- * @param {string} opts.host - Host name or null for local
53
- * @returns {{ windowName, logPath }}
54
- */
55
- createWindow({ windowName, command, cwd, host = null }) {
76
+ createWindow({ windowName, command, cwd, host: hostRef = null }) {
77
+ const host = this.resolveHost(hostRef);
56
78
  this.ensureSession(host);
57
79
 
58
- const logPath = path.join(this.logDir, `${windowName}.log`);
59
- const logDir = path.dirname(logPath);
60
-
61
- // For remote hosts, logs go to a local path
62
- const fullCommand = cwd
63
- ? `cd ${cwd} && ${command} 2>&1 | tee ${logPath}`
64
- : `${command} 2>&1 | tee ${logPath}`;
80
+ const logPath = this.getLogPath(windowName);
81
+ const targetCwd = cwd || process.env.HOME || '.';
82
+ const fullCommand = `cd ${shellEscape(targetCwd)} && ${command} 2>&1 | tee ${shellEscape(logPath)}`;
65
83
 
66
84
  const cmd = this._cmd(
67
- `tmux new-window -t \${SESSION} -n ${windowName} -c '${cwd || process.env.HOME}' "${fullCommand.replace(/"/g, '\\"')}"`,
85
+ `tmux new-window -t \${SESSION} -n ${shellEscape(windowName)} -c ${shellEscape(targetCwd)} ${shellEscape(fullCommand)}`,
68
86
  host
69
87
  );
70
88
 
71
- this._exec(cmd);
72
-
89
+ this._exec(cmd, 20000);
73
90
  return { windowName, logPath };
74
91
  }
75
92
 
76
- /**
77
- * Send text to a tmux window (like typing)
78
- * @param {string} windowName
79
- * @param {string} text - Text to send
80
- * @param {Object} opts
81
- * @param {boolean} opts.enter - Press Enter after text
82
- * @param {string} opts.host - Host name or null
83
- */
84
93
  sendKeys(windowName, text, opts = {}) {
85
- const enterSuffix = opts.enter ? ' Enter' : '';
86
- const escapedText = text.replace(/'/g, "'\\''");
94
+ const host = this.resolveHost(opts.host);
87
95
  const cmd = this._cmd(
88
- `tmux send-keys -t \${SESSION}:${windowName} '${escapedText}'${enterSuffix}`,
89
- opts.host
96
+ `tmux send-keys -t \${SESSION}:${shellEscape(windowName)} -l ${shellEscape(text)}`,
97
+ host
90
98
  );
91
99
  this._exec(cmd);
100
+ if (opts.enter) {
101
+ this.sendSpecialKey(windowName, 'Enter', host);
102
+ }
92
103
  }
93
104
 
94
- /**
95
- * Send a special key (C-c, Escape, etc.)
96
- */
97
- sendSpecialKey(windowName, key, host = null) {
105
+ sendSpecialKey(windowName, key, hostRef = null) {
106
+ const host = this.resolveHost(hostRef);
98
107
  const cmd = this._cmd(
99
- `tmux send-keys -t \${SESSION}:${windowName} ${key}`,
108
+ `tmux send-keys -t \${SESSION}:${shellEscape(windowName)} ${shellEscape(key)}`,
100
109
  host
101
110
  );
102
111
  this._exec(cmd);
103
112
  }
104
113
 
105
- /** Interrupt: Escape then C-c (works for Claude/Codex/Gemini/Qwen) */
106
- interrupt(windowName, host = null) {
114
+ interrupt(windowName, hostRef = null) {
115
+ const host = this.resolveHost(hostRef);
107
116
  this.sendSpecialKey(windowName, 'Escape', host);
108
- setTimeout(() => {
109
- this.sendSpecialKey(windowName, 'C-c', host);
110
- }, 100);
117
+ this.sendSpecialKey(windowName, 'C-c', host);
111
118
  }
112
119
 
113
- /**
114
- * Capture current pane content
115
- * @returns {string} visible text in the pane
116
- */
117
- capturePane(windowName, host = null) {
120
+ capturePane(windowName, hostRef = null, lines = 200) {
118
121
  try {
122
+ const host = this.resolveHost(hostRef);
119
123
  const cmd = this._cmd(
120
- `tmux capture-pane -t \${SESSION}:${windowName} -p -S -100`,
124
+ `tmux capture-pane -t \${SESSION}:${shellEscape(windowName)} -p -S -${Math.max(50, lines)}`,
121
125
  host
122
126
  );
123
- return this._exec(cmd);
127
+ return this._exec(cmd, 15000);
124
128
  } catch {
125
129
  return '';
126
130
  }
127
131
  }
128
132
 
129
- /**
130
- * Check if a window exists
131
- */
132
- hasWindow(windowName, host = null) {
133
+ hasWindow(windowName, hostRef = null) {
133
134
  try {
135
+ const host = this.resolveHost(hostRef);
134
136
  const cmd = this._cmd(
135
- `tmux list-windows -t \${SESSION} -F '#{{window_name}}' 2>/dev/null | grep -qx '${windowName}'`,
137
+ `tmux list-windows -t \${SESSION} -F '#{window_name}' 2>/dev/null | grep -Fx -- ${shellEscape(windowName)}`,
136
138
  host
137
139
  );
138
140
  this._exec(cmd);
@@ -142,38 +144,35 @@ class TmuxManager {
142
144
  }
143
145
  }
144
146
 
145
- /**
146
- * Kill a window
147
- */
148
- killWindow(windowName, host = null) {
147
+ killWindow(windowName, hostRef = null) {
149
148
  try {
149
+ const host = this.resolveHost(hostRef);
150
150
  const cmd = this._cmd(
151
- `tmux kill-window -t \${SESSION}:${windowName} 2>/dev/null`,
151
+ `tmux kill-window -t \${SESSION}:${shellEscape(windowName)} 2>/dev/null`,
152
152
  host
153
153
  );
154
154
  this._exec(cmd);
155
155
  } catch {}
156
156
  }
157
157
 
158
- /**
159
- * List all windows in the master session
160
- * @returns {Array<{index, name, active, size}>}
161
- */
162
- listWindows(host = null) {
158
+ listWindows(hostRef = null) {
163
159
  try {
160
+ const host = this.resolveHost(hostRef);
164
161
  const cmd = this._cmd(
165
- `tmux list-windows -t \${SESSION} -F '#{{window_index}}:#{{window_name}} #{{window_width}}x#{{window_height}} #{{?window_active,1,0}}' 2>/dev/null`,
162
+ `tmux list-windows -t \${SESSION} -F '#{window_index}\t#{window_name}\t#{window_width}x#{window_height}\t#{?window_active,1,0}' 2>/dev/null`,
166
163
  host
167
164
  );
168
- const output = this._exec(cmd);
169
- return output.trim().split('\n').filter(Boolean).map(line => {
170
- const [idxName, size, active] = line.split(' ');
171
- const [index, ...nameParts] = idxName.split(':');
165
+ const output = this._exec(cmd, 15000).trim();
166
+ if (!output) return [];
167
+
168
+ return output.split('\n').filter(Boolean).map((line) => {
169
+ const [index, name, size, active] = line.split('\t');
172
170
  return {
173
- index: parseInt(index),
174
- name: nameParts.join(':'),
171
+ index: Number(index),
172
+ name,
175
173
  size,
176
- active: active === '1'
174
+ active: active === '1',
175
+ host: host?.name || 'local'
177
176
  };
178
177
  });
179
178
  } catch {
@@ -181,13 +180,11 @@ class TmuxManager {
181
180
  }
182
181
  }
183
182
 
184
- /**
185
- * Get process PID running in a window
186
- */
187
- getWindowPid(windowName, host = null) {
183
+ getWindowPid(windowName, hostRef = null) {
188
184
  try {
185
+ const host = this.resolveHost(hostRef);
189
186
  const cmd = this._cmd(
190
- `tmux list-panes -t \${SESSION}:${windowName} -F '#{{pane_pid}}' 2>/dev/null`,
187
+ `tmux list-panes -t \${SESSION}:${shellEscape(windowName)} -F '#{pane_pid}' 2>/dev/null`,
191
188
  host
192
189
  );
193
190
  return this._exec(cmd).trim();
@@ -196,40 +193,51 @@ class TmuxManager {
196
193
  }
197
194
  }
198
195
 
199
- // --- Host resolution ---
200
-
201
- /** Get host config by name */
202
- getHost(name) {
203
- if (!name || name === 'local') return null;
204
- return this.hosts.find(h => h.name === name) || null;
205
- }
206
-
207
- /** Get all configured hosts */
208
- getAllHosts() {
209
- return this.hosts;
210
- }
211
-
212
- /** Test SSH connectivity */
213
- testHost(host) {
196
+ testHost(hostRef) {
197
+ const host = this.resolveHost(hostRef);
214
198
  if (!host || host.type === 'local') return true;
215
199
  try {
216
- const sshCmd = this._sshCmd(host, 'echo ok');
217
- this._exec(sshCmd, 5000);
200
+ this._exec(this._sshCmd(host, 'echo ok'), 5000);
218
201
  return true;
219
202
  } catch {
220
203
  return false;
221
204
  }
222
205
  }
223
206
 
224
- // --- Internal ---
207
+ ensureRemoteDirectory(remotePath, hostRef) {
208
+ const host = this.resolveHost(hostRef);
209
+ if (!host || host.type === 'local') {
210
+ fs.mkdirSync(remotePath, { recursive: true });
211
+ return remotePath;
212
+ }
213
+ this._exec(this._sshCmd(host, `mkdir -p ${shellEscape(remotePath)}`), 10000);
214
+ return remotePath;
215
+ }
216
+
217
+ copyToHost(localPath, remotePath, hostRef) {
218
+ const host = this.resolveHost(hostRef);
219
+ if (!host || host.type === 'local') {
220
+ const targetDir = path.dirname(remotePath);
221
+ fs.mkdirSync(targetDir, { recursive: true });
222
+ fs.copyFileSync(localPath, remotePath);
223
+ return remotePath;
224
+ }
225
+
226
+ this.ensureRemoteDirectory(path.posix.dirname(remotePath), host);
227
+ const parts = ['scp', '-q', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes'];
228
+ if (host.port && host.port !== '22') parts.push('-P', String(host.port));
229
+ if (host.key) parts.push('-i', host.key);
230
+ parts.push(localPath, `${host.user}@${host.host}:${remotePath}`);
231
+ this._exec(parts.map(shellEscape).join(' '), 30000);
232
+ return remotePath;
233
+ }
234
+
235
+ getLogPath(windowName) {
236
+ return path.join(this.logDir, `${windowName}.log`);
237
+ }
225
238
 
226
- /**
227
- * Build command with SESSION variable substitution
228
- * @param {string} tmpl - Command template with ${SESSION}
229
- * @param {Object|null} host - Host config
230
- * @returns {string} final command
231
- */
232
- _cmd(tmpl, host) {
239
+ _cmd(tmpl, hostRef) {
240
+ const host = this.resolveHost(hostRef);
233
241
  const cmd = tmpl.replace(/\$\{SESSION\}/g, this.sessionName);
234
242
  if (!host || host.type === 'local') return cmd;
235
243
  return this._sshCmd(host, cmd);
@@ -237,11 +245,14 @@ class TmuxManager {
237
245
 
238
246
  _sshCmd(host, cmd) {
239
247
  const parts = ['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes'];
240
- if (host.port && host.port !== '22') parts.push('-p', host.port);
248
+ if (host.port && host.port !== '22') parts.push('-p', String(host.port));
241
249
  if (host.key) parts.push('-i', host.key);
242
250
  parts.push(`${host.user}@${host.host}`);
243
- parts.push(cmd);
244
- return parts.join(' ');
251
+ parts.push(shellEscape(cmd));
252
+ return parts.map((part, index) => {
253
+ if (index === parts.length - 1) return part;
254
+ return /\s/.test(part) ? shellEscape(part) : part;
255
+ }).join(' ');
245
256
  }
246
257
 
247
258
  _exec(cmd, timeout = 10000) {
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync } = require('child_process');
4
+ const { ensureDirectories, PATHS } = require('../utils/paths');
5
+ const { initializeConfig, isInitialized } = require('../config/manager');
6
+ const { ensureHostsFile } = require('../config/hosts');
7
+ const { isTermux } = require('../utils/termux');
8
+
9
+ function has(command) {
10
+ try {
11
+ execSync(`which ${command}`, { stdio: 'ignore' });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ ensureDirectories();
19
+ ensureHostsFile();
20
+
21
+ if (!isInitialized()) {
22
+ initializeConfig({ workspaces: [`${process.env.HOME || ''}/Dev`] });
23
+ }
24
+
25
+ console.log('');
26
+ console.log('NexusCrew postinstall');
27
+ console.log(` config: ${PATHS.CONFIG_FILE}`);
28
+ console.log(` hosts: ${PATHS.HOSTS_FILE}`);
29
+ console.log(` tmux: ${has('tmux') ? 'OK' : 'missing'}`);
30
+ console.log(` ssh: ${has('ssh') ? 'OK' : 'missing'}`);
31
+ console.log(` scp: ${has('scp') ? 'OK' : 'missing'}`);
32
+ console.log(` whisper-cli: ${has('whisper-cli') ? 'OK' : 'missing'}`);
33
+ if (isTermux()) {
34
+ console.log(' platform: Termux');
35
+ console.log(` termux-services: ${has('sv') ? 'OK' : 'missing'}`);
36
+ }
37
+ console.log(' next: run `nexuscrew setup` to configure workspace, SSH hosts, local STT, and optional Termux boot/runit');
38
+ console.log('');
@@ -37,9 +37,24 @@ const PATHS = {
37
37
  // Engines config directory
38
38
  ENGINES_DIR: path.join(HOME, '.nexuscrew', 'engines'),
39
39
 
40
+ // Attachments directory
41
+ ATTACHMENTS_DIR: path.join(HOME, '.nexuscrew', 'attachments'),
42
+
43
+ // Local helper binaries
44
+ BIN_DIR: path.join(HOME, '.nexuscrew', 'bin'),
45
+
46
+ // Hosts registry
47
+ HOSTS_FILE: path.join(HOME, '.nexuscrew', 'hosts.json'),
48
+
40
49
  // Termux boot script
41
50
  BOOT_SCRIPT: path.join(HOME, '.termux', 'boot', 'nexuscrew-start.sh'),
42
51
 
52
+ // Termux runit service directory
53
+ TERMUX_SERVICE_DIR: path.join(process.env.PREFIX || '/data/data/com.termux/files/usr', 'var', 'service', 'nexuscrew'),
54
+
55
+ // Termux runit logs
56
+ TERMUX_SERVICE_LOG_DIR: path.join(process.env.PREFIX || '/data/data/com.termux/files/usr', 'var', 'log', 'sv', 'nexuscrew'),
57
+
43
58
  // Claude CLI history (read-only)
44
59
  CLAUDE_HISTORY: path.join(HOME, '.claude', 'history.jsonl'),
45
60
  CLAUDE_PROJECTS: path.join(HOME, '.claude', 'projects'),
@@ -56,7 +71,9 @@ function ensureDirectories() {
56
71
  PATHS.CONFIG_DIR,
57
72
  PATHS.DATA_DIR,
58
73
  PATHS.LOGS_DIR,
59
- PATHS.ENGINES_DIR
74
+ PATHS.ENGINES_DIR,
75
+ PATHS.ATTACHMENTS_DIR,
76
+ PATHS.BIN_DIR
60
77
  ];
61
78
 
62
79
  for (const dir of dirs) {
@@ -24,6 +24,14 @@ function getPrefix() {
24
24
  return process.env.PREFIX || '/data/data/com.termux/files/usr';
25
25
  }
26
26
 
27
+ function getServiceDir(serviceName = 'nexuscrew') {
28
+ return path.join(getPrefix(), 'var', 'service', serviceName);
29
+ }
30
+
31
+ function getStartServicesScript() {
32
+ return path.join(getPrefix(), 'etc', 'profile.d', 'start-services.sh');
33
+ }
34
+
27
35
  /**
28
36
  * Check if a Termux package is installed
29
37
  */
@@ -68,6 +76,10 @@ function isTermuxBootAvailable() {
68
76
  return fs.existsSync(bootDir);
69
77
  }
70
78
 
79
+ function isTermuxServicesAvailable() {
80
+ return fs.existsSync(getStartServicesScript()) && isPackageInstalled('sv');
81
+ }
82
+
71
83
  /**
72
84
  * Acquire Termux wake lock
73
85
  */
@@ -130,16 +142,41 @@ function checkRequiredPackages() {
130
142
  return status;
131
143
  }
132
144
 
145
+ function openUrl(url) {
146
+ try {
147
+ if (isTermux() && isPackageInstalled('termux-open-url')) {
148
+ spawn('termux-open-url', [url], { detached: true, stdio: 'ignore' }).unref();
149
+ return true;
150
+ }
151
+
152
+ if (process.platform === 'darwin') {
153
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
154
+ return true;
155
+ }
156
+
157
+ if (isPackageInstalled('xdg-open')) {
158
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
159
+ return true;
160
+ }
161
+ } catch {}
162
+
163
+ return false;
164
+ }
165
+
133
166
  module.exports = {
134
167
  isTermux,
135
168
  getPrefix,
169
+ getServiceDir,
170
+ getStartServicesScript,
136
171
  isPackageInstalled,
137
172
  installPackage,
138
173
  isTermuxApiWorking,
139
174
  isTermuxBootAvailable,
175
+ isTermuxServicesAvailable,
140
176
  acquireWakeLock,
141
177
  releaseWakeLock,
142
178
  sendNotification,
143
179
  getRequiredPackages,
144
- checkRequiredPackages
180
+ checkRequiredPackages,
181
+ openUrl
145
182
  };