kanbango 3.1.0 → 3.2.0

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/.ai/lessons.jsonl CHANGED
@@ -2,3 +2,4 @@
2
2
  {"id":"260729-1ur","ts":"2026-07-29","scope":"proj","tags":["javascript","objects"],"rule":"DON'T Use empty object fallbacks in spreads like `...(obj || {})` since spreading falsy is safe","when":"object spread","sev":2,"hits":1}
3
3
  {"id":"260729-5eo","ts":"2026-07-29","scope":"proj","tags":["processes","mcp","ownership"],"rule":"DO Track process ownership before stopping or cleaning up discovered services","when":"gui-process management","sev":2,"hits":1}
4
4
  {"id":"260729-1ch","ts":"2026-07-29","scope":"proj","tags":["gui","process","mcp"],"rule":"DO GUI/MCP stop must only SIGTERM processes spawned by the current process; treat port-file PIDs as external_running","when":"gui-process management","sev":2,"hits":1}
5
+ {"id":"260731-o6g","ts":"2026-07-31","scope":"proj","tags":["kanban","epics","mcp"],"rule":"DO Default list/list_epics hide done+archived; hard-delete cascades epic children; archive is reversible flag","when":"kanban cleanup context","sev":2,"hits":1}
@@ -1 +1 @@
1
- {"ts":"2026-07-29T10:38:42.184Z","sessionID":"ses_052c1be3fffeNPoV7VO6z40X2I","added":["260729-1ch"],"bumped":[]}
1
+ {"ts":"2026-07-31T13:58:00.629Z","sessionID":"ses_04792cf77ffec14LhSNoSJReg9","added":["260731-o6g"],"bumped":[]}
package/AGENTS.md CHANGED
@@ -8,11 +8,13 @@ Source of truth: `agent-playbook.js` (also MCP tool descriptions + `kanban_read`
8
8
 
9
9
  - hierarchy: epic (context) → task (work+plan) → subtasks (steps)
10
10
  - list: `col` filter, `view=summary`; keep `task_id`s; no full-board re-list after every write
11
- - list_epics / show_epic for initiatives; filter tasks with `epic=E001`
11
+ - list_epics default = live only (hides done + archived); `include_archived` / `status` to widen
12
+ - list tasks default hides tasks under done/archived epics; show/show_epic by id always works
12
13
  - show: `view=execution` while coding; `full` only if needed
13
14
  - epic_create with description/goals; create tasks with `epic=E001` (prefer id)
14
15
  - create once with `description`, `specs`, `in_scope`, `out_of_scope`, `acceptance_criteria`
15
- - move/update: `return=none`; subtasks = full array replace (no toggle)
16
+ - move/update/delete: `return=none`; subtasks = full array replace (no toggle)
17
+ - cleanup: `delete` (task), `epic_delete` (cascade), `epic_archive` / `epic_unarchive`
16
18
  - non-trivial work: `plan_create` → `plan_advance` → `plan_evidence` (real tests, truncated logs) → `plan_done`
17
19
  - gui: `status` before `start`; `stop` only owned; `external_running` = do not kill
18
20
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to kanbango will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [3.2.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - GUI project label: browser tab title, header, port file, and `kanban_gui` start/status include `project` (basename of cwd; override with `KANBANGO_PROJECT_NAME`)
14
+ - Epic field `archived` + status `archived` (overrides derived child rollup)
15
+ - Hard delete: MCP `delete` / `epic_delete` (cascade child tasks), CLI `kanban rm` / `kanban epic rm`, HTTP `DELETE`
16
+ - Archive: MCP `epic_archive` / `epic_unarchive`, CLI `kanban epic archive|unarchive`, HTTP `POST .../archive|unarchive`
17
+ - `list_epics` default live-only (empty|planned|active); `include_archived`, `include_done`, `status` filters
18
+ - Task `list` hides tasks under done/archived epics unless `include_archived`/`include_done` (explicit `epic=` filter still shows them)
19
+ - GUI: Archive/Unarchive/Delete on epic swimlane, Delete on task card, Show archived toggle
20
+ - Tests: `tests/delete-archive.test.js`
21
+
22
+ ### Changed
23
+ - Agent playbook documents cleanup + live-only epic discovery
24
+ - `show` / `show_epic` by id still return archived items (explicit lookup)
25
+
8
26
  ## [3.1.0] - 2026-07-30
9
27
 
10
28
  ### Added
package/LLM_AGENTS.md CHANGED
@@ -249,7 +249,7 @@ Control the web GUI server: start, stop, or check status.
249
249
  2. Else `KANBANGO_GUI_PORT` env
250
250
  3. Else stable hash of project cwd in range `5510–5999`
251
251
 
252
- If the preferred port is busy, the server picks the next free port. Always trust the returned `url` / `port` (also written to `backlog/.kanbango-gui.json`).
252
+ If the preferred port is busy, the server picks the next free port. Always trust the returned `url` / `port` (also written to `backlog/.kanbango-gui.json`). Responses also include `project` (basename of project cwd, or `KANBANGO_PROJECT_NAME`) so agents and humans can tell boards apart; the GUI tab title and header show the same label.
253
253
 
254
254
  **Auto-start with MCP:** set `KANBANGO_AUTO_GUI=1` in the MCP server env. GUI starts when MCP starts; use `status` to read the URL.
255
255
 
package/README.md CHANGED
@@ -35,10 +35,17 @@ kanban list --json
35
35
  kanban epic add "Phase 1" --description "Why this initiative" --goals "Ship X"
36
36
  kanban add "My task" --col planned --epic E001
37
37
 
38
- # List epics / show epic with child rollup
38
+ # List epics / show epic with child rollup (default: live only)
39
39
  kanban epic list --json
40
+ kanban epic list --all --json
40
41
  kanban epic show E001
41
42
 
43
+ # Archive or delete (epic_delete cascades child tasks)
44
+ kanban epic archive E001
45
+ kanban epic unarchive E001
46
+ kanban epic rm E001
47
+ kanban rm 001
48
+
42
49
  # Show details
43
50
  kanban show 001
44
51
 
@@ -130,7 +137,7 @@ Auto-start the web GUI with MCP (opt-in):
130
137
  }
131
138
  ```
132
139
 
133
- Optional: pin the port with `KANBANGO_GUI_PORT` (e.g. `"5821"`). Without it, each project gets a stable port in `5510–5999` derived from the project path. The real URL is always available via `kanban_gui` → `status` (and written to `backlog/.kanbango-gui.json` while the GUI runs).
140
+ Optional: pin the port with `KANBANGO_GUI_PORT` (e.g. `"5821"`). Without it, each project gets a stable port in `5510–5999` derived from the project path. The real URL is always available via `kanban_gui` → `status` (and written to `backlog/.kanbango-gui.json` while the GUI runs). The browser tab title, GUI header, and `kanban_gui` responses include `project` (folder name of the project cwd; override with `KANBANGO_PROJECT_NAME`).
134
141
 
135
142
  Or generate the config files automatically:
136
143
 
package/agent-playbook.js CHANGED
@@ -8,11 +8,13 @@ const DROP_IN_RULE = [
8
8
  'Kanbango MCP — token rules:',
9
9
  '- hierarchy: epic (context) → task (work+plan) → subtasks (steps)',
10
10
  '- list: col filter, view=summary; keep task_ids; no full-board re-list after writes',
11
- '- list_epics / show_epic for initiatives; list tasks with epic=E001 for work',
11
+ '- list_epics default = live only (hides done + archived); include_archived / status to widen',
12
+ '- list tasks default hides tasks under done/archived epics; show/show_epic by id always works',
12
13
  '- show: view=execution while coding; full only if needed',
13
14
  '- epic_create with description/goals; create tasks with epic=E001 (prefer id)',
14
15
  '- create once with description,specs,in_scope,out_of_scope,acceptance_criteria',
15
- '- move/update: return=none; subtasks=full array replace',
16
+ '- move/update/delete: return=none; subtasks=full array replace',
17
+ '- cleanup: delete (task), epic_delete (cascade tasks), epic_archive / epic_unarchive',
16
18
  '- non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done',
17
19
  '- gui: status before start; stop only owned; external_running = do not kill'
18
20
  ].join('\n');
@@ -22,7 +24,9 @@ const TOOL_DESCRIPTIONS = {
22
24
  'Read board. TOKEN RULES: list defaults to view=summary (id/title/col/progress only).',
23
25
  'Hierarchy: epic (container/context) → task (work) → subtasks (steps).',
24
26
  'Always pass col when possible. Prefer show+view=execution over full.',
25
- 'list_epics/show_epic for initiatives; filter list with epic=E001 or epic title.',
27
+ 'list_epics default live only (empty|planned|active; hides done+archived).',
28
+ 'Pass include_archived=true or status=done|archived to see closed initiatives.',
29
+ 'list hides tasks under done/archived epics unless include_archived/include_done; show/show_epic by id always works.',
26
30
  'Do not re-list the whole board after every write — keep task_id from create/move.',
27
31
  'Task IDs numeric ("014"); epic IDs "E001". views: summary|planning|execution|full; fields[] overrides view.',
28
32
  'operation=help returns this playbook as short text (no board I/O).'
@@ -31,9 +35,11 @@ const TOOL_DESCRIPTIONS = {
31
35
  kanban_manage: [
32
36
  'Write board / plan. TOKEN RULES: one create with all planning fields beats many updates;',
33
37
  'after write use return=none (or summary). Do not dump full task unless needed.',
34
- 'Actions: create|move|update (daily); epic_create|epic_update (containers);',
38
+ 'Actions: create|move|update|delete (daily); epic_create|epic_update|epic_archive|epic_unarchive|epic_delete;',
35
39
  'plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
36
40
  'epic_create: title + description/goals/in_scope/out_of_scope. Link tasks via epic=E001.',
41
+ 'delete: task_id (hard remove). epic_delete: epic_id, always cascades child tasks.',
42
+ 'epic_archive/epic_unarchive: epic_id — hide/restore initiative without deleting history.',
37
43
  'create/plan_create: title required; also send description,specs,in_scope,out_of_scope,acceptance_criteria',
38
44
  '(missing → warnings, not failure). move: task_id+column. update: task_id + fields or subtasks[] full list',
39
45
  '(no toggle). plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
@@ -55,7 +61,9 @@ const MUST_CONTAIN = [
55
61
  'plan_create',
56
62
  'external_running',
57
63
  'subtasks',
58
- 'epic_create'
64
+ 'epic_create',
65
+ 'epic_archive',
66
+ 'epic_delete'
59
67
  ];
60
68
 
61
69
  function playbookHelpPayload() {
package/bin/kanban.js CHANGED
@@ -145,8 +145,9 @@ async function cliMcpInit(options) {
145
145
  }
146
146
  }
147
147
 
148
- async function cliList(colFilter, epicFilter, asJson) {
148
+ async function cliList(colFilter, epicFilter, asJson, listOptions = {}) {
149
149
  await kanban.migrateEpicGroups();
150
+ const epics = await kanban.listEpicEntities();
150
151
  let tasks = await kanban.allTasks();
151
152
 
152
153
  if (colFilter) {
@@ -154,6 +155,8 @@ async function cliList(colFilter, epicFilter, asJson) {
154
155
  }
155
156
  if (epicFilter) {
156
157
  tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, epicFilter));
158
+ } else {
159
+ tasks = kanban.filterTasksForList(tasks, epics, listOptions);
157
160
  }
158
161
 
159
162
  if (asJson) {
@@ -207,11 +210,14 @@ async function cliShow(taskId) {
207
210
  }
208
211
  }
209
212
 
210
- async function cliEpicList(asJson) {
213
+ async function cliEpicList(asJson, listOptions = {}) {
211
214
  await kanban.migrateEpicGroups();
212
215
  const tasks = await kanban.allTasks();
213
216
  const epics = await kanban.listEpicEntities();
214
- const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'summary' }));
217
+ const shaped = kanban.filterShapedEpics(
218
+ epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'summary' })),
219
+ listOptions
220
+ );
215
221
 
216
222
  if (asJson) {
217
223
  console.log(JSON.stringify(shaped, null, 2));
@@ -225,8 +231,9 @@ async function cliEpicList(asJson) {
225
231
 
226
232
  for (const epic of shaped) {
227
233
  const p = epic.progress || {};
234
+ const flag = epic.archived ? ' [archived]' : '';
228
235
  console.log(
229
- ` ${epic.id.padEnd(6)} ${epic.status.padEnd(8)} ${String(epic.title).substring(0, 40).padEnd(40)} ${p.tasks_done || 0}/${p.tasks_total || 0} tasks`
236
+ ` ${epic.id.padEnd(6)} ${epic.status.padEnd(8)} ${String(epic.title).substring(0, 40).padEnd(40)} ${p.tasks_done || 0}/${p.tasks_total || 0} tasks${flag}`
230
237
  );
231
238
  }
232
239
  }
@@ -265,6 +272,38 @@ async function cliEpicUpdate(epicId, patch) {
265
272
  }
266
273
  }
267
274
 
275
+ async function cliEpicArchive(epicId, archived) {
276
+ try {
277
+ const epic = archived
278
+ ? await kanban.archiveEpic(epicId)
279
+ : await kanban.unarchiveEpic(epicId);
280
+ console.log(`✓ Epic ${epic.id} ${archived ? 'archived' : 'unarchived'}`);
281
+ } catch (error) {
282
+ console.error(`✗ ${error.message}`);
283
+ process.exit(1);
284
+ }
285
+ }
286
+
287
+ async function cliEpicDelete(epicId) {
288
+ try {
289
+ const result = await kanban.deleteEpic(epicId);
290
+ console.log(`✓ Deleted epic ${result.epic_id} (+${result.deleted_task_count} tasks)`);
291
+ } catch (error) {
292
+ console.error(`✗ ${error.message}`);
293
+ process.exit(1);
294
+ }
295
+ }
296
+
297
+ async function cliDelete(taskId) {
298
+ try {
299
+ const result = await kanban.deleteTask(taskId);
300
+ console.log(`✓ Deleted task ${shortId(result.task_id)}: ${result.title}`);
301
+ } catch (error) {
302
+ console.error(`✗ ${error.message}`);
303
+ process.exit(1);
304
+ }
305
+ }
306
+
268
307
  async function cliMove(taskId, column) {
269
308
  const success = await kanban.doMove(taskId, column);
270
309
  if (success) {
@@ -286,8 +325,21 @@ async function cliAdd(title, column, epicGroup) {
286
325
  }
287
326
  }
288
327
 
328
+ function injectProjectIntoHtml(html, project) {
329
+ const safe = String(project)
330
+ .replace(/&/g, '&')
331
+ .replace(/</g, '&lt;')
332
+ .replace(/>/g, '&gt;')
333
+ .replace(/"/g, '&quot;');
334
+ return html
335
+ .replace(/<title>[^<]*<\/title>/, `<title>${safe} · Kanban</title>`)
336
+ .replace(/<h1>Donna Kanban<\/h1>/, `<h1>${safe}</h1>`);
337
+ }
338
+
289
339
  async function serveWeb(port) {
290
- const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
340
+ const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
341
+ const project = guiRegistry.projectLabel();
342
+ const html = injectProjectIntoHtml(htmlTemplate, project);
291
343
 
292
344
  const server = http.createServer(async (req, res) => {
293
345
  const url = new URL(req.url, `http://localhost:${port}`);
@@ -302,7 +354,13 @@ async function serveWeb(port) {
302
354
 
303
355
  if (requestPath === '/api/board') {
304
356
  await kanban.migrateEpicGroups();
305
- const tasks = await kanban.allTasks();
357
+ const includeArchived = url.searchParams.get('include_archived') === 'true';
358
+ const epics = await kanban.listEpicEntities();
359
+ // GUI: show done-epic tasks; only hide archived unless toggled
360
+ const tasks = kanban.filterTasksForList(await kanban.allTasks(), epics, {
361
+ live_only: false,
362
+ include_archived: includeArchived
363
+ });
306
364
  sendJson(res, 200, tasks);
307
365
  return;
308
366
  }
@@ -311,7 +369,16 @@ async function serveWeb(port) {
311
369
  await kanban.migrateEpicGroups();
312
370
  const tasks = await kanban.allTasks();
313
371
  const epics = await kanban.listEpicEntities();
314
- sendJson(res, 200, epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' })));
372
+ const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' }));
373
+ const includeArchived = url.searchParams.get('include_archived') === 'true';
374
+ const includeDone = url.searchParams.get('include_done') === 'true';
375
+ const status = url.searchParams.get('status') || undefined;
376
+ // GUI default: all non-archived (live + done). Agents use MCP live-only.
377
+ const liveOnly = url.searchParams.get('live_only') === 'true';
378
+ const filtered = kanban.filterShapedEpics(shaped, liveOnly
379
+ ? { include_archived: includeArchived, include_done: includeDone, status }
380
+ : { live_only: false, include_archived: includeArchived, status });
381
+ sendJson(res, 200, filtered);
315
382
  return;
316
383
  }
317
384
 
@@ -370,6 +437,33 @@ async function serveWeb(port) {
370
437
  return;
371
438
  }
372
439
 
440
+ if (req.method === 'DELETE') {
441
+ const epicDeleteMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
442
+ if (epicDeleteMatch) {
443
+ const result = await kanban.deleteEpic(epicDeleteMatch[1]);
444
+ sendJson(res, 200, result);
445
+ return;
446
+ }
447
+
448
+ const taskDeleteMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
449
+ if (taskDeleteMatch) {
450
+ const result = await kanban.deleteTask(taskDeleteMatch[1]);
451
+ sendJson(res, 200, result);
452
+ return;
453
+ }
454
+ }
455
+
456
+ if (req.method === 'POST') {
457
+ const epicArchiveMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)\/(archive|unarchive)$/);
458
+ if (epicArchiveMatch) {
459
+ const epic = epicArchiveMatch[2] === 'archive'
460
+ ? await kanban.archiveEpic(epicArchiveMatch[1])
461
+ : await kanban.unarchiveEpic(epicArchiveMatch[1]);
462
+ sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
463
+ return;
464
+ }
465
+ }
466
+
373
467
  if (req.method === 'PATCH') {
374
468
  const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
375
469
  if (epicUpdateMatch) {
@@ -504,6 +598,7 @@ async function serveWeb(port) {
504
598
  });
505
599
 
506
600
  console.log(`\x1b[1;32m→ Kanban GUI: ${portInfo.url}\x1b[0m`);
601
+ console.log(` Project: ${portInfo.project || project}`);
507
602
  console.log(` Backlog: ${BACKLOG}`);
508
603
  console.log(' Ctrl+C żeby zamknąć');
509
604
  }
@@ -617,6 +712,7 @@ async function main() {
617
712
  let colFilter = null;
618
713
  let epicFilter = null;
619
714
  let asJson = false;
715
+ let includeArchived = false;
620
716
 
621
717
  for (let i = 1; i < args.length; i++) {
622
718
  if (args[i] === '--col' && args[i + 1]) {
@@ -625,14 +721,31 @@ async function main() {
625
721
  epicFilter = args[++i];
626
722
  } else if (args[i] === '--json') {
627
723
  asJson = true;
724
+ } else if (args[i] === '--include-archived') {
725
+ includeArchived = true;
628
726
  }
629
727
  }
630
728
 
631
- await cliList(colFilter, epicFilter, asJson);
729
+ await cliList(colFilter, epicFilter, asJson, { include_archived: includeArchived });
632
730
  } else if (cmd === 'epic') {
633
731
  const sub = args[1];
634
732
  if (sub === 'list') {
635
- await cliEpicList(args.includes('--json'));
733
+ let includeArchived = false;
734
+ let includeDone = false;
735
+ let status;
736
+ for (let i = 2; i < args.length; i++) {
737
+ if (args[i] === '--include-archived') includeArchived = true;
738
+ else if (args[i] === '--include-done') includeDone = true;
739
+ else if (args[i] === '--all') {
740
+ includeArchived = true;
741
+ includeDone = true;
742
+ } else if (args[i] === '--status' && args[i + 1]) status = args[++i];
743
+ }
744
+ await cliEpicList(args.includes('--json'), {
745
+ include_archived: includeArchived,
746
+ include_done: includeDone,
747
+ status
748
+ });
636
749
  } else if (sub === 'show' && args[2]) {
637
750
  await cliEpicShow(args[2]);
638
751
  } else if (sub === 'add' && args[2]) {
@@ -645,12 +758,20 @@ async function main() {
645
758
  await cliEpicAdd(args[2], { description, goals });
646
759
  } else if (sub === 'update' && args[2] && args[3]) {
647
760
  await cliEpicUpdate(args[2], parseJsonPayload(args[3]));
761
+ } else if (sub === 'archive' && args[2]) {
762
+ await cliEpicArchive(args[2], true);
763
+ } else if (sub === 'unarchive' && args[2]) {
764
+ await cliEpicArchive(args[2], false);
765
+ } else if ((sub === 'rm' || sub === 'delete') && args[2]) {
766
+ await cliEpicDelete(args[2]);
648
767
  } else {
649
- console.error('Usage: kanban epic list|show|add|update ...');
768
+ console.error('Usage: kanban epic list|show|add|update|archive|unarchive|rm ...');
650
769
  process.exit(1);
651
770
  }
652
771
  } else if (cmd === 'show' && args[1]) {
653
772
  await cliShow(args[1]);
773
+ } else if ((cmd === 'rm' || cmd === 'delete') && args[1]) {
774
+ await cliDelete(args[1]);
654
775
  } else if (cmd === 'move' && args[1] && args[2]) {
655
776
  await cliMove(args[1], args[2]);
656
777
  } else if (cmd === 'add' && args[1]) {
package/gui-registry.js CHANGED
@@ -21,6 +21,15 @@ function guiPortFilePath() {
21
21
  return path.join(BACKLOG, GUI_PORT_FILE);
22
22
  }
23
23
 
24
+ function projectLabel(cwd = process.cwd()) {
25
+ const fromEnv = process.env.KANBANGO_PROJECT_NAME;
26
+ if (fromEnv !== undefined && fromEnv !== null && String(fromEnv).trim()) {
27
+ return String(fromEnv).trim();
28
+ }
29
+ const base = path.basename(String(cwd || '').replace(/[/\\]+$/, ''));
30
+ return base || 'kanban';
31
+ }
32
+
24
33
  function hashCwdToPort(cwd = process.cwd()) {
25
34
  let hash = 0;
26
35
  const input = String(cwd);
@@ -79,11 +88,13 @@ async function writeGuiPortFile({ port, pid = process.pid } = {}) {
79
88
  }
80
89
 
81
90
  await ensureBacklogDir();
91
+ const cwd = process.cwd();
82
92
  const data = {
83
93
  port: normalizedPort,
84
94
  pid,
85
95
  url: `http://localhost:${normalizedPort}`,
86
- cwd: process.cwd(),
96
+ cwd,
97
+ project: projectLabel(cwd),
87
98
  started_at: new Date().toISOString()
88
99
  };
89
100
  await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
@@ -129,11 +140,13 @@ async function discoverRunningGui() {
129
140
  pid: info.pid,
130
141
  url: info.url || `http://localhost:${info.port}`,
131
142
  cwd: info.cwd,
143
+ project: info.project || projectLabel(info.cwd),
132
144
  started_at: info.started_at
133
145
  };
134
146
  }
135
147
 
136
148
  module.exports = {
149
+ projectLabel,
137
150
  hashCwdToPort,
138
151
  normalizeGuiPort,
139
152
  resolvePreferredGuiPort,