claude-overnight 1.25.27 → 1.25.30

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.
@@ -1 +1 @@
1
- export declare const VERSION = "1.25.27";
1
+ export declare const VERSION = "1.25.30";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build — do not edit manually.
2
- export const VERSION = "1.25.27";
2
+ export const VERSION = "1.25.30";
package/dist/index.js CHANGED
@@ -10,9 +10,10 @@ import { planTasks, refinePlan, identifyThemes, buildThinkingTasks, orchestrate,
10
10
  import { modelDisplayName, formatContextWindow, DEFAULT_MODEL } from "./models.js";
11
11
  import { setPlannerEnvResolver } from "./planner-query.js";
12
12
  import { setTranscriptRunDir } from "./transcripts.js";
13
+ import { getProxyPort, buildProxyUrl } from "./proxy-port.js";
13
14
  import { pickModel, loadProviders, preflightProvider, buildEnvResolver, healthCheckCursorProxy, PROXY_DEFAULT_URL, isCursorProxyProvider, readCursorProxyLogTail, ensureCursorProxyRunning, bundledComposerProxyShellCommand, warnMacCursorAgentShellPatchIfNeeded, hasCursorAgentToken, } from "./providers.js";
14
15
  import { RunDisplay } from "./ui.js";
15
- import { renderSummary } from "./render.js";
16
+ import { renderSummary, wrap } from "./render.js";
16
17
  import { executeRun } from "./run.js";
17
18
  import { parseCliFlags, isAuthError, fetchModels, ask, select, selectKey, loadTaskFile, validateConcurrency, isGitRepo, validateGitRepo, showPlan, BRAILLE, makeProgressLog, } from "./cli.js";
18
19
  import { loadRunState, findIncompleteRuns, findOrphanedDesigns, backfillOrphanedPlans, formatTimeAgo, showRunHistory, readPreviousRunKnowledge, createRunDir, updateLatestSymlink, readMdDir, saveRunState, autoMergeBranches, } from "./state.js";
@@ -366,11 +367,13 @@ async function main() {
366
367
  const ago = formatTimeAgo(prev.startedAt);
367
368
  let lastStatus = "";
368
369
  try {
369
- lastStatus = readFileSync(join(run.dir, "status.md"), "utf-8").trim().slice(0, 120);
370
+ lastStatus = readFileSync(join(run.dir, "status.md"), "utf-8").trim().slice(0, 200);
370
371
  }
371
372
  catch { }
372
373
  const planTaskCount = prev.phase === "planning" ? countTasksInFile(join(run.dir, "tasks.json")) : 0;
373
374
  console.log(chalk.yellow(`\n ⚠ Unfinished run`) + chalk.dim(` · ${ago}`));
375
+ const termW = Math.max(process.stdout.columns ?? 80, 60);
376
+ const statusMaxW = Math.min(termW - 8, 80);
374
377
  const boxLines = prev.phase === "planning" ? [
375
378
  `${obj}${obj.length >= 50 ? "…" : ""}`,
376
379
  `Plan ready · ${planTaskCount} tasks · budget ${prev.budget} · ${prev.concurrency}× concurrent`,
@@ -380,8 +383,10 @@ async function main() {
380
383
  `${prev.accCompleted}/${prev.budget} sessions · ${Math.max(1, (prev.budget ?? 0) - prev.accCompleted)} remaining · $${prev.accCost.toFixed(2)}`,
381
384
  `Wave ${prev.waveNum + 1} · ${prev.phase}`,
382
385
  ];
383
- if (lastStatus)
384
- boxLines.push(lastStatus);
386
+ if (lastStatus) {
387
+ for (const wl of wrap(lastStatus, statusMaxW))
388
+ boxLines.push(wl);
389
+ }
385
390
  if (merged + unmerged + failed > 0)
386
391
  boxLines.push(`${merged} merged · ${unmerged} unmerged · ${failed} failed`);
387
392
  const boxW = Math.max(...boxLines.map(l => l.length)) + 4;
@@ -415,7 +420,7 @@ async function main() {
415
420
  const merged = s.branches.filter(b => b.status === "merged").length;
416
421
  let lastStatus = "";
417
422
  try {
418
- lastStatus = readFileSync(join(shown[i].dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 70);
423
+ lastStatus = readFileSync(join(shown[i].dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 120);
419
424
  }
420
425
  catch { }
421
426
  console.log(chalk.cyan(` ${i + 1}`) + ` ${obj}${obj.length >= 50 ? "…" : ""}`);
@@ -426,8 +431,11 @@ async function main() {
426
431
  else {
427
432
  console.log(chalk.dim(` ${s.accCompleted}/${s.budget} · $${s.accCost.toFixed(2)} · ${ago} · ${s.phase} at wave ${s.waveNum + 1}${merged ? ` · ${merged} merged` : ""}`));
428
433
  }
429
- if (lastStatus)
430
- console.log(chalk.dim(` ${lastStatus}`));
434
+ if (lastStatus) {
435
+ const termW = Math.max(process.stdout.columns ?? 80, 60);
436
+ for (const wl of wrap(lastStatus, termW - 6))
437
+ console.log(chalk.dim(` ${wl}`));
438
+ }
431
439
  console.log("");
432
440
  }
433
441
  const action = await selectKey(` ${chalk.dim(`[1-${shown.length}] resume`)}`, [
@@ -882,7 +890,18 @@ async function main() {
882
890
  }
883
891
  // Auto-start cursor proxy before pinging (restarts when a token exists so stale listeners get CURSOR_API_KEY).
884
892
  if (cursorProxies.length > 0) {
885
- await ensureCursorProxyRunning();
893
+ const resolvedPort = getProxyPort(cwd);
894
+ const resolvedUrl = buildProxyUrl(resolvedPort);
895
+ await ensureCursorProxyRunning(resolvedUrl);
896
+ // Sync providers to the resolved port (may differ from default if per-project port was picked)
897
+ for (const p of cursorProxies) {
898
+ if (!p.baseURL || p.baseURL === PROXY_DEFAULT_URL) {
899
+ p.baseURL = resolvedUrl;
900
+ }
901
+ }
902
+ if (resolvedUrl !== PROXY_DEFAULT_URL) {
903
+ console.log(chalk.dim(` Proxy port: ${resolvedPort}`));
904
+ }
886
905
  if (!hasCursorAgentToken()) {
887
906
  console.error(chalk.red(` ✗ Cursor models require a User API key — add it via ${chalk.bold("Cursor…")} setup, or set ` +
888
907
  `${chalk.bold("CURSOR_API_KEY")} / ${chalk.bold("CURSOR_BRIDGE_API_KEY")}, or ${chalk.bold("cursorApiKey")} in providers.json.`));
@@ -17,6 +17,8 @@ export declare class InteractivePanel {
17
17
  preview: string;
18
18
  body: string;
19
19
  }): void;
20
+ /** Close the panel entirely (set mode to "none"). */
21
+ close(): void;
20
22
  collapse(): void;
21
23
  toggle(): void;
22
24
  scroll(direction: "up" | "down", visibleRows: number): void;
@@ -35,6 +35,12 @@ export class InteractivePanel {
35
35
  this._bodyLines = params.body.split("\n").filter(l => l.length > 0);
36
36
  this.state.scrollOffset = 0;
37
37
  }
38
+ /** Close the panel entirely (set mode to "none"). */
39
+ close() {
40
+ this.state.mode = "none";
41
+ this.state.expanded = false;
42
+ this.state.scrollOffset = 0;
43
+ }
38
44
  collapse() {
39
45
  this.state.expanded = false;
40
46
  this.state.scrollOffset = 0;
@@ -108,6 +108,11 @@ export declare function healthCheckCursorProxy(baseUrl?: string): Promise<boolea
108
108
  * Returns model IDs like ["auto", "composer", "composer-2", "opus-4.6", ...].
109
109
  */
110
110
  export declare function fetchCursorModels(baseUrl?: string): Promise<string[]>;
111
+ /** Options for {@link ensureCursorProxyRunning}. */
112
+ export interface EnsureProxyOptions {
113
+ forceRestart?: boolean;
114
+ projectRoot?: string;
115
+ }
111
116
  /**
112
117
  * Auto-start the cursor-composer-in-claude as a detached background process.
113
118
  *
@@ -123,9 +128,13 @@ export declare function fetchCursorModels(baseUrl?: string): Promise<string[]>;
123
128
  * When `forceRestart` is true, any listener on the port is killed and the
124
129
  * bundled proxy is spawned (same as a version mismatch).
125
130
  *
126
- * Returns true when the proxy is reachable at PROXY_DEFAULT_URL.
131
+ * When `projectRoot` is provided and `baseUrl` is the default, a per-project
132
+ * port is resolved from `.claude-overnight/config.json` so concurrent runs
133
+ * in different repos don't collide on port 8765.
134
+ *
135
+ * Returns true when the proxy is reachable.
127
136
  */
128
- export declare function ensureCursorProxyRunning(baseUrl?: string, forceRestart?: boolean): Promise<boolean>;
137
+ export declare function ensureCursorProxyRunning(baseUrl?: string, opts?: EnsureProxyOptions): Promise<boolean>;
129
138
  /**
130
139
  * Full install + configure flow for cursor-composer-in-claude.
131
140
  * Walks through CLI install, API key config, and proxy start.
package/dist/providers.js CHANGED
@@ -11,6 +11,7 @@ import { getBearerToken, clearTokenCache } from "./auth.js";
11
11
  import { DEFAULT_MODEL } from "./models.js";
12
12
  import { CURSOR_PRIORITY_MODELS, CURSOR_KNOWN_MODELS, KNOWN_CURSOR_MODEL_IDS, cursorModelHint, } from "./cursor-models.js";
13
13
  import { VERSION } from "./_version.js";
14
+ import { getProxyPort, buildProxyUrl } from "./proxy-port.js";
14
15
  /** Cached system Node.js and agent script paths — resolved once, reused across envFor calls. */
15
16
  let _cachedAgentNode = null;
16
17
  let _cachedAgentScript = null;
@@ -812,13 +813,23 @@ async function isPortInUse(port, host = "127.0.0.1") {
812
813
  * When `forceRestart` is true, any listener on the port is killed and the
813
814
  * bundled proxy is spawned (same as a version mismatch).
814
815
  *
815
- * Returns true when the proxy is reachable at PROXY_DEFAULT_URL.
816
+ * When `projectRoot` is provided and `baseUrl` is the default, a per-project
817
+ * port is resolved from `.claude-overnight/config.json` so concurrent runs
818
+ * in different repos don't collide on port 8765.
819
+ *
820
+ * Returns true when the proxy is reachable.
816
821
  */
817
- export async function ensureCursorProxyRunning(baseUrl = PROXY_DEFAULT_URL, forceRestart = false) {
822
+ export async function ensureCursorProxyRunning(baseUrl = PROXY_DEFAULT_URL, opts) {
818
823
  warnMacCursorAgentShellPatchIfNeeded();
819
- const url = new URL(baseUrl);
820
- const port = parseInt(url.port, 10) || 80;
821
- // Stale listener on :8765 may have been started without CURSOR_API_KEY for the agent child.
824
+ // Resolve per-project port if no explicit base URL was given and projectRoot is available
825
+ const resolvedPort = opts?.projectRoot && baseUrl === PROXY_DEFAULT_URL
826
+ ? getProxyPort(opts.projectRoot)
827
+ : null;
828
+ const effectiveBaseUrl = resolvedPort != null ? buildProxyUrl(resolvedPort) : baseUrl;
829
+ const url = new URL(effectiveBaseUrl);
830
+ const port = resolvedPort ?? (parseInt(url.port, 10) || 80);
831
+ const forceRestart = opts?.forceRestart ?? false;
832
+ // Stale listener may have been started without CURSOR_API_KEY for the agent child.
822
833
  // When we have a token, replace the listener by default so the bundled proxy always inherits it.
823
834
  // Opt out: CURSOR_OVERNIGHT_NO_PROXY_RESTART=1 (e.g. shared port / external proxy).
824
835
  const token = resolveCursorAgentToken();
@@ -961,7 +972,7 @@ async function startProxyProcess(baseUrl, url, port) {
961
972
  catch { }
962
973
  const logFd = openSync(logPath, "a");
963
974
  console.log(chalk.dim(` Spawning proxy… ${chalk.dim(`(logs: ${logPath})`)}`));
964
- const child = spawn(process.execPath, [composerCli], {
975
+ const child = spawn(process.execPath, [composerCli, "--port", String(port)], {
965
976
  detached: true,
966
977
  stdio: ["ignore", logFd, logFd],
967
978
  env: proxyEnv,
@@ -1196,7 +1207,7 @@ export async function setupCursorProxy() {
1196
1207
  { key: "c", desc: "ancel" },
1197
1208
  ]);
1198
1209
  if (choice === "r") {
1199
- if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, true)) {
1210
+ if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, { forceRestart: true })) {
1200
1211
  console.log(chalk.green("\n ✓ Proxy is running and healthy"));
1201
1212
  return true;
1202
1213
  }
@@ -1262,7 +1273,7 @@ async function pickCursorModel() {
1262
1273
  { key: "c", desc: "ancel" },
1263
1274
  ]);
1264
1275
  if (choice === "r") {
1265
- if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, true)) {
1276
+ if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, { forceRestart: true })) {
1266
1277
  console.log(chalk.green(" ✓ Proxy started"));
1267
1278
  break;
1268
1279
  }
@@ -0,0 +1,4 @@
1
+ /** Resolve proxy port (reads from config, or allocates and persists a new one). */
2
+ export declare function getProxyPort(projectRoot: string): number;
3
+ /** Build the full proxy URL for a per-project port. */
4
+ export declare function buildProxyUrl(port: number): string;
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ const CONFIG_FILE = "config.json";
4
+ /** Resolve proxy port (reads from config, or allocates and persists a new one). */
5
+ export function getProxyPort(projectRoot) {
6
+ const dir = join(projectRoot, ".claude-overnight");
7
+ const file = join(dir, CONFIG_FILE);
8
+ try {
9
+ const cfg = JSON.parse(readFileSync(file, "utf-8"));
10
+ if (typeof cfg.proxyPort === "number" && cfg.proxyPort >= 1024 && cfg.proxyPort <= 65535) {
11
+ return cfg.proxyPort;
12
+ }
13
+ }
14
+ catch { /* not found or malformed */ }
15
+ const port = 61000 + Math.floor(Math.random() * 4536);
16
+ try {
17
+ if (!existsSync(dir))
18
+ mkdirSync(dir, { recursive: true });
19
+ const existing = existsSync(file) ? JSON.parse(readFileSync(file, "utf-8")) : {};
20
+ writeFileSync(file, JSON.stringify({ ...existing, proxyPort: port }, null, 2));
21
+ }
22
+ catch { /* best effort */ }
23
+ return port;
24
+ }
25
+ /** Build the full proxy URL for a per-project port. */
26
+ export function buildProxyUrl(port) {
27
+ return `http://127.0.0.1:${port}`;
28
+ }
package/dist/render.d.ts CHANGED
@@ -31,6 +31,10 @@ export declare function renderWaitingIndicator(label: string, startedAt: number
31
31
  style?: "info" | "warn" | "wait" | "thinking";
32
32
  }): string;
33
33
  export declare function truncate(s: string, max: number): string;
34
+ /** Word-wrap text into lines of at most `max` chars.
35
+ * Splits on spaces; if a single word exceeds `max` it is hard-broken.
36
+ * Ignores ANSI escape codes for length calculation. */
37
+ export declare function wrap(s: string, max: number): string[];
34
38
  export declare function fmtTokens(n: number): string;
35
39
  export declare function fmtDur(ms: number): string;
36
40
  /** Context-fill percentage and color function for a token count vs safe limit. */
package/dist/render.js CHANGED
@@ -41,6 +41,36 @@ export function renderWaitingIndicator(label, startedAt, opts = {}) {
41
41
  export function truncate(s, max) {
42
42
  return s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
43
43
  }
44
+ /** Word-wrap text into lines of at most `max` chars.
45
+ * Splits on spaces; if a single word exceeds `max` it is hard-broken.
46
+ * Ignores ANSI escape codes for length calculation. */
47
+ export function wrap(s, max) {
48
+ if (s.length <= max)
49
+ return [s];
50
+ // Strip ANSI for length calculation
51
+ const stripped = s.replace(/\x1b\[[0-9;]*m/g, "");
52
+ if (stripped.length <= max)
53
+ return [s];
54
+ const lines = [];
55
+ const words = stripped.split(/\s+/);
56
+ let cur = "";
57
+ for (const w of words) {
58
+ if (cur.length === 0) {
59
+ cur = w;
60
+ continue;
61
+ }
62
+ if (cur.length + 1 + w.length <= max) {
63
+ cur += " " + w;
64
+ }
65
+ else {
66
+ lines.push(cur);
67
+ cur = w;
68
+ }
69
+ }
70
+ if (cur)
71
+ lines.push(cur);
72
+ return lines;
73
+ }
44
74
  export function fmtTokens(n) {
45
75
  if (n >= 1_000_000)
46
76
  return `${(n / 1_000_000).toFixed(1)}M`;
@@ -412,12 +442,6 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId, maxRow
412
442
  // Build footer
413
443
  let hotkeyRow;
414
444
  const extraFooterRows = [];
415
- // Collapsed panel bar shown in footer area
416
- if (panel?.visible && !panel.state.expanded) {
417
- const bar = panel.renderCollapsed(Math.max((process.stdout.columns ?? 80) || 80, 60));
418
- if (bar)
419
- extraFooterRows.push(bar);
420
- }
421
445
  if (showHotkeys) {
422
446
  const pending = runInfo?.pendingSteer ?? 0;
423
447
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
@@ -530,8 +554,12 @@ function renderStatusBlock(out, w, status) {
530
554
  if (lines.length === 0)
531
555
  return;
532
556
  section(out, w, "Status");
533
- for (const ln of lines)
534
- out.push(` ${chalk.dim(truncate(ln.trim(), w - 4))}`);
557
+ const indent = " ";
558
+ const maxW = w - indent.length;
559
+ for (const ln of lines) {
560
+ for (const wl of wrap(ln.trim(), maxW))
561
+ out.push(`${indent}${chalk.dim(wl)}`);
562
+ }
535
563
  }
536
564
  export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter, maxRows, panel) {
537
565
  const totalUsed = runInfo.accCompleted + runInfo.accFailed;
@@ -609,12 +637,6 @@ export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter, maxRow
609
637
  // Footer
610
638
  let hotkeyRow;
611
639
  const extraFooterRows = [];
612
- // Collapsed panel bar shown in footer area
613
- if (panel?.visible && !panel.state.expanded) {
614
- const bar = panel.renderCollapsed(Math.max((process.stdout.columns ?? 80) || 80, 60));
615
- if (bar)
616
- extraFooterRows.push(bar);
617
- }
618
640
  if (showHotkeys) {
619
641
  const pending = runInfo?.pendingSteer ?? 0;
620
642
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
package/dist/steering.js CHANGED
@@ -104,14 +104,14 @@ Respond with ONLY a JSON object (no markdown fences):
104
104
  "estimatedSessionsRemaining": 15,
105
105
  "tasks": [
106
106
  {"prompt": "task instruction...", "model": "worker"},
107
- {"prompt": "review task...", "model": "planner"},
108
- {"prompt": "verify the app end-to-end...", "model": "planner", "noWorktree": true}
107
+ {"prompt": "quick icon fix, verified by worker next wave...", "model": "fast"},
108
+ {"prompt": "verify the app end-to-end...", "model": "worker", "noWorktree": true}
109
109
  ]
110
110
  }
111
111
 
112
112
  "estimatedSessionsRemaining" is REQUIRED. Your best honest estimate of how many MORE agent sessions (beyond the wave you just composed above) are needed to reach 'amazing' -- include follow-up fixes, polish, verification, and anything else you'd want before shipping. Be realistic, not optimistic. Use 0 only if truly done.
113
113
 
114
- The "model" field on each task: use "worker" (${workerModel}) for implementation tasks, "planner" (${plannerModel}) for review/analysis/verification tasks, "fast" (${fastModel ?? workerModel}) for quick, well-scoped changes that will be checked by the worker model in the next wave. Default is "worker".
114
+ The "model" field on each task: use "worker" (${workerModel}) for all tasks. Use "fast" (${fastModel ?? "not set"}) for small, single-file changes that will be checked by the worker in the next wave.
115
115
  Set "noWorktree": true for verify/user-test tasks -- they need the real project directory with env files, dependencies, and local config.
116
116
 
117
117
  If done: {"done": true, "reasoning": "...", "statusUpdate": "...", "estimatedSessionsRemaining": 0, "tasks": []}`;
package/dist/swarm.js CHANGED
@@ -5,7 +5,7 @@ import chalk from "chalk";
5
5
  import { query } from "@anthropic-ai/claude-agent-sdk";
6
6
  import { NudgeError, RATE_LIMIT_WINDOW_SHORT, extractToolTarget, sumUsageTokens } from "./types.js";
7
7
  import { gitExec, autoCommit, mergeAllBranches, warnDirtyTree, cleanStaleWorktrees, writeSwarmLog } from "./merge.js";
8
- import { ensureCursorProxyRunning } from "./providers.js";
8
+ import { ensureCursorProxyRunning, PROXY_DEFAULT_URL } from "./providers.js";
9
9
  import { getModelCapability } from "./models.js";
10
10
  import { createTurn, beginTurn, endTurn, updateTurn } from "./turns.js";
11
11
  const SIMPLIFY_PROMPT = `You just finished your task. Now review and simplify your changes.
@@ -330,7 +330,7 @@ export class Swarm {
330
330
  // attempt to restart it before the next task.
331
331
  if (this.config.cursorProxy) {
332
332
  this.log(-1, " Checking cursor proxy health…");
333
- const restarted = await ensureCursorProxyRunning();
333
+ const restarted = await ensureCursorProxyRunning(PROXY_DEFAULT_URL, { projectRoot: this.config.cwd });
334
334
  if (!restarted) {
335
335
  this.log(-1, chalk.yellow(" ⚠ Proxy still down — remaining tasks may fail"));
336
336
  }
package/dist/ui.js CHANGED
@@ -550,9 +550,14 @@ export class RunDisplay {
550
550
  }
551
551
  return false; // swallow other CSIs silently
552
552
  }
553
- // Bare ESC: collapse
553
+ // Bare ESC: collapse if expanded, close if collapsed
554
554
  if (s === "\x1B") {
555
- this.panel.collapse();
555
+ if (this.panel.state.expanded) {
556
+ this.panel.collapse();
557
+ }
558
+ else {
559
+ this.panel.close();
560
+ }
556
561
  return true;
557
562
  }
558
563
  // Ctrl-O: toggle (collapse)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.27",
3
+ "version": "1.25.30",
4
4
  "description": "Parallel Claude agents in git worktrees with a usage cap that reserves headroom for your interactive Claude Code. Crash-safe resume. Provider-agnostic model catalog (Anthropic, Cursor, OpenAI, Gemini, DeepSeek, Llama, Qwen) with capability-based task scoping.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.27",
3
+ "version": "1.25.30",
4
4
  "description": "Claude Code skill for understanding, installing, and inspecting claude-overnight runs -- parallel Claude agents in git worktrees with thinking waves, multi-wave steering, and crash-safe resume. Supports Cursor API Proxy, Qwen, OpenRouter.",
5
5
  "author": {
6
6
  "name": "Francesco Fornace"