u-foo 3.0.4 → 3.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "3.0.4",
3
+ "version": "3.0.5",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -204,6 +204,168 @@ function buildCompactSummary(rows = [], focusId = "") {
204
204
  .join(" · ");
205
205
  }
206
206
 
207
+ /**
208
+ * Top-level plan tasks from planGraph JSON (no parent, not generated tools).
209
+ */
210
+ function listTopLevelPlanTasks(planGraph = {}) {
211
+ const nodes = Array.isArray(planGraph.nodes) ? planGraph.nodes : [];
212
+ return nodes.filter((node) => (
213
+ node
214
+ && node.type === "task"
215
+ && !String(node.parentTaskId || "").trim()
216
+ && !node.generated
217
+ ));
218
+ }
219
+
220
+ /**
221
+ * Parse planGraph JSON into a DAG IR: nodes, edges, dependency waves.
222
+ */
223
+ function buildPlanDag(planGraph = {}) {
224
+ const tasks = listTopLevelPlanTasks(planGraph);
225
+ const idSet = new Set(tasks.map((node) => String(node.id || "").trim()).filter(Boolean));
226
+ const nodes = tasks.map((task) => {
227
+ const id = String(task.id || "").trim();
228
+ const deps = (Array.isArray(task.dependsOn) ? task.dependsOn : [])
229
+ .map((dep) => String(dep || "").trim())
230
+ .filter((dep) => dep && idSet.has(dep));
231
+ const { mark, kind } = statusToMark(task.status);
232
+ return {
233
+ id,
234
+ title: nodeTitle(task),
235
+ status: String(task.status || "pending"),
236
+ mark,
237
+ kind,
238
+ dependsOn: deps,
239
+ displayOrder: Number(task.displayOrder) || 0,
240
+ };
241
+ }).filter((node) => node.id);
242
+
243
+ const byId = new Map(nodes.map((node) => [node.id, node]));
244
+ const edges = [];
245
+ for (const node of nodes) {
246
+ for (const dep of node.dependsOn) {
247
+ edges.push({ from: dep, to: node.id });
248
+ }
249
+ }
250
+
251
+ const depthMemo = new Map();
252
+ function depthOf(id, stack = new Set()) {
253
+ if (depthMemo.has(id)) return depthMemo.get(id);
254
+ if (stack.has(id)) return 0;
255
+ stack.add(id);
256
+ const node = byId.get(id);
257
+ let depth = 0;
258
+ if (node) {
259
+ for (const dep of node.dependsOn) {
260
+ depth = Math.max(depth, depthOf(dep, stack) + 1);
261
+ }
262
+ }
263
+ stack.delete(id);
264
+ depthMemo.set(id, depth);
265
+ return depth;
266
+ }
267
+
268
+ for (const node of nodes) depthOf(node.id);
269
+
270
+ const maxDepth = nodes.reduce((max, node) => Math.max(max, depthMemo.get(node.id) || 0), 0);
271
+ const buckets = Array.from({ length: maxDepth + 1 }, () => []);
272
+ const ordered = nodes.slice().sort((a, b) => {
273
+ const depthDiff = (depthMemo.get(a.id) || 0) - (depthMemo.get(b.id) || 0);
274
+ if (depthDiff !== 0) return depthDiff;
275
+ if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder;
276
+ return a.id.localeCompare(b.id);
277
+ });
278
+ for (const node of ordered) {
279
+ buckets[depthMemo.get(node.id) || 0].push(node);
280
+ }
281
+ const waves = buckets.filter((wave) => wave.length > 0);
282
+ return {
283
+ nodes,
284
+ edges,
285
+ waves,
286
+ linear: waves.length > 0 && waves.every((wave) => wave.length === 1),
287
+ };
288
+ }
289
+
290
+ function waveStepLabel(waveIndex = 0, nodeIndex = 0, waveSize = 1) {
291
+ const step = Math.max(1, Math.floor(Number(waveIndex) || 0) + 1);
292
+ if (waveSize <= 1) return String(step);
293
+ const letter = String.fromCharCode(97 + Math.max(0, Math.min(25, Math.floor(Number(nodeIndex) || 0))));
294
+ return `${step}${letter}`;
295
+ }
296
+
297
+ function formatParallelWaveLines(wave = [], waveIndex = 0, titleMax = 40) {
298
+ const lines = [];
299
+ const size = wave.length;
300
+ wave.forEach((node, nodeIndex) => {
301
+ const label = waveStepLabel(waveIndex, nodeIndex, size);
302
+ const body = `${label} ${node.mark} ${truncate(node.title, titleMax)}`;
303
+ if (nodeIndex === 0) {
304
+ lines.push(` ┌─ ${body}`);
305
+ if (size > 1) lines.push("──┤");
306
+ return;
307
+ }
308
+ if (nodeIndex === size - 1) {
309
+ lines.push(` └─ ${body}`);
310
+ return;
311
+ }
312
+ lines.push(` ├─ ${body}`);
313
+ });
314
+ return lines;
315
+ }
316
+
317
+ /**
318
+ * Build markdown (linear list) or ASCII flowchart (parallel waves) from planGraph JSON.
319
+ */
320
+ function buildRoadmapMarkdown(planGraph = {}, {
321
+ cols = 80,
322
+ taskRunLine = "",
323
+ maxRows = 10,
324
+ } = {}) {
325
+ const dag = buildPlanDag(planGraph);
326
+ if (dag.nodes.length === 0) {
327
+ return { markdown: "", lines: [], dag };
328
+ }
329
+
330
+ const titleMax = Math.max(12, Math.min(48, Math.floor(Number(cols) || 80) - 12));
331
+ const objective = truncate(String(planGraph.objective || "").trim(), titleMax);
332
+ const lines = [objective ? `**Plan** · ${objective}` : "**Plan**"];
333
+
334
+ if (dag.linear) {
335
+ dag.waves.forEach((wave, waveIndex) => {
336
+ const node = wave[0];
337
+ lines.push(`${waveIndex + 1}. ${node.mark} ${truncate(node.title, titleMax)}`);
338
+ });
339
+ } else {
340
+ dag.waves.forEach((wave, waveIndex) => {
341
+ if (wave.length === 1) {
342
+ const node = wave[0];
343
+ lines.push(`${waveStepLabel(waveIndex, 0, 1)} ${node.mark} ${truncate(node.title, titleMax)}`);
344
+ return;
345
+ }
346
+ for (const line of formatParallelWaveLines(wave, waveIndex, Math.max(8, titleMax - 4))) {
347
+ lines.push(line);
348
+ }
349
+ });
350
+ }
351
+
352
+ const extra = String(taskRunLine || "").trim();
353
+ if (extra) lines.push(extra);
354
+
355
+ const limit = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : 10;
356
+ let clipped = lines.slice(0, Math.max(1, limit));
357
+ if (lines.length > clipped.length) {
358
+ clipped = clipped.slice(0, Math.max(1, limit - 1));
359
+ clipped.push(`… +${lines.length - clipped.length} more`);
360
+ }
361
+
362
+ return {
363
+ markdown: clipped.join("\n"),
364
+ lines: clipped,
365
+ dag,
366
+ };
367
+ }
368
+
207
369
  function buildDebugLines(executionState = null, planGraph = {}) {
208
370
  const lines = [];
209
371
  const pg = planGraph && typeof planGraph === "object" ? planGraph : {};
@@ -305,61 +467,52 @@ function buildPlanUiProjection(executionState = null, options = {}) {
305
467
  const focusTitle = focus ? truncate(focus.title, narrow ? 18 : 28) : "";
306
468
 
307
469
  let bandLines = [];
470
+ let roadmapMarkdown = "";
308
471
  let visible = false;
472
+ let planDag = null;
473
+
474
+ const taskRunSuffix = (() => {
475
+ if (!taskRun) return "";
476
+ const leaseBit = leaseHeld ? "writing" : taskRun.phase;
477
+ const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
478
+ return `TaskLoop ${leaseBit}${files}`;
479
+ })();
309
480
 
310
481
  if (hasPlan && bandMode !== "hidden") {
311
482
  visible = true;
312
483
  if (bandMode === "debug") {
313
484
  bandLines = buildDebugLines(state, pg);
485
+ roadmapMarkdown = "";
314
486
  } else if (narrow) {
315
487
  const summary = buildCompactSummary(tree, focus && focus.nodeId);
316
488
  bandLines = [truncate(
317
489
  `Plan${focusTitle ? ` · ${focusTitle}` : ""}${progressLabel ? ` (${progressLabel})` : ""}${summary && !focusTitle ? ` ${summary}` : ""}`,
318
490
  Math.max(24, cols - 2)
319
491
  )];
320
- } else if (bandMode === "auto") {
321
- const summary = buildCompactSummary(tree, focus && focus.nodeId);
322
- const header = truncate(
323
- `Plan${pg.objective ? ` · ${pg.objective}` : ""}${summary ? ` ${summary}` : ""}`,
324
- Math.max(24, cols - 2)
325
- );
326
- bandLines = [header];
327
- if (focus) {
328
- const focusChildren = view
329
- .filter((node) => node && node.parentId === focus.nodeId)
330
- .map((node) => {
331
- const { mark } = statusToMark(node.status);
332
- return `${mark} ${nodeTitle(node)}`;
333
- });
334
- if (focusChildren.length > 0) {
335
- bandLines.push(truncate(
336
- ` └ ${focusChildren.join(" · ")}`,
337
- Math.max(24, cols - 2)
338
- ));
339
- } else if (focus.title) {
340
- bandLines.push(truncate(` → ${focus.title}`, Math.max(24, cols - 2)));
341
- }
342
- }
343
- if (taskRun) {
344
- const leaseBit = leaseHeld ? "writing" : taskRun.phase;
345
- const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
346
- bandLines.push(truncate(` TaskLoop ${leaseBit}${files}`, Math.max(24, cols - 2)));
347
- }
348
- const maxRows = Number.isFinite(options.maxBandRows) ? options.maxBandRows : 3;
349
- bandLines = bandLines.slice(0, Math.max(1, maxRows));
492
+ roadmapMarkdown = "";
350
493
  } else {
351
- // expanded
352
- const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
353
- bandLines = [truncate(title, Math.max(24, cols - 2))];
354
- for (const row of tree) {
355
- bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
356
- }
357
- if (taskRun) {
358
- const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
359
- bandLines.push(truncate(`TaskLoop · ${taskRun.phase}${files}`, Math.max(24, cols - 2)));
494
+ // auto + expanded: JSON → DAG → roadmap markdown
495
+ const maxRows = Number.isFinite(options.maxBandRows)
496
+ ? options.maxBandRows
497
+ : (bandMode === "expanded" ? 16 : 10);
498
+ const roadmap = buildRoadmapMarkdown(pg, {
499
+ cols,
500
+ taskRunLine: taskRunSuffix,
501
+ maxRows,
502
+ });
503
+ planDag = roadmap.dag;
504
+ roadmapMarkdown = roadmap.markdown;
505
+ bandLines = roadmap.lines.slice();
506
+ if (bandMode === "expanded" && tree.length > 0) {
507
+ // Keep tree as fallback detail only when roadmap empty (shouldn't happen).
508
+ if (bandLines.length === 0) {
509
+ const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
510
+ bandLines = [truncate(title, Math.max(24, cols - 2))];
511
+ for (const row of tree) {
512
+ bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
513
+ }
514
+ }
360
515
  }
361
- const maxRows = Number.isFinite(options.maxBandRows) ? options.maxBandRows : 7;
362
- bandLines = bandLines.slice(0, Math.max(1, maxRows));
363
516
  }
364
517
  }
365
518
 
@@ -402,7 +555,7 @@ function buildPlanUiProjection(executionState = null, options = {}) {
402
555
  leaseHeld,
403
556
  progressDone: progress.done,
404
557
  progressTotal: progress.total,
405
- bandLines,
558
+ bandLines: roadmapMarkdown ? [roadmapMarkdown] : bandLines,
406
559
  });
407
560
 
408
561
  return {
@@ -413,11 +566,13 @@ function buildPlanUiProjection(executionState = null, options = {}) {
413
566
  progress,
414
567
  focus,
415
568
  tree,
569
+ dag: planDag,
416
570
  taskRun,
417
571
  leaseHeld,
418
572
  statusLine,
419
573
  idleHint,
420
574
  activityStatusLine,
575
+ roadmapMarkdown,
421
576
  bandLines,
422
577
  hash,
423
578
  };
@@ -428,5 +583,7 @@ module.exports = {
428
583
  getBandMode,
429
584
  setBandMode,
430
585
  statusToMark,
586
+ buildPlanDag,
587
+ buildRoadmapMarkdown,
431
588
  buildPlanUiProjection,
432
589
  };
@@ -30,6 +30,12 @@ const {
30
30
  shouldAutoContinuePlan,
31
31
  buildPlanAutoContinueReminder,
32
32
  } = require("./context/userNudge");
33
+ const {
34
+ drainAgentMailboxForTurn,
35
+ shouldAutoContinueForTaskWake,
36
+ buildTaskRunWakeReminder,
37
+ listTaskRunsAwaitingModel,
38
+ } = require("./runtime/agentWakeup");
33
39
  const {
34
40
  runAskUserTool,
35
41
  syncInteractionFromPlanGraph,
@@ -1782,33 +1788,74 @@ async function runNativeLoop({
1782
1788
  messages.push({ role: "user", content });
1783
1789
  }
1784
1790
 
1785
- function tryInjectPlanAutoContinue() {
1786
- if (!shouldAutoContinuePlan(executionState)) return false;
1787
- if (planAutoContinues >= DEFAULT_MAX_PLAN_AUTO_CONTINUES) return false;
1791
+ /** Deliver mid-loop TaskRun runtime events before the next model call. */
1792
+ function injectRuntimeMailboxEvents() {
1793
+ const drained = drainAgentMailboxForTurn(executionState);
1794
+ if (!drained.text) return false;
1795
+ messages.push({ role: "user", content: drained.text });
1796
+ return true;
1797
+ }
1798
+
1799
+ function nextAutoContinueKey() {
1788
1800
  const waitingId = String(
1789
1801
  (executionState.planGraph && executionState.planGraph.waitingFor
1790
1802
  && executionState.planGraph.waitingFor.id) || ""
1791
1803
  ).trim();
1804
+ if (waitingId) return `plan:${waitingId}`;
1805
+ const runs = listTaskRunsAwaitingModel(executionState);
1806
+ if (runs.length > 0) {
1807
+ return `task:${runs.map((run) => run.id).sort().join(",")}`;
1808
+ }
1809
+ return "mailbox";
1810
+ }
1811
+
1812
+ function tryInjectAgentAutoContinue() {
1813
+ if (planAutoContinues >= DEFAULT_MAX_PLAN_AUTO_CONTINUES) return false;
1814
+ const continueKey = nextAutoContinueKey();
1792
1815
  if (
1793
1816
  consecutiveEmptyAutoContinues >= 2
1794
- && waitingId
1795
- && waitingId === lastAutoContinueWaitingId
1817
+ && continueKey
1818
+ && continueKey === lastAutoContinueWaitingId
1796
1819
  ) {
1797
1820
  return false;
1798
1821
  }
1799
- const reminder = buildPlanAutoContinueReminder(executionState);
1800
- if (!reminder) return false;
1801
- messages.push({ role: "user", content: reminder });
1802
- planAutoContinues += 1;
1803
- lastAutoContinueWaitingId = waitingId;
1804
- consecutiveEmptyAutoContinues += 1;
1805
- return true;
1822
+
1823
+ // Prefer draining fresh runtime mail (task_started, etc.) before nudges.
1824
+ if (injectRuntimeMailboxEvents()) {
1825
+ planAutoContinues += 1;
1826
+ lastAutoContinueWaitingId = continueKey;
1827
+ consecutiveEmptyAutoContinues += 1;
1828
+ return true;
1829
+ }
1830
+
1831
+ if (shouldAutoContinuePlan(executionState)) {
1832
+ const reminder = buildPlanAutoContinueReminder(executionState);
1833
+ if (!reminder) return false;
1834
+ messages.push({ role: "user", content: reminder });
1835
+ planAutoContinues += 1;
1836
+ lastAutoContinueWaitingId = continueKey;
1837
+ consecutiveEmptyAutoContinues += 1;
1838
+ return true;
1839
+ }
1840
+
1841
+ if (shouldAutoContinueForTaskWake(executionState)) {
1842
+ const reminder = buildTaskRunWakeReminder(executionState);
1843
+ if (!reminder) return false;
1844
+ messages.push({ role: "user", content: reminder });
1845
+ planAutoContinues += 1;
1846
+ lastAutoContinueWaitingId = continueKey;
1847
+ consecutiveEmptyAutoContinues += 1;
1848
+ return true;
1849
+ }
1850
+
1851
+ return false;
1806
1852
  }
1807
1853
 
1808
1854
  while (true) {
1809
1855
  guards.ensureActive();
1810
1856
 
1811
1857
  injectPendingUserReminders();
1858
+ injectRuntimeMailboxEvents();
1812
1859
 
1813
1860
  if (activeLedger) {
1814
1861
  runProviderTurnGate(activeLedger);
@@ -1876,7 +1923,7 @@ async function runNativeLoop({
1876
1923
  if (!aggregated.trim() && text) {
1877
1924
  aggregated = text;
1878
1925
  }
1879
- if (tryInjectPlanAutoContinue()) {
1926
+ if (tryInjectAgentAutoContinue()) {
1880
1927
  continue;
1881
1928
  }
1882
1929
  return {
@@ -2,9 +2,12 @@
2
2
 
3
3
  /**
4
4
  * Drain Agent Loop mailbox into a turnDynamic block (never as user role).
5
+ * Mid-loop wakeups (nativeRunner) also consume the same mailbox into the
6
+ * conversation so TaskRun waiting_model does not strand the Agent Loop.
5
7
  */
6
8
 
7
- const { drainAgentMailbox, ensureMailbox } = require("./loopMailbox");
9
+ const { drainAgentMailbox, ensureMailbox, peek } = require("./loopMailbox");
10
+ const { ensureTaskRunStore } = require("./taskRun");
8
11
 
9
12
  function formatAgentRuntimeEvents(events = []) {
10
13
  const list = Array.isArray(events) ? events : [];
@@ -43,6 +46,11 @@ function peekAgentMailboxText(executionState = null) {
43
46
  return formatAgentRuntimeEvents(box.queue || []);
44
47
  }
45
48
 
49
+ function hasPendingAgentMailbox(executionState = null) {
50
+ const state = executionState && typeof executionState === "object" ? executionState : {};
51
+ return Boolean(peek(ensureMailbox(state, "agentMailbox")));
52
+ }
53
+
46
54
  function drainAgentMailboxForTurn(executionState = null) {
47
55
  const events = drainAgentMailbox(executionState);
48
56
  return {
@@ -51,8 +59,63 @@ function drainAgentMailboxForTurn(executionState = null) {
51
59
  };
52
60
  }
53
61
 
62
+ const AWAITING_MODEL_PHASES = new Set([
63
+ "waiting_model",
64
+ "planning",
65
+ "initializing",
66
+ ]);
67
+
68
+ /**
69
+ * TaskRuns that still need the Agent Loop (model turn / planning).
70
+ */
71
+ function listTaskRunsAwaitingModel(executionState = null) {
72
+ const store = ensureTaskRunStore(executionState);
73
+ return Object.values(store.byId || {}).filter((run) => (
74
+ run
75
+ && (run.status === "running" || run.status === "queued")
76
+ && AWAITING_MODEL_PHASES.has(String(run.phase || "").trim().toLowerCase())
77
+ ));
78
+ }
79
+
80
+ function shouldWakeAgentForTaskRuns(executionState = null) {
81
+ return listTaskRunsAwaitingModel(executionState).length > 0;
82
+ }
83
+
84
+ /**
85
+ * Whether the Agent Loop must keep going after a text-only model turn
86
+ * because TaskRuns or unread runtime mail still need service.
87
+ */
88
+ function shouldAutoContinueForTaskWake(executionState = null) {
89
+ if (!executionState || typeof executionState !== "object") return false;
90
+ if (executionState.pendingUserInteraction) return false;
91
+ return hasPendingAgentMailbox(executionState) || shouldWakeAgentForTaskRuns(executionState);
92
+ }
93
+
94
+ function buildTaskRunWakeReminder(executionState = null) {
95
+ const runs = listTaskRunsAwaitingModel(executionState);
96
+ if (runs.length === 0 && !hasPendingAgentMailbox(executionState)) return "";
97
+ const lines = [
98
+ "Runtime wake (not a user message): active TaskRun(s) still need the Agent Loop.",
99
+ "Continue serving them now (read/inspect/edit via tools, or plan_graph/task_run control).",
100
+ "Do not end the turn with text only while a TaskRun is waiting_model.",
101
+ ];
102
+ for (const run of runs.slice(0, 4)) {
103
+ const label = run.title || run.objective || run.parentNodeId || run.id;
104
+ lines.push(
105
+ `- taskRunId=${run.id} phase=${run.phase || ""} status=${run.status || ""}`
106
+ + (label ? ` — ${String(label).slice(0, 160)}` : "")
107
+ );
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
54
112
  module.exports = {
55
113
  formatAgentRuntimeEvents,
56
114
  peekAgentMailboxText,
115
+ hasPendingAgentMailbox,
57
116
  drainAgentMailboxForTurn,
117
+ listTaskRunsAwaitingModel,
118
+ shouldWakeAgentForTaskRuns,
119
+ shouldAutoContinueForTaskWake,
120
+ buildTaskRunWakeReminder,
58
121
  };
@@ -90,6 +90,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
90
90
  hasPlan: false,
91
91
  visible: false,
92
92
  bandLines: [],
93
+ roadmapMarkdown: "",
93
94
  idleHint: "",
94
95
  statusLine: "",
95
96
  hash: "",
@@ -1248,18 +1249,30 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1248
1249
  renderMergeText(activeMerge)
1249
1250
  ),
1250
1251
  ) : null,
1251
- planUi.visible && planUi.bandLines.length > 0
1252
+ planUi.visible && (planUi.roadmapMarkdown || (planUi.bandLines && planUi.bandLines.length > 0))
1252
1253
  ? h(Box, {
1253
1254
  flexDirection: "column",
1254
1255
  width: "100%",
1255
1256
  marginTop: 1,
1256
1257
  },
1257
- ...planUi.bandLines.map((line, idx) => h(Text, {
1258
- key: `plan-band-${idx}`,
1259
- color: "magenta",
1260
- dimColor: idx > 0,
1261
- wrap: "truncate",
1262
- }, line || " ")),
1258
+ ...(() => {
1259
+ let lines = Array.isArray(planUi.bandLines) ? planUi.bandLines.slice() : [];
1260
+ const md = String(planUi.roadmapMarkdown || "").trim();
1261
+ if (md) {
1262
+ try {
1263
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(md, { inCodeBlock: false });
1264
+ if (Array.isArray(rendered) && rendered.length > 0) lines = rendered;
1265
+ } catch {
1266
+ lines = md.split(/\r?\n/);
1267
+ }
1268
+ }
1269
+ return lines.map((line, idx) => h(Text, {
1270
+ key: `plan-band-${idx}`,
1271
+ color: md ? undefined : "magenta",
1272
+ dimColor: !md && idx > 0,
1273
+ wrap: "truncate",
1274
+ }, line || " "));
1275
+ })(),
1263
1276
  )
1264
1277
  : null,
1265
1278
  interactionLines.length > 0