ninja-terminals 2.4.6 → 2.4.9

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/cli.js CHANGED
@@ -185,7 +185,7 @@ MCP tools available after restart:
185
185
  }
186
186
 
187
187
  const port = parseInt(getArg('--port', '3300'), 10);
188
- const terminals = parseInt(getArg('--terminals', '2'), 10); // Free tier default
188
+ const terminals = parseInt(getArg('--terminals', '4'), 10); // Default fleet size
189
189
  const cwd = getArg('--cwd', process.cwd());
190
190
  const token = getArg('--token', null);
191
191
  const offline = hasFlag('--offline');
@@ -5,10 +5,16 @@ const http = require('http');
5
5
  const net = require('net');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
- const { spawn } = require('child_process');
8
+ const { spawn, execSync } = require('child_process');
9
9
 
10
- const SESSION_DIR = path.join(os.homedir(), '.ninja');
10
+ // NINJA_HOME lets a second, isolated Ninja runtime coexist with another agent's default
11
+ // runtime: it gets its own session.json/lock so the two never SIGTERM each other on launch.
12
+ // Defaults to ~/.ninja, so existing single-runtime behavior is unchanged.
13
+ const SESSION_DIR = path.join(process.env.NINJA_HOME || os.homedir(), '.ninja');
11
14
  const SESSION_FILE = path.join(SESSION_DIR, 'session.json');
15
+ // Per-port session files live here, one per running runtime. session.json above
16
+ // stays as a "most-recent" pointer so existing readers keep working unchanged.
17
+ const SESSIONS_DIR = path.join(SESSION_DIR, 'sessions');
12
18
  const TOKEN_FILE = path.join(SESSION_DIR, 'token');
13
19
  const LEDGER_FILE = path.join(SESSION_DIR, 'dispatch-ledger.ndjson');
14
20
  const VERIFICATION_LEDGER_FILE = path.join(SESSION_DIR, 'verification-ledger.ndjson');
@@ -18,6 +24,10 @@ function ensureSessionDir() {
18
24
  fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
19
25
  }
20
26
 
27
+ function sessionFileForPort(port) {
28
+ return path.join(SESSIONS_DIR, `${port}.json`);
29
+ }
30
+
21
31
  // Probe a single host: 'free' (bindable), 'inuse' (EADDRINUSE), or
22
32
  // 'unavailable' (stack absent / other error — don't treat as a collision).
23
33
  function probePort(port, host) {
@@ -54,6 +64,81 @@ async function findAvailablePort(preferredPort, host = '127.0.0.1', maxAttempts
54
64
  throw new Error(`No available port found from ${start} to ${Math.min(start + maxAttempts - 1, 65535)}`);
55
65
  }
56
66
 
67
+ // Terminate a previous Ninja runtime recorded in the session file so a new
68
+ // launch REPLACES it instead of orphaning it (which caused servers to pile up
69
+ // and the port to climb 3300 -> 3301 -> 3302 on each relaunch). Verifies the
70
+ // PID is actually a Ninja process before killing (guards against PID reuse).
71
+ // Returns true if it killed something. selfPid is skipped (don't kill caller).
72
+ async function killStaleRuntime(session, selfPid = process.pid) {
73
+ const pid = session && session.pid;
74
+ if (!pid || pid === selfPid) return false;
75
+
76
+ // Is the process even alive?
77
+ try { process.kill(pid, 0); } catch { return false; }
78
+
79
+ // NEVER kill a HEALTHY runtime — it may be another instance doing real work.
80
+ // Only a dead/hung runtime is a "stale" one worth reclaiming. This is the
81
+ // guard that stops a new launch from SIGTERMing a live parallel fleet.
82
+ if (session.port) {
83
+ const health = await healthCheckSession(
84
+ { host: session.host || '127.0.0.1', port: session.port },
85
+ 1500,
86
+ );
87
+ if (health.ok) return false;
88
+ }
89
+
90
+ // Verify it's a Ninja server (avoid killing an unrelated reused PID).
91
+ if (process.platform !== 'win32') {
92
+ try {
93
+ const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8' }).trim();
94
+ if (!/server\.js|cli\.js|ninja/i.test(cmd)) return false;
95
+ } catch { return false; }
96
+ }
97
+
98
+ try {
99
+ process.kill(pid, 'SIGTERM');
100
+ // Give it a moment to release its port so the new server can reclaim it.
101
+ await new Promise((resolve) => setTimeout(resolve, 800));
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // Reclaim the preferred port from any *Ninja* server still holding it. The
109
+ // pid-based killStaleRuntime only knows the single pid in session.json, so
110
+ // orphans on climbed ports (3301/3302 from earlier crashed relaunches) survive
111
+ // and findAvailablePort climbs past them — each climb is a new server and a new
112
+ // browser tab (the 2/4/6-tab pile-up). Sweeping the port itself kills them all.
113
+ // Only SIGTERMs processes verified as Ninja; a non-Ninja listener is left alone
114
+ // (caller then climbs past it as before). POSIX-only (lsof); no-op elsewhere.
115
+ async function reclaimPort(port, selfPid = process.pid) {
116
+ if (process.platform === 'win32') return 0;
117
+ // Never reclaim a HEALTHY fleet. If the port answers /health, it's a live
118
+ // Ninja instance — leave it alone and let the caller climb to the next port.
119
+ // Only sweep when the listener is dead/hung (a true orphan).
120
+ const holder = await healthCheckSession({ host: '127.0.0.1', port }, 1500);
121
+ if (holder.ok) return 0;
122
+ let pids;
123
+ try {
124
+ pids = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { encoding: 'utf8' })
125
+ .split('\n').map((s) => parseInt(s, 10)).filter((p) => p && p !== selfPid);
126
+ } catch {
127
+ return 0; // lsof absent or nothing on the port
128
+ }
129
+ let killed = 0;
130
+ for (const pid of new Set(pids)) {
131
+ try {
132
+ const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8' }).trim();
133
+ if (!/server\.js|cli\.js|ninja/i.test(cmd)) continue; // not ours — leave it
134
+ process.kill(pid, 'SIGTERM');
135
+ killed++;
136
+ } catch { /* gone already / not killable */ }
137
+ }
138
+ if (killed) await new Promise((resolve) => setTimeout(resolve, 800)); // let ports release
139
+ return killed;
140
+ }
141
+
57
142
  function writeRuntimeSession(session) {
58
143
  ensureSessionDir();
59
144
  const payload = {
@@ -63,10 +148,36 @@ function writeRuntimeSession(session) {
63
148
  createdAt: new Date().toISOString(),
64
149
  ...session,
65
150
  };
66
- fs.writeFileSync(SESSION_FILE, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 });
151
+ const json = JSON.stringify(payload, null, 2) + '\n';
152
+ // Pointer: "most-recent runtime" — keeps every existing reader working.
153
+ fs.writeFileSync(SESSION_FILE, json, { mode: 0o600 });
154
+ // Per-port file: lets parallel fleets coexist without clobbering each other.
155
+ if (payload.port) {
156
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
157
+ fs.writeFileSync(sessionFileForPort(payload.port), json, { mode: 0o600 });
158
+ }
67
159
  return payload;
68
160
  }
69
161
 
162
+ // All currently-recorded runtimes (one per per-port file). Most-recent first.
163
+ function listRuntimeSessions() {
164
+ let files;
165
+ try {
166
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'));
167
+ } catch {
168
+ return [];
169
+ }
170
+ const sessions = files.map((f) => {
171
+ try {
172
+ return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
173
+ } catch {
174
+ return null;
175
+ }
176
+ }).filter(Boolean);
177
+ sessions.sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')));
178
+ return sessions;
179
+ }
180
+
70
181
  function updateRuntimeSession(patch) {
71
182
  const current = readRuntimeSession() || {};
72
183
  return writeRuntimeSession({ ...current, ...patch, updatedAt: new Date().toISOString() });
@@ -81,7 +192,29 @@ function readRuntimeSession() {
81
192
  }
82
193
  }
83
194
 
84
- function clearRuntimeSession() {
195
+ // Remove a runtime's record on shutdown. With a port, only THIS runtime's
196
+ // per-port file is removed; the shared pointer is repointed to a surviving
197
+ // fleet (sync, best-effort — no health check, this runs in process 'exit')
198
+ // instead of being deleted, so other live fleets stay discoverable. Called
199
+ // with no port it keeps the old behavior (delete the pointer).
200
+ function clearRuntimeSession(port) {
201
+ if (port) {
202
+ try { fs.unlinkSync(sessionFileForPort(port)); } catch { /* already gone */ }
203
+ let pointer = null;
204
+ try { pointer = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { /* none */ }
205
+ // Only touch the pointer if it was pointing at us.
206
+ if (!pointer || pointer.port === port) {
207
+ const survivors = listRuntimeSessions();
208
+ if (survivors.length) {
209
+ try {
210
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(survivors[0], null, 2) + '\n', { mode: 0o600 });
211
+ } catch { /* best effort */ }
212
+ } else {
213
+ try { fs.unlinkSync(SESSION_FILE); } catch { /* already gone */ }
214
+ }
215
+ }
216
+ return;
217
+ }
85
218
  try {
86
219
  fs.unlinkSync(SESSION_FILE);
87
220
  } catch {
@@ -321,16 +454,20 @@ function hasVisualAfter(afterTimestamp, options = {}) {
321
454
  module.exports = {
322
455
  SESSION_DIR,
323
456
  SESSION_FILE,
457
+ SESSIONS_DIR,
324
458
  TOKEN_FILE,
325
459
  LEDGER_FILE,
326
460
  VERIFICATION_LEDGER_FILE,
327
461
  VISUAL_LEDGER_FILE,
328
462
  VALID_VISUAL_STAGES,
329
463
  findAvailablePort,
464
+ killStaleRuntime,
465
+ reclaimPort,
330
466
  writeRuntimeSession,
331
467
  updateRuntimeSession,
332
468
  readRuntimeSession,
333
469
  clearRuntimeSession,
470
+ listRuntimeSessions,
334
471
  writeAuthToken,
335
472
  readAuthToken,
336
473
  healthCheckSession,
package/mcp-server.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * Also starts HTTP server on port 3300 for browser UI.
8
8
  */
9
9
 
10
+ const pkg = require('./package.json');
10
11
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
11
12
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
12
13
  const {
@@ -359,7 +360,7 @@ httpServer.on('upgrade', (req, socket, head) => {
359
360
  app.get('/health', (_req, res) => {
360
361
  res.json({
361
362
  status: 'ok',
362
- version: '2.1.5-mcp',
363
+ version: `${pkg.version}-mcp`,
363
364
  terminals: terminals.size,
364
365
  mode: 'mcp',
365
366
  });
@@ -510,7 +511,7 @@ app.post('/api/terminals/:id/input', async (req, res) => {
510
511
  // ── MCP Server Setup ────────────────────────────────────────
511
512
 
512
513
  const mcpServer = new Server(
513
- { name: 'ninja-terminals', version: '2.1.5' },
514
+ { name: 'ninja-terminals', version: pkg.version },
514
515
  { capabilities: { tools: {} } }
515
516
  );
516
517
 
package/ninja-ensure.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  readRuntimeSession,
12
12
  healthCheckSession,
13
13
  writeRuntimeSession,
14
+ killStaleRuntime,
14
15
  requestJson,
15
16
  } = require('./lib/runtime-session');
16
17
  const {
@@ -310,9 +311,18 @@ async function main() {
310
311
  if (health.ok && !fresh && sessionMatchesLaunchConfig(existingSession, requestedCwd, launchConfig)) {
311
312
  session = existingSession;
312
313
  action = 'reuse';
314
+ } else if (health.ok && fresh) {
315
+ // --fresh explicitly asked to start over: only safe to reclaim a runtime
316
+ // that isn't healthy. killStaleRuntime health-gates, so a live fleet is
317
+ // never killed here either — we just start alongside it.
318
+ const killed = await killStaleRuntime(existingSession);
319
+ if (killed) log(`Terminated dead previous runtime (pid ${existingSession.pid})`);
313
320
  } else if (health.ok) {
314
- log('Healthy session found, but it does not match the requested launch config');
315
- log('Will start a fresh runtime for this explicit request');
321
+ // A healthy fleet is running but doesn't match this launch (different
322
+ // project/mode, or older than the reuse window). Do NOT kill it — that
323
+ // would destroy a parallel instance's work. Start a new server alongside;
324
+ // it climbs to the next free port and the two coexist.
325
+ log('A different healthy Ninja fleet is running — starting a new one alongside it (it will use the next free port).');
316
326
  }
317
327
  }
318
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninja-terminals",
3
- "version": "2.4.6",
3
+ "version": "2.4.9",
4
4
  "description": "MCP server for multi-terminal Claude Code orchestration with DAG task management, parallel execution, and self-improvement",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1033,7 +1033,8 @@ function setupAddTerminal() {
1033
1033
  if (!btn) return;
1034
1034
 
1035
1035
  // Store last used directory
1036
- let lastCwd = localStorage.getItem('ninja-last-cwd') || '/Users/davidmini/Desktop/Projects';
1036
+ // ponytail: empty default → server falls back to its own process.cwd(); never hardcode an author machine path
1037
+ let lastCwd = localStorage.getItem('ninja-last-cwd') || '';
1037
1038
  let lastAgentType = localStorage.getItem('ninja-last-agent-type') || 'claude';
1038
1039
 
1039
1040
  if (preset) {
package/public/index.html CHANGED
@@ -9,6 +9,11 @@
9
9
  <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
10
10
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
11
11
  <link rel="stylesheet" href="style.css">
12
+ <!-- PostHog analytics -->
13
+ <script>
14
+ !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
15
+ posthog.init('phc_AmUYDCH793ekSGYpDxfoH8raVceuXjxnPxqF844i7u7A',{api_host:'https://us.i.posthog.com',defaults:'2025-05-24'})
16
+ </script>
12
17
  </head>
13
18
  <body>
14
19
  <!-- Learnings Modal -->
package/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ const pkg = require('./package.json');
1
2
  const express = require('express');
2
3
  const http = require('http');
3
4
  const { WebSocketServer } = require('ws');
@@ -26,6 +27,9 @@ const { runPostSession } = require('./lib/post-session');
26
27
  const {
27
28
  SESSION_FILE,
28
29
  findAvailablePort,
30
+ killStaleRuntime,
31
+ reclaimPort,
32
+ readRuntimeSession,
29
33
  writeRuntimeSession,
30
34
  updateRuntimeSession,
31
35
  clearRuntimeSession,
@@ -179,8 +183,10 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro', agentType =
179
183
  // Resolve working directory — custom cwd or default to PROJECT_DIR
180
184
  // Validate cwd to prevent broken paths like "\" from corrupting terminal startup
181
185
  let workDir = cwd || PROJECT_DIR;
182
- if (!workDir || workDir.length < 2 || !path.isAbsolute(workDir)) {
183
- console.warn(`[spawn] Invalid cwd "${workDir}", falling back to PROJECT_DIR`);
186
+ // ponytail: must also exist on disk — a hardcoded/stale absolute path passes isAbsolute
187
+ // but kills pty.spawn ("Connection closed"). Fall back instead of dying.
188
+ if (!workDir || workDir.length < 2 || !path.isAbsolute(workDir) || !fs.existsSync(workDir)) {
189
+ console.warn(`[spawn] Invalid or missing cwd "${workDir}", falling back to PROJECT_DIR`);
184
190
  workDir = PROJECT_DIR;
185
191
  }
186
192
  const settingsDir = workDir;
@@ -496,7 +502,7 @@ app.post('/api/upload', requireAuth, upload.single('file'), (req, res) => {
496
502
  app.get('/health', (_req, res) => {
497
503
  res.json({
498
504
  status: 'ok',
499
- version: '2.0.0',
505
+ version: pkg.version,
500
506
  terminals: terminals.size,
501
507
  sseClients: sse.clientCount,
502
508
  uptime: process.uptime(),
@@ -1305,6 +1311,18 @@ app.post('/api/auth/register', async (req, res) => {
1305
1311
  // ── Start ───────────────────────────────────────────────────
1306
1312
 
1307
1313
  async function startServer() {
1314
+ // Clean up only DEAD runtimes — never a healthy one. killStaleRuntime and
1315
+ // reclaimPort both health-gate: a runtime that answers /health is a live
1316
+ // parallel fleet (possibly another instance doing real work) and is left
1317
+ // untouched; this server then climbs to the next free port and coexists.
1318
+ // Only dead/hung leftovers get reclaimed (keeps the no-orphan-pileup win).
1319
+ const previous = readRuntimeSession();
1320
+ const replaced = await killStaleRuntime(previous);
1321
+ if (replaced) console.log(`[runtime] cleaned up dead Ninja runtime (pid ${previous.pid})`);
1322
+
1323
+ const reclaimed = await reclaimPort(PREFERRED_PORT);
1324
+ if (reclaimed) console.log(`[runtime] reclaimed port ${PREFERRED_PORT} from ${reclaimed} dead Ninja orphan(s)`);
1325
+
1308
1326
  const selectedPort = await findAvailablePort(PREFERRED_PORT);
1309
1327
  if (selectedPort !== PREFERRED_PORT) {
1310
1328
  console.log(`[port] ${PREFERRED_PORT} is unavailable; using ${selectedPort}`);
@@ -1353,7 +1371,7 @@ async function startServer() {
1353
1371
  process.on('exit', () => {
1354
1372
  try {
1355
1373
  const addr = server.address();
1356
- if (addr && addr.port) clearRuntimeSession();
1374
+ if (addr && addr.port) clearRuntimeSession(addr.port);
1357
1375
  } catch {
1358
1376
  // ignore shutdown cleanup failures
1359
1377
  }