ninja-terminals 2.4.7 → 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');
@@ -7,8 +7,14 @@ const os = require('os');
7
7
  const path = require('path');
8
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) {
@@ -66,6 +76,17 @@ async function killStaleRuntime(session, selfPid = process.pid) {
66
76
  // Is the process even alive?
67
77
  try { process.kill(pid, 0); } catch { return false; }
68
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
+
69
90
  // Verify it's a Ninja server (avoid killing an unrelated reused PID).
70
91
  if (process.platform !== 'win32') {
71
92
  try {
@@ -84,6 +105,40 @@ async function killStaleRuntime(session, selfPid = process.pid) {
84
105
  }
85
106
  }
86
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
+
87
142
  function writeRuntimeSession(session) {
88
143
  ensureSessionDir();
89
144
  const payload = {
@@ -93,10 +148,36 @@ function writeRuntimeSession(session) {
93
148
  createdAt: new Date().toISOString(),
94
149
  ...session,
95
150
  };
96
- 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
+ }
97
159
  return payload;
98
160
  }
99
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
+
100
181
  function updateRuntimeSession(patch) {
101
182
  const current = readRuntimeSession() || {};
102
183
  return writeRuntimeSession({ ...current, ...patch, updatedAt: new Date().toISOString() });
@@ -111,7 +192,29 @@ function readRuntimeSession() {
111
192
  }
112
193
  }
113
194
 
114
- 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
+ }
115
218
  try {
116
219
  fs.unlinkSync(SESSION_FILE);
117
220
  } catch {
@@ -351,6 +454,7 @@ function hasVisualAfter(afterTimestamp, options = {}) {
351
454
  module.exports = {
352
455
  SESSION_DIR,
353
456
  SESSION_FILE,
457
+ SESSIONS_DIR,
354
458
  TOKEN_FILE,
355
459
  LEDGER_FILE,
356
460
  VERIFICATION_LEDGER_FILE,
@@ -358,10 +462,12 @@ module.exports = {
358
462
  VALID_VISUAL_STAGES,
359
463
  findAvailablePort,
360
464
  killStaleRuntime,
465
+ reclaimPort,
361
466
  writeRuntimeSession,
362
467
  updateRuntimeSession,
363
468
  readRuntimeSession,
364
469
  clearRuntimeSession,
470
+ listRuntimeSessions,
365
471
  writeAuthToken,
366
472
  readAuthToken,
367
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
@@ -311,11 +311,18 @@ async function main() {
311
311
  if (health.ok && !fresh && sessionMatchesLaunchConfig(existingSession, requestedCwd, launchConfig)) {
312
312
  session = existingSession;
313
313
  action = 'reuse';
314
- } else if (health.ok) {
315
- log('Healthy session found, but it does not match the requested launch config (or --fresh)');
316
- log('Replacing it to avoid orphaned servers and a climbing port...');
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.
317
318
  const killed = await killStaleRuntime(existingSession);
318
- if (killed) log(`Terminated previous runtime (pid ${existingSession.pid})`);
319
+ if (killed) log(`Terminated dead previous runtime (pid ${existingSession.pid})`);
320
+ } else if (health.ok) {
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).');
319
326
  }
320
327
  }
321
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninja-terminals",
3
- "version": "2.4.7",
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');
@@ -27,6 +28,7 @@ const {
27
28
  SESSION_FILE,
28
29
  findAvailablePort,
29
30
  killStaleRuntime,
31
+ reclaimPort,
30
32
  readRuntimeSession,
31
33
  writeRuntimeSession,
32
34
  updateRuntimeSession,
@@ -181,8 +183,10 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro', agentType =
181
183
  // Resolve working directory — custom cwd or default to PROJECT_DIR
182
184
  // Validate cwd to prevent broken paths like "\" from corrupting terminal startup
183
185
  let workDir = cwd || PROJECT_DIR;
184
- if (!workDir || workDir.length < 2 || !path.isAbsolute(workDir)) {
185
- 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`);
186
190
  workDir = PROJECT_DIR;
187
191
  }
188
192
  const settingsDir = workDir;
@@ -498,7 +502,7 @@ app.post('/api/upload', requireAuth, upload.single('file'), (req, res) => {
498
502
  app.get('/health', (_req, res) => {
499
503
  res.json({
500
504
  status: 'ok',
501
- version: '2.0.0',
505
+ version: pkg.version,
502
506
  terminals: terminals.size,
503
507
  sseClients: sse.clientCount,
504
508
  uptime: process.uptime(),
@@ -1307,11 +1311,17 @@ app.post('/api/auth/register', async (req, res) => {
1307
1311
  // ── Start ───────────────────────────────────────────────────
1308
1312
 
1309
1313
  async function startServer() {
1310
- // Replace any previous Ninja runtime instead of orphaning it (prevents a
1311
- // pile-up of dead servers and a port that climbs on each relaunch).
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).
1312
1319
  const previous = readRuntimeSession();
1313
1320
  const replaced = await killStaleRuntime(previous);
1314
- if (replaced) console.log(`[runtime] replaced previous Ninja runtime (pid ${previous.pid})`);
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)`);
1315
1325
 
1316
1326
  const selectedPort = await findAvailablePort(PREFERRED_PORT);
1317
1327
  if (selectedPort !== PREFERRED_PORT) {
@@ -1361,7 +1371,7 @@ async function startServer() {
1361
1371
  process.on('exit', () => {
1362
1372
  try {
1363
1373
  const addr = server.address();
1364
- if (addr && addr.port) clearRuntimeSession();
1374
+ if (addr && addr.port) clearRuntimeSession(addr.port);
1365
1375
  } catch {
1366
1376
  // ignore shutdown cleanup failures
1367
1377
  }