@nonbot/cli 0.9.5 → 0.9.7

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.
@@ -3,16 +3,17 @@ import { loadAuth, getActiveProfile } from '../lib/auth.js';
3
3
  import * as activations from '../lib/activations.js';
4
4
  import { fireActivation, makeHeadlessSpawner, } from '../lib/activations.js';
5
5
  import { checkCompletions } from '../lib/completion.js';
6
- import { deliverAnsweredPrompts } from '../lib/run-prompt.js';
7
- import { loadOrCreateMachineId, resolveMachineName } from '../lib/machine.js';
6
+ import { evictOldestToCap } from '../lib/bounded-set.js';
7
+ import { deliverAnsweredPrompts, capturePane, parsePromptMenu, mintPromptId, } from '../lib/run-prompt.js';
8
+ import { loadOrCreateMachineId, resolveMachineName, shortMachineId } from '../lib/machine.js';
8
9
  import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
9
10
  import { groupBySession, launchCoordinatedSet, setIsIsolated, } from '../lib/choir/coordinated-set.js';
10
11
  import { IsolatedSessionTracker, reconcileAndCleanup as defaultReconcileAndCleanup, resolveBaseBranch as defaultResolveBaseBranch, } from '../lib/choir/isolated-session.js';
11
- import { applyPaneTitle } from '../lib/pane-title.js';
12
+ import { applyPaneTitle, applyPaneState } from '../lib/pane-title.js';
12
13
  import { installService, uninstallService } from '../lib/service.js';
13
14
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
14
15
  import { VERSION } from '../version.js';
15
- import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activationCard, runSummary, formatElapsed, c, } from '../lib/output.js';
16
+ import { errorBlock, statusRow, daemonOpener, daemonCloser, activationCard, runSummary, formatElapsed, liveFooter, needsYouBanner, resumedLine, buildPaneBorderFormat, buildTmuxStatusLeft, buildTmuxStatusRight, WORDMARK_WIDTH, c, } from '../lib/output.js';
16
17
  export const POLL_FAST_MS = 2000;
17
18
  export const POLL_MAX_MS = 30000;
18
19
  export const POLL_INTERVAL_MS = POLL_FAST_MS;
@@ -81,6 +82,21 @@ export function applyNonbotTmuxConfig(deps = {}) {
81
82
  tmux('set-option', 'set-titles', 'on');
82
83
  tmux('set-option', 'set-titles-string', 'NONBOT TMUX │ #W');
83
84
  tmux('refresh-client', '-S');
85
+ if (deps.chrome) {
86
+ const ascii = env.NONBOT_ASCII_ONLY === '1' || env.NONBOT_ASCII_ONLY === 'true';
87
+ tmux('set-option', '-g', 'pane-border-status', 'top');
88
+ tmux('set-option', '-g', 'pane-border-format', buildPaneBorderFormat({ ascii }));
89
+ tmux('set-option', '-g', 'pane-border-lines', 'heavy');
90
+ tmux('set-option', '-g', 'status', 'on');
91
+ tmux('set-option', '-g', 'status-interval', '5');
92
+ tmux('set-option', '-g', 'status-style', 'bg=#0a0d17,fg=#8a93a6');
93
+ tmux('set-option', '-g', 'status-left-length', '60');
94
+ tmux('set-option', '-g', 'status-right-length', '120');
95
+ tmux('set-option', '-g', 'status-left', buildTmuxStatusLeft({ ascii }));
96
+ tmux('set-option', '-g', 'status-right', buildTmuxStatusRight({ ascii }));
97
+ tmux('set-option', '-g', 'window-status-current-style', 'fg=#0a0d17,bg=#6c8cff,bold');
98
+ tmux('refresh-client', '-S');
99
+ }
84
100
  if (!stdoutIsTTY)
85
101
  return;
86
102
  const insideTmux = inTmuxSession(env);
@@ -135,7 +151,7 @@ export function maybeReexecIntoTmux(deps = {}) {
135
151
  return 'reexeced';
136
152
  }
137
153
  export async function runDaemonCommand(args = [], options = {}, deps = {}) {
138
- const log = deps.log ?? ((s) => process.stdout.write(s));
154
+ const rawLog = deps.log ?? ((s) => process.stdout.write(s));
139
155
  const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
140
156
  const fetchImpl = deps.fetchImpl ?? fetch;
141
157
  const loader = deps.loadAuth ?? loadAuth;
@@ -147,7 +163,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
147
163
  const install = deps.installService ?? installService;
148
164
  const res = await install();
149
165
  if (res.ok)
150
- log(statusRow('✓', 'Service installed', res.message) + '\n');
166
+ rawLog(statusRow('✓', 'Service installed', res.message) + '\n');
151
167
  else
152
168
  errLog(statusRow('✗', 'Service install failed', res.message, { stream: process.stderr }) + '\n');
153
169
  return res.ok ? 0 : 1;
@@ -156,7 +172,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
156
172
  const uninstall = deps.uninstallService ?? uninstallService;
157
173
  const res = await uninstall();
158
174
  if (res.ok)
159
- log(statusRow('✓', 'Service removed', res.message) + '\n');
175
+ rawLog(statusRow('✓', 'Service removed', res.message) + '\n');
160
176
  else
161
177
  errLog(statusRow('✗', 'Service uninstall failed', res.message, { stream: process.stderr }) + '\n');
162
178
  return res.ok ? 0 : 1;
@@ -169,7 +185,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
169
185
  if (headless) {
170
186
  deps.headless = true;
171
187
  if (!deps.spawnTerminal) {
172
- deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log, errLog });
188
+ deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log: rawLog, errLog });
173
189
  }
174
190
  }
175
191
  const auth = await loader();
@@ -181,8 +197,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
181
197
  const tmuxSessionName = detectTmux();
182
198
  const machineId = deps.machineId ?? loadOrCreateMachineId();
183
199
  const machineName = resolveMachineName();
200
+ const tmuxChromeEnabled = tmuxSessionName !== null &&
201
+ !nonbotTmuxOptOut() &&
202
+ process.env.NONBOT_NO_TMUX_STATUS !== '1';
184
203
  if (tmuxSessionName) {
185
- applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
204
+ applyNonbotTmuxConfig({ spawnSync: deps.spawnSync, chrome: tmuxChromeEnabled });
186
205
  }
187
206
  const resolveTerminalFn = deps.resolveTerminal ?? resolveTerminal;
188
207
  const resolvedTerminalProfile = headless ? null : resolveTerminalFn(undefined);
@@ -201,6 +220,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
201
220
  const trackedPanes = new Map();
202
221
  const killedByStop = new Set();
203
222
  const injectedPrompts = new Set();
223
+ const openPrompts = new Map();
204
224
  const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
205
225
  const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
206
226
  const runHeartbeats = new Map();
@@ -235,13 +255,116 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
235
255
  let doneCount = 0;
236
256
  let failedCount = 0;
237
257
  const paneTitlingEnabled = tmuxSessionName !== null && !nonbotTmuxOptOut();
258
+ const spawnForTmux = deps.spawnSync;
238
259
  const retitlePane = (state, paneId, story) => {
239
260
  if (!paneTitlingEnabled || !paneId)
240
261
  return;
241
- applyPaneTitle(paneId, state, story, deps.spawnSync);
262
+ applyPaneTitle(paneId, state, story, spawnForTmux);
263
+ applyPaneState(paneId, state, spawnForTmux);
242
264
  };
265
+ const pushTmuxStatus = (needsYou = openPrompts.size, halted = false) => {
266
+ if (!tmuxChromeEnabled)
267
+ return;
268
+ const spawn = deps.spawnSync ?? nodeSpawnSync;
269
+ const set = (name, value) => {
270
+ try {
271
+ spawn('tmux', ['set-option', '-g', name, String(value)], {
272
+ encoding: 'utf-8',
273
+ timeout: 1000,
274
+ windowsHide: true,
275
+ });
276
+ }
277
+ catch {
278
+ }
279
+ };
280
+ set('@nb_running', trackedPanes.size);
281
+ set('@nb_done', doneCount);
282
+ set('@nb_failed', failedCount);
283
+ set('@nb_needsyou', needsYou);
284
+ set('@nb_poll', Math.round((fixedInterval ?? currentInterval) / 1000));
285
+ set('@nb_halted', halted ? 1 : 0);
286
+ try {
287
+ spawn('tmux', ['refresh-client', '-S'], { encoding: 'utf-8', timeout: 1000, windowsHide: true });
288
+ }
289
+ catch {
290
+ }
291
+ };
292
+ const log = (s) => rawLog(s);
293
+ let lastPollOk = true;
294
+ const noBell = process.env.NONBOT_NO_BELL === '1' || process.env.NONBOT_NO_BELL === 'true';
295
+ const stdoutIsTTY = deps.stdoutIsTTY ?? (process.stdout?.isTTY === true && !process.env.VITEST);
296
+ const pollHealth = () => {
297
+ if (openPrompts.size > 0)
298
+ return 'waiting';
299
+ if (!lastPollOk)
300
+ return 'reconnecting';
301
+ if (fixedInterval === undefined && currentInterval > POLL_FAST_MS)
302
+ return 'backoff';
303
+ return 'live';
304
+ };
305
+ const heartbeatLine = () => liveFooter({
306
+ running: trackedPanes.size,
307
+ waiting: openPrompts.size,
308
+ done: doneCount,
309
+ failed: failedCount,
310
+ nextPollMs: fixedInterval ?? currentInterval,
311
+ version: `v${VERSION}`,
312
+ machine: machineName,
313
+ health: pollHealth(),
314
+ });
243
315
  const emitSummary = () => {
244
316
  log(runSummary({ running: trackedPanes.size, done: doneCount, failed: failedCount }) + '\n');
317
+ pushTmuxStatus();
318
+ };
319
+ const sweepPanePrompts = () => {
320
+ if (!paneTitlingEnabled || trackedPanes.size === 0)
321
+ return;
322
+ let changed = false;
323
+ for (const [activationId, paneId] of trackedPanes) {
324
+ let menu;
325
+ try {
326
+ menu = parsePromptMenu(capturePane(paneId, spawnForTmux), '');
327
+ }
328
+ catch {
329
+ continue;
330
+ }
331
+ const meta = trackedMeta.get(activationId);
332
+ if (menu.options.length > 0) {
333
+ const promptId = mintPromptId(activationId, paneId, menu);
334
+ if (openPrompts.get(activationId) === promptId)
335
+ continue;
336
+ openPrompts.set(activationId, promptId);
337
+ log('\n' +
338
+ needsYouBanner({
339
+ activationId,
340
+ story: meta?.story,
341
+ paneId,
342
+ provider: meta?.provider,
343
+ options: menu.options,
344
+ elapsedMs: meta ? Date.now() - meta.startedAt : undefined,
345
+ }) +
346
+ '\n');
347
+ retitlePane('waiting', paneId, meta?.story ?? '');
348
+ if (!noBell && stdoutIsTTY)
349
+ rawLog('\x07');
350
+ changed = true;
351
+ }
352
+ else if (openPrompts.has(activationId)) {
353
+ openPrompts.delete(activationId);
354
+ log(resumedLine(activationId) + '\n');
355
+ retitlePane('running', paneId, meta?.story ?? '');
356
+ changed = true;
357
+ }
358
+ }
359
+ for (const id of [...openPrompts.keys()]) {
360
+ if (!trackedPanes.has(id)) {
361
+ openPrompts.delete(id);
362
+ changed = true;
363
+ }
364
+ }
365
+ if (changed) {
366
+ pushTmuxStatus();
367
+ }
245
368
  };
246
369
  let running = true;
247
370
  let sigHandler;
@@ -250,7 +373,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
250
373
  running = false;
251
374
  for (const id of [...runHeartbeats.keys()])
252
375
  stopHeartbeat(id);
253
- log('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
376
+ pushTmuxStatus(0, true);
377
+ rawLog('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
254
378
  process.exit(0);
255
379
  };
256
380
  process.on('SIGINT', sigHandler);
@@ -275,17 +399,23 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
275
399
  ];
276
400
  if (tmuxSessionName)
277
401
  badgeSublines.push(`tmux host: ${tmuxSessionName}`);
402
+ const stdoutCols = deps.stdoutColumns ?? process.stdout?.columns ?? 0;
403
+ const stdoutRows = deps.stdoutRows ?? process.stdout?.rows ?? 0;
404
+ const compactMast = stdoutIsTTY &&
405
+ ((stdoutCols > 0 && stdoutCols < WORDMARK_WIDTH + 4) || (stdoutRows > 0 && stdoutRows < 16));
278
406
  log(daemonOpener(`v${VERSION}`, {
279
407
  state: 'IDLE',
280
408
  badgeColor: 'green',
281
409
  badgeLabel: 'CONNECTED',
282
410
  badgeSublines,
411
+ compact: compactMast,
283
412
  }) + '\n\n');
284
413
  const headerMarker = c.green('[ + ]');
285
414
  const headerLabel = c.white('LISTENING · READY FOR ACTIVATIONS');
286
415
  log(`${headerMarker} ${c.bold(headerLabel)}\n`);
287
416
  const ctxRows = [
288
417
  ['YOU', auth.email],
418
+ ['MACHINE', `${machineName} · ${shortMachineId(machineId)}`],
289
419
  ['LISTENING', `${hostUrl}/api/cli/activations/pending`],
290
420
  ['PROFILE', profileName],
291
421
  ['LANDING', terminalLabel],
@@ -303,6 +433,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
303
433
  inTmux: tmuxSessionName !== null,
304
434
  }) + '\n');
305
435
  log('\n');
436
+ pushTmuxStatus();
306
437
  while (running) {
307
438
  let firedThisPoll = false;
308
439
  try {
@@ -329,6 +460,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
329
460
  return 1;
330
461
  }
331
462
  if (res.ok) {
463
+ lastPollOk = true;
332
464
  const body = (await res.json());
333
465
  const pendingKills = body?.pendingKills ?? [];
334
466
  if (pendingKills.length > 0) {
@@ -496,6 +628,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
496
628
  baseUrl: auth.baseUrl,
497
629
  pat: auth.pat,
498
630
  injected: injectedPrompts,
631
+ trackedPanes,
499
632
  machineId,
500
633
  fetchImpl,
501
634
  spawnImpl: deps.spawnSync,
@@ -504,6 +637,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
504
637
  }
505
638
  catch {
506
639
  }
640
+ try {
641
+ sweepPanePrompts();
642
+ }
643
+ catch {
644
+ }
507
645
  if (trackedPanes.size > 0) {
508
646
  const reported = await checkCompletions({
509
647
  tracked: trackedPanes,
@@ -537,7 +675,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
537
675
  doneCount++;
538
676
  trackedMeta.delete(id);
539
677
  safeEmit(id, RUN_STAGE.FINISHED);
540
- stopHeartbeat(id, 'daemon-exit');
678
+ stopHeartbeat(id, 'finished');
541
679
  try {
542
680
  const session = isolatedSessions.noteTerminal(id);
543
681
  if (session) {
@@ -556,18 +694,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
556
694
  emitSummary();
557
695
  }
558
696
  }
559
- if (!firedThisPoll && pendingKills.length === 0) {
560
- log(pollTick({
561
- pending: 0,
562
- sleepMs: fixedInterval ?? currentInterval,
563
- backingOff: fixedInterval === undefined && currentInterval > POLL_FAST_MS,
564
- }) + '\n');
697
+ if (!firedThisPoll && pendingKills.length === 0 && !tmuxChromeEnabled) {
698
+ log(heartbeatLine() + '\n');
565
699
  }
566
- if (seen.size > 500)
567
- seen.clear();
700
+ evictOldestToCap(seen, 500);
568
701
  }
569
702
  }
570
703
  catch (e) {
704
+ lastPollOk = false;
571
705
  errLog(statusRow('⚠', 'poll error', e.message, { stream: process.stderr }) + '\n');
572
706
  }
573
707
  if (options.oneShot)
@@ -60,6 +60,17 @@ export async function runRunPromptHookCommand(deps = {}) {
60
60
  }
61
61
  if (!question && parsed.options.length === 0)
62
62
  return 0;
63
+ if (paneId && /^%\d+$/.test(paneId)) {
64
+ try {
65
+ spawnSync('tmux', ['set-option', '-p', '-t', paneId, '@nb_state', 'waiting'], {
66
+ encoding: 'utf-8',
67
+ timeout: 1000,
68
+ windowsHide: true,
69
+ });
70
+ }
71
+ catch {
72
+ }
73
+ }
63
74
  const promptId = mintPromptId(activationId, paneId, { question, options: parsed.options });
64
75
  const ok = await reportPrompt({
65
76
  baseUrl,
@@ -9,6 +9,7 @@ import { appendActivityLog } from './activity-log.js';
9
9
  import { activationCard, clockTime } from './output.js';
10
10
  import { buildCommandFromParams, hookSettingsPathFor, shellQuoteSingle, } from './command-builders.js';
11
11
  import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
12
+ const PANE_ID_RE = /^%\d+$/;
12
13
  export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
13
14
  export const WIRE_COMMAND_MAX_LENGTH = 4096;
14
15
  export const COMMAND_WARN_LENGTH = 8192;
@@ -450,6 +451,8 @@ export async function executePendingKills(kills, baseUrl, token) {
450
451
  return;
451
452
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
452
453
  const runOne = async (k) => {
454
+ if (!PANE_ID_RE.test(k.tmuxPaneId))
455
+ return;
453
456
  try {
454
457
  spawnSync('tmux', ['send-keys', '-t', k.tmuxPaneId, 'C-c'], {
455
458
  encoding: 'utf-8',
@@ -0,0 +1,13 @@
1
+ export function evictOldestToCap(set, cap) {
2
+ if (set.size <= cap)
3
+ return;
4
+ const overflow = set.size - cap;
5
+ const victims = [];
6
+ for (const id of set) {
7
+ victims.push(id);
8
+ if (victims.length >= overflow)
9
+ break;
10
+ }
11
+ for (const id of victims)
12
+ set.delete(id);
13
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
- import { removeWorktree as defaultRemoveWorktree } from './worktree.js';
2
+ import { removeWorktree as defaultRemoveWorktree, pollGitStatus as defaultPollGitStatus } from './worktree.js';
3
3
  import { runReconcile as defaultRunReconcile } from './reconcile.js';
4
4
  import { isValidBranch } from './names.js';
5
5
  export class IsolatedSessionTracker {
@@ -55,10 +55,21 @@ export function resolveBaseBranch(repoRoot, spawnImpl = nodeSpawnSync) {
55
55
  }
56
56
  return 'main';
57
57
  }
58
+ const LAUNCHER_ARTIFACTS = new Set(['.mcp.json']);
59
+ function worktreeHasUnmergedWork(worktreePath, baseBranch, spawnImpl, pollGitStatusImpl) {
60
+ try {
61
+ const status = pollGitStatusImpl(worktreePath, spawnImpl, baseBranch);
62
+ return status.dirtyPaths.some((p) => !LAUNCHER_ARTIFACTS.has(p));
63
+ }
64
+ catch {
65
+ return true;
66
+ }
67
+ }
58
68
  export function reconcileAndCleanup(session, deps = {}) {
59
69
  const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
60
70
  const runReconcileImpl = deps.runReconcileImpl ?? defaultRunReconcile;
61
71
  const removeWorktreeImpl = deps.removeWorktreeImpl ?? defaultRemoveWorktree;
72
+ const pollGitStatusImpl = deps.pollGitStatusImpl ?? defaultPollGitStatus;
62
73
  const log = deps.log;
63
74
  const branches = session.panes.map((p) => p.branch);
64
75
  const reconcile = runReconcileImpl({
@@ -72,7 +83,12 @@ export function reconcileAndCleanup(session, deps = {}) {
72
83
  });
73
84
  const removed = [];
74
85
  if (!reconcile.blocked) {
86
+ let kept = 0;
75
87
  for (const p of session.panes) {
88
+ if (worktreeHasUnmergedWork(p.worktreePath, session.baseBranch, spawnImpl, pollGitStatusImpl)) {
89
+ kept++;
90
+ continue;
91
+ }
76
92
  try {
77
93
  removeWorktreeImpl({ repoRoot: session.repoRoot, worktreePath: p.worktreePath, force: true });
78
94
  removed.push(p.worktreePath);
@@ -82,7 +98,9 @@ export function reconcileAndCleanup(session, deps = {}) {
82
98
  }
83
99
  log?.(`[reconcile] ${session.sessionId} — merged ${reconcile.merged.length} ` +
84
100
  `branch${reconcile.merged.length === 1 ? '' : 'es'}, ` +
85
- `removed ${removed.length} worktree${removed.length === 1 ? '' : 's'}\n`);
101
+ `removed ${removed.length} worktree${removed.length === 1 ? '' : 's'}` +
102
+ (kept > 0 ? `, kept ${kept} with uncommitted work for the human` : '') +
103
+ `\n`);
86
104
  }
87
105
  else {
88
106
  log?.(`[reconcile] ${session.sessionId} — BLOCKED (${reconcile.reason}); ` +
@@ -6,13 +6,13 @@ export function listLivePaneIds(spawnImpl = nodeSpawnSync) {
6
6
  timeout: 1500,
7
7
  windowsHide: true,
8
8
  });
9
+ if (r.status !== 0)
10
+ return null;
9
11
  const out = typeof r.stdout === 'string' ? r.stdout : '';
10
- if (r.status !== 0 && !out)
11
- return new Set();
12
12
  return new Set(out.split('\n').map((s) => s.trim()).filter((s) => /^%\d+$/.test(s)));
13
13
  }
14
14
  catch {
15
- return new Set();
15
+ return null;
16
16
  }
17
17
  }
18
18
  export function detectCompletedActivations(tracked, livePaneIds, killed) {
@@ -48,9 +48,11 @@ export async function checkCompletions(opts) {
48
48
  if (tracked.size === 0)
49
49
  return [];
50
50
  const live = listLivePaneIds(opts.spawnImpl);
51
+ if (live === null)
52
+ return [];
51
53
  const completed = detectCompletedActivations(tracked, live, killed);
52
54
  for (const [id, pane] of [...tracked.entries()]) {
53
- if (!live.has(pane)) {
55
+ if (!live.has(pane) && killed.has(id)) {
54
56
  tracked.delete(id);
55
57
  killed.delete(id);
56
58
  }
@@ -58,11 +60,13 @@ export async function checkCompletions(opts) {
58
60
  const reported = [];
59
61
  for (const id of completed) {
60
62
  const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
61
- if (ok) {
62
- reported.push(id);
63
- const card = opts.renderComplete?.(id);
64
- opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
63
+ if (!ok) {
64
+ continue;
65
65
  }
66
+ tracked.delete(id);
67
+ reported.push(id);
68
+ const card = opts.renderComplete?.(id);
69
+ opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
66
70
  }
67
71
  return reported;
68
72
  }
@@ -36,6 +36,12 @@ export function loadOrCreateMachineId(deps = {}) {
36
36
  }
37
37
  return machineId;
38
38
  }
39
+ export function shortMachineId(id) {
40
+ const hex = (id || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
41
+ if (hex.length === 0)
42
+ return 'm_unknown';
43
+ return `m_${hex.slice(0, 6)}`;
44
+ }
39
45
  export function resolveMachineName(env = process.env) {
40
46
  const override = env.NONBOT_MACHINE_NAME;
41
47
  if (override && override.trim().length > 0)
@@ -1,12 +1,15 @@
1
1
  import { ANSI, renderBannerHeader } from './banner.js';
2
2
  export { ANSI };
3
3
  export const BRAND = {
4
- white: '\x1b[38;2;255;255;255m',
5
- cyan: '\x1b[38;2;79;195;247m',
6
- green: '\x1b[38;2;40;199;111m',
7
- amber: '\x1b[38;2;255;159;67m',
8
- red: '\x1b[38;2;255;76;81m',
9
- muted: '\x1b[38;2;139;148;158m',
4
+ white: '\x1b[38;2;240;235;226m',
5
+ cream: '\x1b[38;2;240;235;226m',
6
+ blue: '\x1b[38;2;108;140;255m',
7
+ indigo: '\x1b[38;2;155;108;240m',
8
+ cyan: '\x1b[38;2;88;199;230m',
9
+ green: '\x1b[38;2;69;209;127m',
10
+ amber: '\x1b[38;2;255;180;84m',
11
+ red: '\x1b[38;2;255;92;108m',
12
+ muted: '\x1b[38;2;138;147;166m',
10
13
  bold: '\x1b[1m',
11
14
  reset: '\x1b[0m',
12
15
  underline: '\x1b[4m',
@@ -31,6 +34,9 @@ function paint(s, code, stream = process.stdout) {
31
34
  }
32
35
  export const c = {
33
36
  white: (s, stream) => paintRaw(s, BRAND.white, stream),
37
+ cream: (s, stream) => paintRaw(s, BRAND.cream, stream),
38
+ blue: (s, stream) => paintRaw(s, BRAND.blue, stream),
39
+ indigo: (s, stream) => paintRaw(s, BRAND.indigo, stream),
34
40
  cyan: (s, stream) => paintRaw(s, BRAND.cyan, stream),
35
41
  green: (s, stream) => paintRaw(s, BRAND.green, stream),
36
42
  amber: (s, stream) => paintRaw(s, BRAND.amber, stream),
@@ -137,17 +143,7 @@ const WORDMARK_LINES = [
137
143
  ];
138
144
  export const WORDMARK_WIDTH = 63;
139
145
  export const WORDMARK_HEIGHT = 6;
140
- export const WORDMARK_GRADIENT = [
141
- '#13b9e2', '#1abadc', '#20bad7', '#27bbd1', '#2ebbcb', '#35bcc5', '#3bbdc0',
142
- '#42bdba', '#49beb4', '#50bfae', '#50bfae', '#5dc0a3', '#64c09d', '#6bc197',
143
- '#71c292', '#78c28c', '#7fc386', '#86c380', '#8cc47b', '#93c575', '#93c575',
144
- '#a1c669', '#a7c664', '#aec75e', '#b5c858', '#bcc852', '#c2c94d', '#c9ca47',
145
- '#d0ca41', '#d7cb3b', '#ddcb36', '#e4cc30', '#e0c536', '#dcbf3b', '#d8b841',
146
- '#d4b246', '#d0ab4c', '#cca551', '#c89e57', '#c4975d', '#c09162', '#bc8a68',
147
- '#bc8a68', '#bc8a68', '#b07679', '#ac707e', '#a86984', '#a36389', '#9f5c8f',
148
- '#9b5694', '#974f9a', '#9348a0', '#8f42a5', '#8f42a5', '#8735b0', '#832eb6',
149
- '#7f27bc', '#7b21c1', '#771ac7', '#7314cc', '#6f0dd2', '#6b07d7', '#6700dd',
150
- ];
146
+ export const WORDMARK_DOT_COLS = [31, 32, 33];
151
147
  function hexFg(hex) {
152
148
  const n = parseInt(hex.slice(1), 16);
153
149
  return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`;
@@ -163,8 +159,8 @@ function paintWordmarkLine(line, stream = process.stdout) {
163
159
  out += ' ';
164
160
  continue;
165
161
  }
166
- const hex = WORDMARK_GRADIENT[i] ?? WORDMARK_GRADIENT[WORDMARK_GRADIENT.length - 1];
167
- out += BRAND.bold + hexFg(hex) + ch + BRAND.reset;
162
+ const colour = WORDMARK_DOT_COLS.includes(i) ? BRAND.blue : BRAND.cream;
163
+ out += BRAND.bold + colour + ch + BRAND.reset;
168
164
  }
169
165
  return out;
170
166
  }
@@ -333,6 +329,43 @@ export function fixBlock(args) {
333
329
  });
334
330
  return lines.join('\n');
335
331
  }
332
+ export function promptLine(opts = {}) {
333
+ const stream = opts.stream ?? process.stdout;
334
+ const cursor = asciiOnly() ? '_' : '▌';
335
+ return (`${c.cyan('~', stream)} ${c.green('$', stream)} ` +
336
+ `${c.bold(c.cream('nonbot', stream), stream)} ${c.blue('run', stream)}` +
337
+ c.cream(cursor, stream));
338
+ }
339
+ export function gradientRule(width, opts = {}) {
340
+ const stream = opts.stream ?? process.stdout;
341
+ const n = Math.max(0, Math.floor(width));
342
+ const glyph = asciiOnly() ? '-' : '━';
343
+ if (!isTTY(stream) || n === 0)
344
+ return glyph.repeat(n);
345
+ const from = [0x6c, 0x8c, 0xff];
346
+ const to = [0x9b, 0x6c, 0xf0];
347
+ let out = '';
348
+ for (let i = 0; i < n; i++) {
349
+ const t = n === 1 ? 0 : i / (n - 1);
350
+ const r = Math.round(from[0] + (to[0] - from[0]) * t);
351
+ const g = Math.round(from[1] + (to[1] - from[1]) * t);
352
+ const b = Math.round(from[2] + (to[2] - from[2]) * t);
353
+ out += `\x1b[38;2;${r};${g};${b}m${glyph}`;
354
+ }
355
+ return out + BRAND.reset;
356
+ }
357
+ export function compactMasthead(version, opts = {}) {
358
+ const stream = opts.stream ?? process.stdout;
359
+ const state = opts.state ?? 'IDLE';
360
+ const badgeColor = opts.badgeColor ?? 'green';
361
+ const badgeLabel = opts.badgeLabel ?? 'CONNECTED';
362
+ const dotGlyph = asciiOnly() ? '*' : '●';
363
+ const brand = `${c.bold(c.cream('non.bot', stream), stream)}${c.blue('.', stream)}`;
364
+ const badge = colorize(`${dotGlyph} ${badgeLabel}`, badgeColor, stream);
365
+ const sep = c.muted('·', stream);
366
+ const tail = [c.muted(`daemon ${version}`, stream), sep, c.muted(state, stream)].join(' ');
367
+ return `${brand} ${badge} ${tail}`;
368
+ }
336
369
  export const DAEMON_TAGLINES = [
337
370
  "click ▶ on the canvas. terminal opens here. that's it.",
338
371
  ];
@@ -348,10 +381,19 @@ export function daemonOpener(version, opts = {}) {
348
381
  const seed = opts.seed ?? Date.now();
349
382
  const tagline = pickDaemonTagline(seed, opts.pool);
350
383
  const state = opts.state ?? 'IDLE';
351
- const breadcrumb = opts.breadcrumb ?? 'SYS://nonbot/daemon';
352
- const breadcrumbLine = breadcrumb
353
- ? ` ${c.cyan(breadcrumb, stream)}\n\n`
354
- : '';
384
+ if (opts.compact) {
385
+ return compactMasthead(version, {
386
+ state,
387
+ badgeColor: opts.badgeColor ?? 'green',
388
+ badgeLabel: opts.badgeLabel ?? 'CONNECTED',
389
+ stream,
390
+ });
391
+ }
392
+ const topLine = opts.breadcrumb === ''
393
+ ? ''
394
+ : opts.breadcrumb
395
+ ? ` ${c.cyan(opts.breadcrumb, stream)}\n\n`
396
+ : ` ${promptLine({ stream })}\n\n`;
355
397
  const stamp = renderWordmarkWithBadge({
356
398
  badgeColor: opts.badgeColor ?? 'green',
357
399
  badgeLabel: opts.badgeLabel ?? 'CONNECTED',
@@ -369,8 +411,10 @@ export function daemonOpener(version, opts = {}) {
369
411
  subParts.push(c.muted(trackedCaps(state), stream));
370
412
  }
371
413
  const subtitleLine = ` ${subParts.join(' ')}`;
414
+ const ruleWidth = Math.min(46, Math.max(10, terminalWidth(opts.width) - 2));
415
+ const ruleLine = ` ${gradientRule(ruleWidth, { stream })}`;
372
416
  const taglineLine = tagline ? `\n\n ${c.muted(tagline, stream)}` : '';
373
- return `${breadcrumbLine}${stamp}\n\n${subtitleLine}${taglineLine}`;
417
+ return `${topLine}${stamp}\n\n${ruleLine}\n\n${subtitleLine}${taglineLine}`;
374
418
  }
375
419
  export function daemonCloser(opts = {}) {
376
420
  const stream = opts.stream ?? process.stdout;
@@ -390,3 +434,112 @@ export function daemonCloser(opts = {}) {
390
434
  }
391
435
  return parts.join('');
392
436
  }
437
+ function stripControlBytes(s) {
438
+ return (s ?? '')
439
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
440
+ .replace(/ {2,}/g, ' ')
441
+ .trim();
442
+ }
443
+ const FOOTER_DOT_COLOR = {
444
+ live: 'green',
445
+ backoff: 'amber',
446
+ reconnecting: 'red',
447
+ waiting: 'red',
448
+ };
449
+ export function liveFooter(args) {
450
+ const stream = args.stream ?? process.stdout;
451
+ const failed = args.failed ?? 0;
452
+ const health = args.health ?? (args.waiting > 0 ? 'waiting' : 'live');
453
+ const dotGlyph = asciiOnly() ? '*' : '●';
454
+ const dotColor = FOOTER_DOT_COLOR[health];
455
+ const dot = colorize(dotGlyph, dotColor, stream);
456
+ const healthLabel = colorize(health, dotColor, stream);
457
+ const sep = c.muted('·', stream);
458
+ const segs = [`${dot} ${healthLabel}`];
459
+ segs.push(c.green(`${args.running} running`, stream));
460
+ if (args.waiting > 0)
461
+ segs.push(c.bold(c.amber(`${args.waiting} waiting`, stream), stream));
462
+ segs.push(c.muted(`${args.done} done`, stream));
463
+ if (failed > 0)
464
+ segs.push(c.red(`${failed} failed`, stream));
465
+ if (args.nextPollMs !== undefined) {
466
+ segs.push(c.muted(`next poll ${formatMsToS(args.nextPollMs)}`, stream));
467
+ }
468
+ if (args.version)
469
+ segs.push(c.muted(args.version, stream));
470
+ if (args.machine)
471
+ segs.push(c.cyan(args.machine, stream));
472
+ return segs.join(` ${sep} `);
473
+ }
474
+ export function needsYouBanner(args) {
475
+ const stream = args.stream ?? process.stdout;
476
+ const ascii = asciiOnly();
477
+ const bang = ascii ? '!!' : '‼';
478
+ const ptr = ascii ? '>' : '❯';
479
+ const tl = ascii ? '+' : '╔';
480
+ const tr = ascii ? '+' : '╗';
481
+ const bl = ascii ? '+' : '╚';
482
+ const br = ascii ? '+' : '╝';
483
+ const h = ascii ? '-' : '═';
484
+ const v = ascii ? '|' : '║';
485
+ const inner = Math.max(40, Math.min(args.width ?? 72, 100)) - 4;
486
+ const titleRaw = `${bang} NEEDS YOU · a run is waiting on your answer`;
487
+ const title = titleRaw.length > inner ? titleRaw.slice(0, inner - 1) + '…' : titleRaw.padEnd(inner, ' ');
488
+ const sep = c.muted('·', stream);
489
+ const lines = [];
490
+ lines.push(c.amber(` ${tl}${h.repeat(inner + 2)}${tr}`, stream));
491
+ lines.push(` ${c.amber(v, stream)} ${c.amber(c.bold(title, stream), stream)} ${c.amber(v, stream)}`);
492
+ lines.push(c.amber(` ${bl}${h.repeat(inner + 2)}${br}`, stream));
493
+ const story = stripControlBytes(args.story ?? '');
494
+ const idCell = c.bold(c.white(args.activationId, stream), stream);
495
+ lines.push(` ${idCell}${story ? ` ${sep} ${c.white(story, stream)}` : ''}`);
496
+ const metaParts = [c.amber('WAITING', stream)];
497
+ if (args.elapsedMs !== undefined)
498
+ metaParts.push(c.muted(formatElapsed(args.elapsedMs), stream));
499
+ metaParts.push(c.muted(`pane ${args.paneId}`, stream));
500
+ if (args.provider)
501
+ metaParts.push(c.muted(args.provider, stream));
502
+ lines.push(` ${metaParts.join(` ${sep} `)}`);
503
+ if (args.options && args.options.length > 0) {
504
+ const opts = args.options.slice(0, 4).map((o, i) => {
505
+ const label = stripControlBytes(o.label).slice(0, 40);
506
+ const lead = i === 0 ? `${c.cyan(ptr, stream)} ` : '';
507
+ return `${lead}${c.white(`${o.index}. ${label}`, stream)}`;
508
+ });
509
+ lines.push(` ${opts.join(' ')}`);
510
+ }
511
+ lines.push(` ${c.muted('answer it → on the canvas, on your phone, or', stream)} ` +
512
+ c.cyan(`tmux select-window -t ${args.paneId}`, stream));
513
+ return lines.join('\n');
514
+ }
515
+ export function resumedLine(activationId, stream = process.stdout) {
516
+ const check = asciiOnly() ? 'OK' : '✓';
517
+ return (` ${c.green(check, stream)} ${c.white(activationId, stream)} ` +
518
+ c.muted('· resumed · answer received', stream));
519
+ }
520
+ export function buildPaneBorderFormat(opts = {}) {
521
+ const warn = opts.ascii ? '!! ' : '⚠ ';
522
+ return (`#{?#{==:#{@nb_state},waiting},#[fg=#ffb454 bold]${warn},}` +
523
+ '#[fg=' +
524
+ '#{?#{==:#{@nb_state},waiting},#ffb454,' +
525
+ '#{?#{==:#{@nb_state},failed},#ff5c6c,' +
526
+ '#{?#{==:#{@nb_state},completed},#45d17f,' +
527
+ '#{?#{==:#{@nb_state},stopping},#ffb454,#58c7e6}}}}' +
528
+ ']' +
529
+ ' #{pane_title} ');
530
+ }
531
+ export function buildTmuxStatusLeft(opts = {}) {
532
+ const dot = opts.ascii ? '* ' : '● ';
533
+ return (' #[fg=#f0ebe2,bold]non#[fg=#6c8cff].#[fg=#f0ebe2]bot#[default] ' +
534
+ '#{?#{==:#{@nb_halted},1},#[fg=#ff5c6c]HALTED,' +
535
+ `#[fg=#45d17f]${dot}CONNECTED}#[default] `);
536
+ }
537
+ export function buildTmuxStatusRight(opts = {}) {
538
+ const warn = opts.ascii ? '!!' : '⚠';
539
+ const dot = '·';
540
+ return ('#[fg=#45d17f]#{@nb_running} run#[default] ' +
541
+ `#[fg=#8a93a6]${dot} #{@nb_done} done` +
542
+ `#{?#{@nb_failed}, #[fg=#ff5c6c]${dot} #{@nb_failed} fail,}#[default] ` +
543
+ `#{?#{@nb_needsyou},#[fg=#ffb454,bold]${dot} ${warn} #{@nb_needsyou} NEEDS YOU ,}` +
544
+ `#[fg=#58c7e6]${dot} #{host_short} ${dot} poll #{@nb_poll}s `);
545
+ }
@@ -4,12 +4,14 @@ const STATE_GLYPH = {
4
4
  completed: '✓',
5
5
  failed: '✗',
6
6
  stopping: '■',
7
+ waiting: '‼',
7
8
  };
8
9
  const STATE_GLYPH_ASCII = {
9
10
  running: '>',
10
11
  completed: 'OK',
11
12
  failed: 'XX',
12
13
  stopping: '#',
14
+ waiting: '!!',
13
15
  };
14
16
  function asciiOnly(env = process.env) {
15
17
  return env.NONBOT_ASCII_ONLY === '1' || env.NONBOT_ASCII_ONLY === 'true';
@@ -34,6 +36,26 @@ export function buildPaneTitleCommand(paneId, state, story, env = process.env) {
34
36
  const title = formatPaneTitle(state, story, env);
35
37
  return { cmd: 'tmux', args: ['select-pane', '-t', paneId, '-T', title] };
36
38
  }
39
+ export function buildPaneStateOptionCommand(paneId, state) {
40
+ if (!/^%\d+$/.test(paneId))
41
+ return null;
42
+ return { cmd: 'tmux', args: ['set-option', '-p', '-t', paneId, '@nb_state', state] };
43
+ }
44
+ export function applyPaneState(paneId, state, spawnImpl = nodeSpawnSync) {
45
+ const command = buildPaneStateOptionCommand(paneId, state);
46
+ if (!command)
47
+ return false;
48
+ try {
49
+ spawnImpl(command.cmd, command.args, {
50
+ encoding: 'utf-8',
51
+ timeout: 1000,
52
+ windowsHide: true,
53
+ });
54
+ }
55
+ catch {
56
+ }
57
+ return true;
58
+ }
37
59
  export function applyPaneTitle(paneId, state, story, spawnImpl = nodeSpawnSync, env = process.env) {
38
60
  const command = buildPaneTitleCommand(paneId, state, story, env);
39
61
  if (!command)
@@ -94,6 +94,9 @@ export function validateProvider(s) {
94
94
  const TMUX_SAFE_DIRECTIVES = new Set([
95
95
  'set', 'set-option', 'setw', 'set-window-option',
96
96
  ]);
97
+ const TMUX_FORBIDDEN_OPTIONS = new Set([
98
+ 'default-command', 'default-shell', 'command-alias',
99
+ ]);
97
100
  export function isSafeTmuxConf(conf) {
98
101
  for (const rawLine of conf.split(/\r?\n/)) {
99
102
  const line = rawLine.trim();
@@ -103,13 +106,32 @@ export function isSafeTmuxConf(conf) {
103
106
  continue;
104
107
  if (line.includes('#(') || line.includes('$(') || line.includes('`'))
105
108
  return false;
106
- const firstToken = line.split(/\s+/)[0];
107
- if (!TMUX_SAFE_DIRECTIVES.has(firstToken))
109
+ if (line.includes('\\'))
108
110
  return false;
111
+ for (const segment of line.split(';')) {
112
+ const cmd = segment.trim();
113
+ if (cmd === '')
114
+ continue;
115
+ const tokens = cmd.split(/\s+/);
116
+ if (!TMUX_SAFE_DIRECTIVES.has(tokens[0]))
117
+ return false;
118
+ for (const tok of tokens) {
119
+ if (TMUX_FORBIDDEN_OPTIONS.has(tok))
120
+ return false;
121
+ }
122
+ }
109
123
  }
110
124
  return true;
111
125
  }
112
- const ITERM_FORBIDDEN_KEYS = ['Command', 'Initial Text', 'Send Text at Start'];
126
+ const ITERM_FORBIDDEN_KEYS = [
127
+ 'Command',
128
+ 'Initial Text',
129
+ 'Send Text at Start',
130
+ 'Triggers',
131
+ 'Smart Selection Rules',
132
+ 'Semantic History',
133
+ 'Bound Hosts',
134
+ ];
113
135
  export function isSafeItermProfileJson(jsonStr) {
114
136
  let parsed;
115
137
  try {
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
3
3
  import { VERSION } from '../version.js';
4
+ import { evictOldestToCap } from './bounded-set.js';
4
5
  const QUESTION_MAX = 200;
5
6
  const LABEL_MAX = 80;
6
7
  const OPTIONS_MAX = 8;
@@ -59,6 +60,21 @@ export function parsePromptMenu(paneText, fallbackMessage = '') {
59
60
  question = (question || fallback).slice(0, QUESTION_MAX);
60
61
  return { question, options };
61
62
  }
63
+ export function capturePane(paneId, spawnImpl = nodeSpawnSync) {
64
+ if (!PANE_ID_RE.test(paneId))
65
+ return '';
66
+ try {
67
+ const r = spawnImpl('tmux', ['capture-pane', '-p', '-t', paneId], {
68
+ encoding: 'utf-8',
69
+ timeout: 2000,
70
+ windowsHide: true,
71
+ });
72
+ return typeof r.stdout === 'string' ? r.stdout : '';
73
+ }
74
+ catch {
75
+ return '';
76
+ }
77
+ }
62
78
  export function mintPromptId(activationId, paneId, parsed) {
63
79
  const sig = JSON.stringify({
64
80
  q: parsed.question,
@@ -186,13 +202,26 @@ export async function deliverAnsweredPrompts(opts) {
186
202
  return [];
187
203
  const reported = [];
188
204
  for (const p of answered) {
189
- if (!p.paneId)
205
+ const localPaneId = opts.trackedPanes?.get(p.activationId);
206
+ if (!localPaneId)
207
+ continue;
208
+ if (p.paneId && p.paneId !== localPaneId)
190
209
  continue;
191
210
  if (!opts.injected.has(p.promptId)) {
192
- const sent = injectAnswer(p.paneId, p.answerIndex, p.answerText, opts.spawnImpl);
211
+ const sent = injectAnswer(localPaneId, p.answerIndex, p.answerText, opts.spawnImpl);
193
212
  if (!sent)
194
213
  continue;
195
214
  opts.injected.add(p.promptId);
215
+ try {
216
+ const spawn = opts.spawnImpl ?? nodeSpawnSync;
217
+ spawn('tmux', ['set-option', '-p', '-t', localPaneId, '@nb_state', 'running'], {
218
+ encoding: 'utf-8',
219
+ timeout: 1000,
220
+ windowsHide: true,
221
+ });
222
+ }
223
+ catch {
224
+ }
196
225
  }
197
226
  const confirmed = await confirmDelivered({
198
227
  baseUrl: opts.baseUrl,
@@ -202,10 +231,9 @@ export async function deliverAnsweredPrompts(opts) {
202
231
  });
203
232
  if (confirmed) {
204
233
  reported.push(p.promptId);
205
- opts.log?.(`✓ ${p.activationId} · answer delivered to pane ${p.paneId}\n`);
234
+ opts.log?.(`✓ ${p.activationId} · answer delivered to pane ${localPaneId}\n`);
206
235
  }
207
236
  }
208
- if (opts.injected.size > 500)
209
- opts.injected.clear();
237
+ evictOldestToCap(opts.injected, 500);
210
238
  return reported;
211
239
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.9.5';
1
+ export const VERSION = '0.9.7';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",