u-foo 1.4.1 → 1.5.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.
@@ -22,6 +22,13 @@ function defaultCreateSkills(projectRoot) {
22
22
  return new UfooSkills(projectRoot);
23
23
  }
24
24
 
25
+ function defaultResolveTerminalApp() {
26
+ const program = String(process.env.TERM_PROGRAM || "").trim();
27
+ if (program === "Apple_Terminal") return "terminal";
28
+ if (program === "iTerm.app" || process.env.ITERM_SESSION_ID) return "iterm2";
29
+ return "";
30
+ }
31
+
25
32
  async function withCapturedConsole(capture, fn) {
26
33
  const originalLog = console.log;
27
34
  const originalError = console.error;
@@ -72,6 +79,10 @@ function createCommandExecutor(options = {}) {
72
79
  stopCronTask = () => false,
73
80
  runGroupCore = runGroupCoreCommand,
74
81
  requestCron = null,
82
+ listProjects = () => [],
83
+ getCurrentProject = () => ({ projectRoot }),
84
+ switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
85
+ resolveTerminalApp = defaultResolveTerminalApp,
75
86
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
76
87
  schedule = (fn, ms) => setTimeout(fn, ms),
77
88
  } = options;
@@ -355,7 +366,10 @@ function createCommandExecutor(options = {}) {
355
366
 
356
367
  async function handleLaunchCommand(args = []) {
357
368
  if (args.length === 0) {
358
- logMessage("error", "{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [count=<n>]");
369
+ logMessage(
370
+ "error",
371
+ "{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [count=<n>] [scope=inplace|window]"
372
+ );
359
373
  return;
360
374
  }
361
375
 
@@ -375,20 +389,68 @@ function createCommandExecutor(options = {}) {
375
389
  }
376
390
  }
377
391
 
392
+ function normalizeLaunchScopeOption(value, fallback = "inplace") {
393
+ const raw = String(value || "").trim().toLowerCase();
394
+ if (!raw) return fallback;
395
+ if (raw === "inplace" || raw === "same" || raw === "current" || raw === "tab" || raw === "pane") {
396
+ return "inplace";
397
+ }
398
+ if (
399
+ raw === "window"
400
+ || raw === "separate"
401
+ || raw === "new"
402
+ || raw === "new-window"
403
+ || raw === "external"
404
+ || raw === "1"
405
+ || raw === "true"
406
+ || raw === "yes"
407
+ || raw === "y"
408
+ || raw === "on"
409
+ ) {
410
+ return "window";
411
+ }
412
+ if (raw === "0" || raw === "false" || raw === "no" || raw === "n" || raw === "off") {
413
+ return "inplace";
414
+ }
415
+ return "";
416
+ }
417
+
378
418
  const nickname = parsedOptions.nickname || "";
379
419
  const count = parseInt(parsedOptions.count || "1", 10);
420
+ const scopeRaw = parsedOptions.scope || parsedOptions.launch_scope || parsedOptions.window || "";
421
+ let launchScope = normalizeLaunchScopeOption(scopeRaw, "inplace");
422
+ if (scopeRaw && !launchScope) {
423
+ logMessage("error", "{white-fg}✗{/white-fg} scope must be inplace|window");
424
+ return;
425
+ }
426
+ const rawFlags = args
427
+ .slice(1)
428
+ .filter((arg) => !String(arg || "").includes("="))
429
+ .map((arg) => String(arg || "").trim().toLowerCase())
430
+ .filter(Boolean);
431
+ for (const flag of rawFlags) {
432
+ const normalized = normalizeLaunchScopeOption(flag, "");
433
+ if (normalized) launchScope = normalized;
434
+ }
435
+ if (!launchScope) launchScope = "inplace";
380
436
  if (nickname && count > 1) {
381
437
  logMessage("error", "{white-fg}✗{/white-fg} nickname requires count=1");
382
438
  return;
383
439
  }
384
440
 
385
441
  try {
386
- send({
442
+ const request = {
387
443
  type: IPC_REQUEST_TYPES.LAUNCH_AGENT,
388
444
  agent: normalizedAgent,
389
445
  count: Number.isFinite(count) ? count : 1,
390
446
  nickname,
391
- });
447
+ launch_scope: launchScope,
448
+ };
449
+ const terminalApp = String(resolveTerminalApp() || "").trim().toLowerCase();
450
+ if (terminalApp === "terminal" || terminalApp === "iterm2") {
451
+ request.terminal_app = terminalApp;
452
+ }
453
+ send(request);
392
454
  schedule(requestStatus, 1000);
393
455
  } catch (err) {
394
456
  logMessage("error", `{white-fg}✗{/white-fg} Launch failed: ${escapeBlessed(err.message)}`);
@@ -413,6 +475,64 @@ function createCommandExecutor(options = {}) {
413
475
  schedule(requestStatus, 1000);
414
476
  }
415
477
 
478
+ async function handleProjectCommand(args = []) {
479
+ const subcommand = String(args[0] || "list").trim().toLowerCase();
480
+
481
+ if (subcommand === "list") {
482
+ const rowsRaw = await Promise.resolve(listProjects());
483
+ const rows = Array.isArray(rowsRaw) ? rowsRaw : [];
484
+ const current = await Promise.resolve(getCurrentProject());
485
+ const currentRoot = current && current.project_root ? String(current.project_root) : "";
486
+ if (rows.length === 0) {
487
+ logMessage("system", "{white-fg}No projects found{/white-fg}");
488
+ return;
489
+ }
490
+ logMessage("system", `{cyan-fg}Projects:{/cyan-fg} ${rows.length}`);
491
+ rows.forEach((item, idx) => {
492
+ const row = item || {};
493
+ const root = String(row.project_root || "");
494
+ const name = String(row.project_name || root || "-");
495
+ const status = String(row.status || "unknown");
496
+ const marker = root && root === currentRoot ? "*" : " ";
497
+ logMessage(
498
+ "system",
499
+ `${marker}${idx + 1}. {cyan-fg}${escapeBlessed(name)}{/cyan-fg} [{white-fg}${escapeBlessed(status)}{/white-fg}] ${escapeBlessed(root)}`
500
+ );
501
+ });
502
+ return;
503
+ }
504
+
505
+ if (subcommand === "current") {
506
+ const current = await Promise.resolve(getCurrentProject());
507
+ if (!current || !current.project_root) {
508
+ logMessage("error", "{white-fg}✗{/white-fg} Current project unavailable");
509
+ return;
510
+ }
511
+ logMessage("system", `{cyan-fg}Current:{/cyan-fg} ${escapeBlessed(current.project_root)}`);
512
+ return;
513
+ }
514
+
515
+ if (subcommand === "switch") {
516
+ const target = String(args[1] || "").trim();
517
+ if (!target) {
518
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /project switch <index|path>");
519
+ return;
520
+ }
521
+ logMessage("system", `{white-fg}⚙{/white-fg} Switching project: ${escapeBlessed(target)}`);
522
+ const result = await Promise.resolve(switchProject({ target }));
523
+ if (!result || result.ok !== true) {
524
+ const reason = result && result.error ? String(result.error) : "switch failed";
525
+ logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(reason)}`);
526
+ return;
527
+ }
528
+ const nextRoot = result.project_root || result.projectRoot || "";
529
+ logMessage("system", `{white-fg}✓{/white-fg} Switched project: ${escapeBlessed(nextRoot)}`);
530
+ return;
531
+ }
532
+
533
+ logMessage("error", "{white-fg}✗{/white-fg} Unknown project command. Use: list, current, switch");
534
+ }
535
+
416
536
  function parseKeyValueArgs(args = []) {
417
537
  const parsed = {};
418
538
  for (const raw of args) {
@@ -963,6 +1083,9 @@ function createCommandExecutor(options = {}) {
963
1083
  case "resume":
964
1084
  await handleResumeCommand(args);
965
1085
  return true;
1086
+ case "project":
1087
+ await handleProjectCommand(args);
1088
+ return true;
966
1089
  case "cron":
967
1090
  await handleCronCommand(args);
968
1091
  return true;
@@ -992,6 +1115,7 @@ function createCommandExecutor(options = {}) {
992
1115
  handleSkillsCommand,
993
1116
  handleLaunchCommand,
994
1117
  handleResumeCommand,
1118
+ handleProjectCommand,
995
1119
  handleCronCommand,
996
1120
  handleGroupCommand,
997
1121
  handleSettingsCommand,
@@ -55,6 +55,14 @@ const COMMAND_TREE = {
55
55
  ucode: { desc: "Launch ucode core agent" },
56
56
  },
57
57
  },
58
+ "/project": {
59
+ desc: "Project switch operations (spike)",
60
+ children: {
61
+ current: { desc: "Show current chat project" },
62
+ list: { desc: "List running projects from registry" },
63
+ switch: { desc: "Switch daemon connection to project index/path" },
64
+ },
65
+ },
58
66
  "/resume": {
59
67
  desc: "Resume agents (optional nickname) or list recoverable targets",
60
68
  children: {
@@ -2,13 +2,14 @@ const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
2
2
 
3
3
  function createDaemonConnection(options = {}) {
4
4
  const {
5
- connectClient,
5
+ connectClient: connectClientOption,
6
6
  handleMessage,
7
7
  queueStatusLine,
8
8
  resolveStatusLine,
9
9
  logMessage,
10
10
  } = options;
11
11
 
12
+ let connectClient = connectClientOption;
12
13
  let client = null;
13
14
  let reconnectPromise = null;
14
15
  let exitRequested = false;
@@ -120,6 +121,39 @@ function createDaemonConnection(options = {}) {
120
121
  return true;
121
122
  }
122
123
 
124
+ async function switchConnection(next = {}) {
125
+ const nextConnectClient = typeof next.connectClient === "function"
126
+ ? next.connectClient
127
+ : null;
128
+ if (!nextConnectClient) {
129
+ return { ok: false, error: "switchConnection requires connectClient" };
130
+ }
131
+ const previousClient = client;
132
+ try {
133
+ queueStatusLine("Switching daemon connection");
134
+ const nextClient = await nextConnectClient();
135
+ if (!nextClient) {
136
+ return { ok: false, error: "Failed to connect target daemon" };
137
+ }
138
+ connectClient = nextConnectClient;
139
+ attachClient(nextClient);
140
+ if (next.callRequestStatus !== false) {
141
+ requestStatus();
142
+ }
143
+ resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon switched");
144
+ return { ok: true };
145
+ } catch (err) {
146
+ // Keep existing connection alive on switch failures.
147
+ if (previousClient && (!client || client.destroyed)) {
148
+ client = previousClient;
149
+ }
150
+ const message = err && err.message ? err.message : String(err || "switch failed");
151
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed");
152
+ logMessage("error", `{white-fg}✗{/white-fg} ${message}`);
153
+ return { ok: false, error: message };
154
+ }
155
+ }
156
+
123
157
  function send(req) {
124
158
  if (!client || client.destroyed) {
125
159
  enqueueRequest(req);
@@ -155,6 +189,7 @@ function createDaemonConnection(options = {}) {
155
189
  connect,
156
190
  send,
157
191
  requestStatus,
192
+ switchConnection,
158
193
  close,
159
194
  markExit,
160
195
  getState,
@@ -40,6 +40,41 @@ function createDaemonCoordinator(options = {}) {
40
40
  daemonConnection: connection,
41
41
  logMessage,
42
42
  });
43
+ let switchProjectChain = Promise.resolve();
44
+
45
+ function switchProject(target = {}) {
46
+ const runSwitch = async () => {
47
+ if (!daemonTransport || typeof daemonTransport.connectClientForTarget !== "function") {
48
+ return { ok: false, error: "project switching requires daemonTransport.connectClientForTarget" };
49
+ }
50
+ if (!target || !target.projectRoot || !target.sockPath) {
51
+ return { ok: false, error: "switchProject requires projectRoot and sockPath" };
52
+ }
53
+ if (!connection || typeof connection.switchConnection !== "function") {
54
+ return { ok: false, error: "daemon connection does not support switching" };
55
+ }
56
+
57
+ const result = await connection.switchConnection({
58
+ connectClient: () => daemonTransport.connectClientForTarget(target),
59
+ callRequestStatus: false,
60
+ });
61
+ if (!result || result.ok !== true) {
62
+ return {
63
+ ok: false,
64
+ error: (result && result.error) || "switch failed",
65
+ };
66
+ }
67
+ if (typeof daemonTransport.setTarget === "function") {
68
+ daemonTransport.setTarget(target);
69
+ }
70
+ connection.requestStatus();
71
+ return { ok: true, target };
72
+ };
73
+
74
+ const scheduled = switchProjectChain.then(runSwitch, runSwitch);
75
+ switchProjectChain = scheduled.catch(() => {});
76
+ return scheduled;
77
+ }
43
78
 
44
79
  function isConnected() {
45
80
  if (!connection || typeof connection.getState !== "function") return false;
@@ -54,6 +89,7 @@ function createDaemonCoordinator(options = {}) {
54
89
  restart: () => restart(),
55
90
  close: () => connection.close(),
56
91
  markExit: () => connection.markExit(),
92
+ switchProject,
57
93
  isConnected,
58
94
  getState: () => (typeof connection.getState === "function" ? connection.getState() : null),
59
95
  };
@@ -13,21 +13,52 @@ function createDaemonTransport(options = {}) {
13
13
  restartDelayMs = DAEMON_TRANSPORT_DEFAULTS.restartDelayMs,
14
14
  } = options;
15
15
 
16
- async function connectClient() {
17
- let client = await connectWithRetry(sockPath, primaryRetries, retryDelayMs);
16
+ let activeProjectRoot = projectRoot;
17
+ let activeSockPath = sockPath;
18
+
19
+ function resolveTarget(override = {}) {
20
+ return {
21
+ projectRoot: override.projectRoot || activeProjectRoot,
22
+ sockPath: override.sockPath || activeSockPath,
23
+ };
24
+ }
25
+
26
+ async function connectClientForTarget(override = {}) {
27
+ const target = resolveTarget(override);
28
+ let client = await connectWithRetry(target.sockPath, primaryRetries, retryDelayMs);
18
29
  if (!client) {
19
30
  // Retry once with a fresh daemon start and longer wait.
20
- if (!isRunning(projectRoot)) {
21
- startDaemon(projectRoot);
31
+ if (!isRunning(target.projectRoot)) {
32
+ startDaemon(target.projectRoot);
22
33
  await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
23
34
  }
24
- client = await connectWithRetry(sockPath, secondaryRetries, retryDelayMs);
35
+ client = await connectWithRetry(target.sockPath, secondaryRetries, retryDelayMs);
25
36
  }
26
37
  return client;
27
38
  }
28
39
 
40
+ async function connectClient() {
41
+ return connectClientForTarget();
42
+ }
43
+
44
+ function setTarget(next = {}) {
45
+ if (next.projectRoot) activeProjectRoot = next.projectRoot;
46
+ if (next.sockPath) activeSockPath = next.sockPath;
47
+ return getTarget();
48
+ }
49
+
50
+ function getTarget() {
51
+ return {
52
+ projectRoot: activeProjectRoot,
53
+ sockPath: activeSockPath,
54
+ };
55
+ }
56
+
29
57
  return {
30
58
  connectClient,
59
+ connectClientForTarget,
60
+ setTarget,
61
+ getTarget,
31
62
  };
32
63
  }
33
64
 
@@ -3,6 +3,7 @@ const DEFAULT_MODE_OPTIONS = ["terminal", "tmux", "internal"];
3
3
  function createDashboardKeyController(options = {}) {
4
4
  const {
5
5
  state,
6
+ globalMode = false,
6
7
  existsSync = () => false,
7
8
  getInjectSockPath = () => "",
8
9
  getAgentAdapter = () => null,
@@ -21,6 +22,7 @@ function createDashboardKeyController(options = {}) {
21
22
  setAutoResume = () => {},
22
23
  clampAgentWindow = () => {},
23
24
  clampAgentWindowWithSelection = () => {},
25
+ requestProjectSwitch = () => {},
24
26
  renderDashboard = () => {},
25
27
  renderAgentDashboard = () => {},
26
28
  renderScreen = () => {},
@@ -374,6 +376,65 @@ function createDashboardKeyController(options = {}) {
374
376
  return true;
375
377
  }
376
378
 
379
+ function handleProjectsKey(key) {
380
+ const projects = Array.isArray(state.projects) ? state.projects : [];
381
+ if (projects.length === 0) {
382
+ if (key.name === "up" || key.name === "enter" || key.name === "return" || key.name === "escape") {
383
+ exitDashboardMode(false);
384
+ }
385
+ return true;
386
+ }
387
+
388
+ if (key.name === "down") {
389
+ state.dashboardView = "agents";
390
+ if (!Array.isArray(state.activeAgents) || state.activeAgents.length === 0) {
391
+ state.selectedAgentIndex = -1;
392
+ } else if (!Number.isFinite(state.selectedAgentIndex) || state.selectedAgentIndex < 0) {
393
+ state.selectedAgentIndex = 0;
394
+ } else if (state.selectedAgentIndex >= state.activeAgents.length) {
395
+ state.selectedAgentIndex = state.activeAgents.length - 1;
396
+ }
397
+ clampAgentWindow();
398
+ syncTargetFromSelection();
399
+ renderDashboardAndScreen();
400
+ return true;
401
+ }
402
+
403
+ if (key.name === "left") {
404
+ const current = Number.isFinite(state.selectedProjectIndex) ? state.selectedProjectIndex : 0;
405
+ if (current > 0) {
406
+ const next = current - 1;
407
+ state.selectedProjectIndex = next;
408
+ renderDashboardAndScreen();
409
+ requestProjectSwitch(next);
410
+ }
411
+ return true;
412
+ }
413
+
414
+ if (key.name === "right") {
415
+ const current = Number.isFinite(state.selectedProjectIndex) ? state.selectedProjectIndex : 0;
416
+ if (current < projects.length - 1) {
417
+ const next = current + 1;
418
+ state.selectedProjectIndex = next;
419
+ renderDashboardAndScreen();
420
+ requestProjectSwitch(next);
421
+ }
422
+ return true;
423
+ }
424
+
425
+ if (key.name === "up" || key.name === "escape") {
426
+ exitDashboardMode(false);
427
+ return true;
428
+ }
429
+
430
+ if (key.name === "enter" || key.name === "return") {
431
+ exitDashboardMode(false);
432
+ return true;
433
+ }
434
+
435
+ return true;
436
+ }
437
+
377
438
  function handleAgentsKey(key) {
378
439
  if (key.name === "left") {
379
440
  if (state.activeAgents.length > 0 && state.selectedAgentIndex > 0) {
@@ -403,7 +464,18 @@ function createDashboardKeyController(options = {}) {
403
464
  return true;
404
465
  }
405
466
 
406
- if (key.name === "up" || key.name === "escape") {
467
+ if (key.name === "up") {
468
+ clearTargetAgent();
469
+ if (globalMode) {
470
+ state.dashboardView = "projects";
471
+ renderDashboardAndScreen();
472
+ return true;
473
+ }
474
+ exitDashboardMode(false);
475
+ return true;
476
+ }
477
+
478
+ if (key.name === "escape") {
407
479
  clearTargetAgent();
408
480
  exitDashboardMode(false);
409
481
  return true;
@@ -461,6 +533,13 @@ function createDashboardKeyController(options = {}) {
461
533
  return handleAgentDashboardKey(key);
462
534
  }
463
535
 
536
+ if (globalMode && state.dashboardView === "projects") {
537
+ return handleProjectsKey(key);
538
+ }
539
+ if (!globalMode && state.dashboardView === "projects") {
540
+ return true;
541
+ }
542
+
464
543
  if (state.dashboardView === "mode") return handleModeKey(key);
465
544
  if (state.dashboardView === "provider") return handleProviderKey(key);
466
545
  if (state.dashboardView === "assistant") return handleAssistantKey(key);