u-foo 1.7.5 → 1.8.1

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.
Files changed (44) hide show
  1. package/README.md +9 -1
  2. package/README.zh-CN.md +9 -1
  3. package/bin/ufoo.js +4 -2
  4. package/package.json +1 -1
  5. package/src/agent/cliRunner.js +3 -2
  6. package/src/agent/ucodeBootstrap.js +5 -3
  7. package/src/agent/ufooAgent.js +184 -5
  8. package/src/assistant/constants.js +1 -1
  9. package/src/chat/commandExecutor.js +98 -3
  10. package/src/chat/commands.js +7 -0
  11. package/src/chat/completionController.js +40 -0
  12. package/src/chat/daemonMessageRouter.js +21 -1
  13. package/src/chat/dashboardKeyController.js +55 -3
  14. package/src/chat/dashboardView.js +26 -5
  15. package/src/chat/index.js +148 -41
  16. package/src/chat/inputListenerController.js +14 -0
  17. package/src/chat/inputMath.js +1 -1
  18. package/src/chat/inputSubmitHandler.js +9 -5
  19. package/src/chat/transientAgentState.js +64 -0
  20. package/src/cli/groupCoreCommands.js +21 -12
  21. package/src/cli.js +23 -1
  22. package/src/code/tui.js +1 -1
  23. package/src/daemon/cronOps.js +11 -4
  24. package/src/daemon/groupOrchestrator.js +581 -97
  25. package/src/daemon/index.js +418 -3
  26. package/src/daemon/ops.js +25 -7
  27. package/src/daemon/promptLoop.js +16 -0
  28. package/src/daemon/promptRequest.js +126 -2
  29. package/src/daemon/reporting.js +18 -0
  30. package/src/daemon/soloBootstrap.js +435 -0
  31. package/src/daemon/status.js +5 -1
  32. package/src/globalMode.js +33 -0
  33. package/src/group/bootstrap.js +157 -0
  34. package/src/group/promptProfiles.js +646 -0
  35. package/src/group/templateValidation.js +99 -0
  36. package/src/group/validateTemplate.js +36 -5
  37. package/src/init/index.js +13 -7
  38. package/src/report/store.js +6 -0
  39. package/src/shared/eventContract.js +1 -0
  40. package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
  41. package/templates/groups/product-discovery.json +79 -0
  42. package/templates/groups/ui-polish.json +87 -0
  43. package/templates/groups/verify-ship.json +79 -0
  44. package/templates/groups/research-quick.json +0 -49
package/src/chat/index.js CHANGED
@@ -45,11 +45,19 @@ const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
45
45
  const { createDaemonTransport } = require("./daemonTransport");
46
46
  const { listProjectRuntimes, resolveRuntimeDir } = require("../projects/registry");
47
47
  const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
48
+ const { loadTemplateRegistry } = require("../group/templates");
48
49
  const {
49
50
  sortProjectRuntimes,
50
51
  parseTimestampMs,
51
52
  filterVisibleProjectRuntimes,
52
53
  } = require("./projectRuntimes");
54
+ const { isGlobalControllerProjectRoot, resolveGlobalControllerProjectRoot } = require("../globalMode");
55
+ const {
56
+ DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
57
+ setTransientAgentState: setTransientAgentStateValue,
58
+ getTransientAgentState,
59
+ pruneTransientAgentStates,
60
+ } = require("./transientAgentState");
53
61
 
54
62
  const MODE_OPTIONS = ["auto", "host", "terminal", "tmux", "internal"];
55
63
 
@@ -63,10 +71,25 @@ async function runChat(projectRoot, options = {}) {
63
71
  activeProjectRoot = path.resolve(projectRoot || process.cwd());
64
72
  }
65
73
 
66
- if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
74
+ let globalScope = globalMode ? "controller" : "project";
75
+
76
+ const runtimePaths = getUfooPaths(projectRoot);
77
+ const contextIndexFile = path.join(runtimePaths.ufooDir, "context", "decisions.jsonl");
78
+ const needsGlobalControllerBootstrap = globalMode && (
79
+ !fs.existsSync(runtimePaths.ufooDir)
80
+ || !fs.existsSync(runtimePaths.busDir)
81
+ || !fs.existsSync(runtimePaths.agentDir)
82
+ || !fs.existsSync(contextIndexFile)
83
+ );
84
+
85
+ if (needsGlobalControllerBootstrap || !fs.existsSync(runtimePaths.ufooDir)) {
67
86
  const repoRoot = path.join(__dirname, "..", "..");
68
87
  const init = new UfooInit(repoRoot);
69
- await init.init({ modules: "context,bus", project: projectRoot });
88
+ await init.init({
89
+ modules: "context,bus",
90
+ project: projectRoot,
91
+ controllerMode: globalMode,
92
+ });
70
93
  }
71
94
 
72
95
  // Ensure subscriber ID exists for chat (persistent across restarts)
@@ -352,7 +375,7 @@ async function runChat(projectRoot, options = {}) {
352
375
  }
353
376
 
354
377
  function ensureInputCursorVisible() {
355
- const innerWidth = getInnerWidth();
378
+ const innerWidth = getWrapWidth();
356
379
  if (innerWidth <= 0) return;
357
380
  const totalRows = countLines(input.value, innerWidth);
358
381
  const visibleRows = Math.max(1, input.height || 1);
@@ -528,6 +551,14 @@ async function runChat(projectRoot, options = {}) {
528
551
  completionPanel,
529
552
  promptBox,
530
553
  commandRegistry: COMMAND_REGISTRY,
554
+ getGroupTemplateCandidates: () => {
555
+ const registry = loadTemplateRegistry(activeProjectRoot);
556
+ return registry.templates.map((item) => ({
557
+ alias: item.alias,
558
+ name: item.templateName || item.templateId || "",
559
+ source: item.source || "",
560
+ }));
561
+ },
531
562
  getMentionCandidates: () => activeAgents.map((id) => ({
532
563
  id,
533
564
  label: getAgentLabel(id),
@@ -558,6 +589,14 @@ async function runChat(projectRoot, options = {}) {
558
589
  getSelectedAgentIndex: () => selectedAgentIndex,
559
590
  getActiveAgents: () => activeAgents,
560
591
  getTargetAgent: () => targetAgent,
592
+ getGlobalScope: () => globalScope,
593
+ clearTargetAgent,
594
+ exitProjectScope: () => {
595
+ setGlobalScope("controller").catch((err) => {
596
+ const message = err && err.message ? err.message : String(err || "scope switch failed");
597
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(message)}`);
598
+ });
599
+ },
561
600
  requestCloseAgent,
562
601
  logMessage,
563
602
  isSuppressKeypress: () => pasteController.isSuppressKeypress(),
@@ -674,7 +713,6 @@ async function runChat(projectRoot, options = {}) {
674
713
  let targetAgent = null; // Selected agent for direct messaging
675
714
  let focusMode = "input"; // "input" or "dashboard"
676
715
  let dashboardView = "agents"; // "projects" | "agents" | "mode" | "provider" | "cron"
677
- let reportPendingTotal = 0;
678
716
  let selectedModeIndex = Math.max(0, MODE_OPTIONS.indexOf(launchMode));
679
717
  const providerOptions = [
680
718
  { label: "codex", value: "codex-cli" },
@@ -687,15 +725,16 @@ async function runChat(projectRoot, options = {}) {
687
725
  { label: "Start new session", value: false },
688
726
  ];
689
727
  let selectedResumeIndex = autoResume ? 0 : 1;
728
+ let selectedCronIndex = -1;
690
729
  const DASH_HINTS = {
691
730
  agents: "←/→ select · Enter · ↓ mode · ↑ back",
692
731
  agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
693
732
  agentsEmpty: "↓ mode · ↑ back",
694
733
  mode: "←/→ select · Enter · ↓ provider · ↑ back",
695
734
  provider: "←/→ select · Enter · ↓ cron · ↑ back",
696
- cron: "Ctrl+X close · ↑ back",
735
+ cron: "←/→ switch · Ctrl+X stop · ↑ back",
697
736
  resume: "",
698
- projects: "Use /project switch <index|path>",
737
+ projects: "Use /open <path> or /project switch <index|path>",
699
738
  projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
700
739
  projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
701
740
  };
@@ -926,9 +965,13 @@ async function runChat(projectRoot, options = {}) {
926
965
  rows = [];
927
966
  }
928
967
  rows = filterVisibleProjectRuntimes(rows);
968
+ if (globalMode) {
969
+ rows = rows.filter((row) => !isGlobalControllerProjectRoot(resolveRuntimeProjectRoot(row)));
970
+ }
929
971
  const normalizedActive = String(activeProjectRoot || "");
930
972
  if (
931
973
  normalizedActive
974
+ && !(globalMode && isGlobalControllerProjectRoot(normalizedActive))
932
975
  && !rows.some((row) => resolveRuntimeProjectRoot(row) === normalizedActive)
933
976
  ) {
934
977
  rows.unshift({
@@ -989,18 +1032,24 @@ async function runChat(projectRoot, options = {}) {
989
1032
  }
990
1033
 
991
1034
  function updatePromptBox() {
992
- if (targetAgent) {
993
- const label = getAgentLabel(targetAgent);
994
- promptBox.setContent(`>@${label}`);
995
- promptBox.width = label.length + 3; // >@name + spacer
996
- input.left = promptBox.width;
997
- input.width = `100%-${promptBox.width}`;
998
- } else {
999
- promptBox.setContent(">");
1000
- promptBox.width = 2;
1001
- input.left = 2;
1002
- input.width = "100%-2";
1035
+ // Determine scope prefix (only in global mode)
1036
+ let prefix = "";
1037
+ if (globalMode && globalScope === "controller") {
1038
+ prefix = "g";
1039
+ } else if (globalMode && globalScope === "project") {
1040
+ prefix = truncateText(path.basename(activeProjectRoot), 10, "");
1003
1041
  }
1042
+
1043
+ // Build content: [prefix]>[>@agent]
1044
+ const content = targetAgent
1045
+ ? `${prefix}>@${getAgentLabel(targetAgent)}`
1046
+ : `${prefix}>`;
1047
+
1048
+ promptBox.setContent(content);
1049
+ promptBox.width = content.length + 1; // content + spacer
1050
+ input.left = promptBox.width;
1051
+ input.width = `100%-${promptBox.width}`;
1052
+
1004
1053
  if (!input.parent || !promptBox.parent) return;
1005
1054
  resizeInput();
1006
1055
  if (typeof input._updateCursor === "function") {
@@ -1126,6 +1175,7 @@ async function runChat(projectRoot, options = {}) {
1126
1175
  function renderDashboard() {
1127
1176
  const computed = computeDashboardContent({
1128
1177
  globalMode,
1178
+ globalScope,
1129
1179
  focusMode,
1130
1180
  dashboardView,
1131
1181
  activeAgents,
@@ -1147,8 +1197,9 @@ async function runChat(projectRoot, options = {}) {
1147
1197
  : "";
1148
1198
  }
1149
1199
  if (metaState) return metaState;
1150
- const transientState = transientAgentStateMap.get(agentId);
1151
- return typeof transientState === "string" ? transientState : "";
1200
+ return getTransientAgentState(transientAgentStateMap, agentId, {
1201
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1202
+ });
1152
1203
  },
1153
1204
  launchMode,
1154
1205
  agentProvider,
@@ -1156,10 +1207,10 @@ async function runChat(projectRoot, options = {}) {
1156
1207
  selectedModeIndex,
1157
1208
  selectedProviderIndex,
1158
1209
  selectedResumeIndex,
1210
+ selectedCronIndex,
1159
1211
  cronTasks,
1160
1212
  providerOptions,
1161
1213
  resumeOptions,
1162
- pendingReports: reportPendingTotal,
1163
1214
  dashHints: DASH_HINTS,
1164
1215
  modeOptions: MODE_OPTIONS,
1165
1216
  });
@@ -1197,20 +1248,12 @@ async function runChat(projectRoot, options = {}) {
1197
1248
 
1198
1249
  function updateDashboard(status) {
1199
1250
  activeAgents = status.active || [];
1200
- if (transientAgentStateMap.size > 0) {
1201
- const activeSet = new Set(activeAgents);
1202
- for (const id of transientAgentStateMap.keys()) {
1203
- if (!activeSet.has(id)) {
1204
- transientAgentStateMap.delete(id);
1205
- }
1206
- }
1207
- }
1251
+ pruneTransientAgentStates(transientAgentStateMap, activeAgents, {
1252
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1253
+ });
1208
1254
  if (globalMode) {
1209
1255
  refreshProjectRuntimes();
1210
1256
  }
1211
- reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
1212
- ? status.reports.pending_total
1213
- : 0;
1214
1257
  cronTasks = Array.isArray(status?.cron?.tasks) ? status.cron.tasks : [];
1215
1258
  const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1216
1259
  let fallbackMap = null;
@@ -1286,6 +1329,12 @@ async function runChat(projectRoot, options = {}) {
1286
1329
  selectedAgentIndex = 0;
1287
1330
  }
1288
1331
  clampAgentWindow();
1332
+ } else if (dashboardView === "cron") {
1333
+ if (cronTasks.length === 0) {
1334
+ selectedCronIndex = -1;
1335
+ } else if (selectedCronIndex < 0 || selectedCronIndex >= cronTasks.length) {
1336
+ selectedCronIndex = Math.max(0, Math.min(selectedCronIndex, cronTasks.length - 1));
1337
+ }
1289
1338
  }
1290
1339
  }
1291
1340
  syncTargetFromSelection();
@@ -1298,7 +1347,14 @@ async function runChat(projectRoot, options = {}) {
1298
1347
  dashboardView = globalMode ? "projects" : "agents";
1299
1348
  if (globalMode) {
1300
1349
  refreshProjectRuntimes();
1301
- syncSelectedProjectToActive();
1350
+ if (globalScope === "project") {
1351
+ syncSelectedProjectToActive();
1352
+ } else {
1353
+ // Controller scope: no active project in list, init to 0 for navigation
1354
+ if (projectRuntimes.length > 0 && (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length)) {
1355
+ selectedProjectIndex = 0;
1356
+ }
1357
+ }
1302
1358
  } else {
1303
1359
  selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1304
1360
  agentListWindowStart = 0;
@@ -1307,6 +1363,7 @@ async function runChat(projectRoot, options = {}) {
1307
1363
  selectedModeIndex = Math.max(0, MODE_OPTIONS.indexOf(launchMode));
1308
1364
  selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
1309
1365
  selectedResumeIndex = autoResume ? 0 : 1;
1366
+ selectedCronIndex = cronTasks.length > 0 ? 0 : -1;
1310
1367
  // Immediately set @target when first agent is selected.
1311
1368
  if (!globalMode && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1312
1369
  targetAgent = activeAgents[selectedAgentIndex];
@@ -1334,6 +1391,7 @@ async function runChat(projectRoot, options = {}) {
1334
1391
  selectedModeIndex: { get: () => selectedModeIndex, set: (value) => { selectedModeIndex = value; } },
1335
1392
  selectedProviderIndex: { get: () => selectedProviderIndex, set: (value) => { selectedProviderIndex = value; } },
1336
1393
  selectedResumeIndex: { get: () => selectedResumeIndex, set: (value) => { selectedResumeIndex = value; } },
1394
+ selectedCronIndex: { get: () => selectedCronIndex, set: (value) => { selectedCronIndex = value; } },
1337
1395
  launchMode: { get: () => launchMode },
1338
1396
  agentProvider: { get: () => agentProvider },
1339
1397
  autoResume: { get: () => autoResume },
@@ -1382,6 +1440,19 @@ async function runChat(projectRoot, options = {}) {
1382
1440
  clampAgentWindowWithSelection,
1383
1441
  requestProjectSwitch: requestProjectSwitchByIndex,
1384
1442
  requestCloseProject: requestCloseProjectByIndex,
1443
+ requestCron: (payload = {}) => {
1444
+ send({
1445
+ type: IPC_REQUEST_TYPES.CRON,
1446
+ ...payload,
1447
+ });
1448
+ },
1449
+ setGlobalScope: (scope, targetProjectRoot) => {
1450
+ setGlobalScope(scope, targetProjectRoot).catch((err) => {
1451
+ const message = err && err.message ? err.message : String(err || "scope switch failed");
1452
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(message)}`);
1453
+ });
1454
+ },
1455
+ getGlobalScope: () => globalScope,
1385
1456
  renderDashboard,
1386
1457
  renderAgentDashboard,
1387
1458
  renderScreen: () => screen.render(),
@@ -1518,7 +1589,7 @@ async function runChat(projectRoot, options = {}) {
1518
1589
  hasStream: (publisher) => streamTracker.hasStream(publisher),
1519
1590
  setTransientAgentState: (agentId, state) => {
1520
1591
  if (!agentId || !state) return;
1521
- transientAgentStateMap.set(agentId, state);
1592
+ setTransientAgentStateValue(transientAgentStateMap, agentId, state);
1522
1593
  },
1523
1594
  clearTransientAgentState: (agentId) => {
1524
1595
  if (!agentId) return;
@@ -1630,6 +1701,7 @@ async function runChat(projectRoot, options = {}) {
1630
1701
  if (globalMode) {
1631
1702
  refreshProjectRuntimes();
1632
1703
  syncSelectedProjectToActive();
1704
+ updatePromptBox();
1633
1705
  renderDashboard();
1634
1706
  screen.render();
1635
1707
  }
@@ -1645,6 +1717,44 @@ async function runChat(projectRoot, options = {}) {
1645
1717
  }
1646
1718
  }
1647
1719
 
1720
+ async function setGlobalScope(scope, targetProjectRoot) {
1721
+ if (!globalMode) return;
1722
+
1723
+ if (scope === "controller") {
1724
+ if (globalScope === "controller") return;
1725
+ const controllerRoot = resolveGlobalControllerProjectRoot();
1726
+ if (activeProjectRoot !== controllerRoot) {
1727
+ const result = await requestProjectSwitchByTarget(controllerRoot);
1728
+ if (!result || !result.ok) {
1729
+ const reason = (result && result.error) || "switch to controller failed";
1730
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(reason)}`);
1731
+ return;
1732
+ }
1733
+ }
1734
+ globalScope = "controller";
1735
+ targetAgent = null;
1736
+ updatePromptBox();
1737
+ if (projectRuntimes.length > 0 && (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length)) {
1738
+ selectedProjectIndex = 0;
1739
+ }
1740
+ renderDashboard();
1741
+ screen.render();
1742
+ } else if (scope === "project") {
1743
+ if (!targetProjectRoot) return;
1744
+ targetAgent = null;
1745
+ const result = await requestProjectSwitchByTarget(targetProjectRoot);
1746
+ if (!result || !result.ok) {
1747
+ const reason = (result && result.error) || "switch to project failed";
1748
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(reason)}`);
1749
+ return;
1750
+ }
1751
+ globalScope = "project";
1752
+ updatePromptBox();
1753
+ renderDashboard();
1754
+ screen.render();
1755
+ }
1756
+ }
1757
+
1648
1758
  let projectSwitching = false;
1649
1759
  let pendingProjectSwitchRoot = null;
1650
1760
  let projectSwitchDebounceTimer = null;
@@ -1815,9 +1925,12 @@ async function runChat(projectRoot, options = {}) {
1815
1925
  listProjects: () => listProjectRuntimes({ validate: true, cleanupTmp: true }),
1816
1926
  getCurrentProject: () => ({
1817
1927
  project_root: activeProjectRoot,
1818
- project_name: path.basename(activeProjectRoot),
1928
+ project_name: globalMode && isGlobalControllerProjectRoot(activeProjectRoot)
1929
+ ? "global-controller"
1930
+ : path.basename(activeProjectRoot),
1819
1931
  }),
1820
1932
  switchProject: async ({ target } = {}) => requestProjectSwitchByTarget(target),
1933
+ globalMode,
1821
1934
  });
1822
1935
 
1823
1936
  async function executeCommand(text) {
@@ -1933,13 +2046,6 @@ async function runChat(projectRoot, options = {}) {
1933
2046
  focusInput();
1934
2047
  });
1935
2048
 
1936
- // Escape in input mode only clears @target, never exits
1937
- input.key(["escape"], () => {
1938
- if (targetAgent) {
1939
- clearTargetAgent();
1940
- }
1941
- });
1942
-
1943
2049
  focusInput();
1944
2050
  if (screen.program && typeof screen.program.decset === "function") {
1945
2051
  screen.program.decset(2004);
@@ -1957,6 +2063,7 @@ async function runChat(projectRoot, options = {}) {
1957
2063
  if (globalMode) {
1958
2064
  refreshProjectRuntimes();
1959
2065
  }
2066
+ updatePromptBox();
1960
2067
  renderDashboard();
1961
2068
  resizeInput();
1962
2069
  requestStatus();
@@ -7,6 +7,9 @@ function createInputListenerController(options = {}) {
7
7
  getSelectedAgentIndex = () => -1,
8
8
  getActiveAgents = () => [],
9
9
  getTargetAgent = () => null,
10
+ getGlobalScope = () => "",
11
+ clearTargetAgent = () => {},
12
+ exitProjectScope = () => {},
10
13
  requestCloseAgent = () => {},
11
14
  logMessage = () => {},
12
15
  isSuppressKeypress = () => false,
@@ -238,6 +241,17 @@ function createInputListenerController(options = {}) {
238
241
  }
239
242
 
240
243
  if (keyName === "escape") {
244
+ // Layer 1: clear @target agent
245
+ if (getTargetAgent && getTargetAgent()) {
246
+ clearTargetAgent();
247
+ return;
248
+ }
249
+ // Layer 2: exit project scope → global scope
250
+ if (getGlobalScope && getGlobalScope() === "project") {
251
+ exitProjectScope();
252
+ return;
253
+ }
254
+ // Layer 3: existing behavior (cancel input)
241
255
  if (textarea && typeof textarea._done === "function") {
242
256
  textarea._done(null, null);
243
257
  }
@@ -6,7 +6,7 @@ function safeStrWidth(strWidth, value) {
6
6
  function getInnerWidth({ input, screen, promptWidth = 2 }) {
7
7
  const lpos = input.lpos || input._getCoords();
8
8
  if (lpos && Number.isFinite(lpos.xl) && Number.isFinite(lpos.xi)) {
9
- return Math.max(1, lpos.xl - lpos.xi + 1);
9
+ return Math.max(1, lpos.xl - lpos.xi);
10
10
  }
11
11
  if (typeof input.width === "number") return Math.max(1, input.width);
12
12
  if (typeof input.width === "string") {
@@ -154,14 +154,18 @@ function createInputSubmitHandler(options = {}) {
154
154
  const choice = state.pending.disambiguate.candidates[idx - 1];
155
155
  if (choice) {
156
156
  queueStatusLine(`ufoo-agent processing (assigning ${choice.agent_id})`);
157
+ const requestMeta = {
158
+ source: "chat-dialog",
159
+ dispatch_default_injection_mode: "immediate",
160
+ allow_relevance_queue: true,
161
+ };
162
+ if (state.pending.project_root) {
163
+ requestMeta.force_project_root = state.pending.project_root;
164
+ }
157
165
  send({
158
166
  type: IPC_REQUEST_TYPES.PROMPT,
159
167
  text: `Use agent ${choice.agent_id} to handle: ${state.pending.original || "the request"}`,
160
- request_meta: {
161
- source: "chat-dialog",
162
- dispatch_default_injection_mode: "immediate",
163
- allow_relevance_queue: true,
164
- },
168
+ request_meta: requestMeta,
165
169
  });
166
170
  state.pending = null;
167
171
  } else {
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS = 8000;
4
+
5
+ function normalizeNow(now) {
6
+ return Number.isFinite(now) ? now : Date.now();
7
+ }
8
+
9
+ function setTransientAgentState(store, agentId, state, now = Date.now()) {
10
+ if (!(store instanceof Map)) return;
11
+ const id = String(agentId || "").trim();
12
+ const nextState = String(state || "").trim();
13
+ if (!id || !nextState) return;
14
+ store.set(id, {
15
+ state: nextState,
16
+ updatedAt: normalizeNow(now),
17
+ });
18
+ }
19
+
20
+ function getTransientAgentState(store, agentId, options = {}) {
21
+ if (!(store instanceof Map)) return "";
22
+ const id = String(agentId || "").trim();
23
+ if (!id) return "";
24
+ const entry = store.get(id);
25
+ if (!entry) return "";
26
+
27
+ const ttlMs = Number.isFinite(options.ttlMs)
28
+ ? Math.max(0, Math.trunc(options.ttlMs))
29
+ : DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS;
30
+ const now = normalizeNow(options.now);
31
+ const state = typeof entry === "string" ? entry : String(entry.state || "").trim();
32
+ const updatedAt = typeof entry === "object" && Number.isFinite(entry.updatedAt)
33
+ ? entry.updatedAt
34
+ : now;
35
+
36
+ if (!state) {
37
+ store.delete(id);
38
+ return "";
39
+ }
40
+ if (ttlMs > 0 && now - updatedAt > ttlMs) {
41
+ store.delete(id);
42
+ return "";
43
+ }
44
+ return state;
45
+ }
46
+
47
+ function pruneTransientAgentStates(store, activeAgentIds = [], options = {}) {
48
+ if (!(store instanceof Map)) return;
49
+ const activeSet = new Set(Array.isArray(activeAgentIds) ? activeAgentIds : []);
50
+ for (const id of Array.from(store.keys())) {
51
+ if (!activeSet.has(id)) {
52
+ store.delete(id);
53
+ continue;
54
+ }
55
+ getTransientAgentState(store, id, options);
56
+ }
57
+ }
58
+
59
+ module.exports = {
60
+ DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
61
+ setTransientAgentState,
62
+ getTransientAgentState,
63
+ pruneTransientAgentStates,
64
+ };
@@ -6,7 +6,7 @@ const {
6
6
  resolveTemplateReference,
7
7
  createTemplateFromBuiltin,
8
8
  } = require("../group/templates");
9
- const { validateTemplate } = require("../group/validateTemplate");
9
+ const { validateTemplateTarget } = require("../group/templateValidation");
10
10
 
11
11
  function parseTemplateNewArgs(args = []) {
12
12
  const alias = String(args[0] || "").trim();
@@ -97,15 +97,13 @@ function printList({ templates, errors }, { write, json, cwd }) {
97
97
  function formatResolveErrors(errors = []) {
98
98
  if (!Array.isArray(errors) || errors.length === 0) return "";
99
99
  return errors
100
- .map((item) => `${item.filePath}: ${item.error}`)
100
+ .map((item) => `${item.filePath}: ${item.error || item.message || "unknown error"}`)
101
101
  .join("; ");
102
102
  }
103
103
 
104
104
  function throwResolveFailure(target, resolved = {}) {
105
105
  const details = formatResolveErrors(resolved.errors || []);
106
- if (details) {
107
- throw new Error(`Failed to load template "${target}": ${details}`);
108
- }
106
+ if (details) throw new Error(`Failed to load template "${target}": ${details}`);
109
107
  throw new Error(`Template not found: ${target}`);
110
108
  }
111
109
 
@@ -120,6 +118,7 @@ function printValidation(result, target, entry, { write, json }) {
120
118
  filePath: entry.filePath,
121
119
  ok: result.ok,
122
120
  errors: result.errors,
121
+ prompt_profiles: result.promptProfiles || [],
123
122
  },
124
123
  null,
125
124
  2
@@ -130,6 +129,15 @@ function printValidation(result, target, entry, { write, json }) {
130
129
 
131
130
  if (result.ok) {
132
131
  write(`✓ Template "${entry.alias}" is valid (${entry.source})`);
132
+ if (Array.isArray(result.promptProfiles) && result.promptProfiles.length > 0) {
133
+ for (const profile of result.promptProfiles) {
134
+ write(
135
+ ` - ${profile.nickname || profile.agent_id || "agent"}: `
136
+ + `${profile.requested_profile} -> ${profile.resolved_profile} `
137
+ + `[${profile.profile_source}]`
138
+ );
139
+ }
140
+ }
133
141
  return;
134
142
  }
135
143
 
@@ -144,6 +152,7 @@ async function runGroupCoreCommand(subcmd, cmdArgs = [], options = {}) {
144
152
  const write = typeof options.write === "function" ? options.write : console.log;
145
153
  const json = Boolean(options.json);
146
154
  const templatesOptions = options.templatesOptions || {};
155
+ const promptProfilesOptions = options.promptProfilesOptions || {};
147
156
 
148
157
  const args = Array.isArray(cmdArgs) ? cmdArgs.filter((item) => item !== undefined) : [];
149
158
  const normalizedSubcmd = String(subcmd || "").trim().toLowerCase();
@@ -205,18 +214,18 @@ async function runGroupCoreCommand(subcmd, cmdArgs = [], options = {}) {
205
214
  if (action === "validate") {
206
215
  const target = String(args[1] || "").trim();
207
216
  if (!target) throw new Error("group template validate requires <alias|path>");
208
- const resolved = resolveTemplateReference(cwd, target, {
217
+ const result = validateTemplateTarget(cwd, target, {
209
218
  allowPath: true,
210
219
  cwd,
211
- ...templatesOptions,
220
+ templatesOptions,
221
+ promptProfilesOptions,
212
222
  });
213
- if (!resolved.entry) {
214
- throwResolveFailure(target, resolved);
223
+ if (!result.entry) {
224
+ throwResolveFailure(target, { errors: result.errors || [] });
215
225
  }
216
- const result = validateTemplate(resolved.entry.data);
217
- printValidation(result, target, resolved.entry, { write, json });
226
+ printValidation(result, target, result.entry, { write, json });
218
227
  if (!result.ok) {
219
- throw new Error(`Template validation failed: ${resolved.entry.alias}`);
228
+ throw new Error(`Template validation failed: ${result.entry.alias}`);
220
229
  }
221
230
  return;
222
231
  }
package/src/cli.js CHANGED
@@ -411,7 +411,8 @@ async function runCli(argv) {
411
411
  .description("Launch an agent (ucode, uclaude, ucodex)")
412
412
  .argument("<agent>", "Agent type: ucode|uclaude|ucodex|claude|codex")
413
413
  .argument("[nickname]", "Optional nickname for the agent")
414
- .action(async (agent, nickname) => {
414
+ .option("--profile <id>", "Prompt profile to assign after launch")
415
+ .action(async (agent, nickname, opts) => {
415
416
  try {
416
417
  const projectRoot = process.cwd();
417
418
  await ensureDaemonRunning(projectRoot);
@@ -436,6 +437,7 @@ async function runCli(argv) {
436
437
  type: "launch_agent",
437
438
  agent: normalizedAgent,
438
439
  nickname: nickname || "",
440
+ prompt_profile: opts.profile || "",
439
441
  count: 1,
440
442
  ...collectHostLaunchRequestContext(),
441
443
  });
@@ -446,6 +448,26 @@ async function runCli(argv) {
446
448
  process.exitCode = 1;
447
449
  }
448
450
  });
451
+ program
452
+ .command("role")
453
+ .description("Assign a preset role to an existing agent")
454
+ .argument("<target>", "Agent subscriber id or nickname")
455
+ .argument("<profile>", "Prompt profile id or alias")
456
+ .action(async (target, profile) => {
457
+ try {
458
+ const projectRoot = process.cwd();
459
+ await ensureDaemonRunning(projectRoot);
460
+ const resp = await sendDaemonRequest(projectRoot, {
461
+ type: "assign_role",
462
+ target,
463
+ prompt_profile: profile,
464
+ });
465
+ console.log(resp?.data?.reply || `Assigned role ${profile}`);
466
+ } catch (err) {
467
+ console.error(err.message || String(err));
468
+ process.exitCode = 1;
469
+ }
470
+ });
449
471
  program
450
472
  .command("resume")
451
473
  .description("Resume agent sessions (optional nickname)")
package/src/code/tui.js CHANGED
@@ -566,7 +566,7 @@ function runUcodeTui({
566
566
  const getWrapWidth = () => inputMath.getWrapWidth(input, getInnerWidth());
567
567
 
568
568
  const ensureInputCursorVisible = () => {
569
- const innerWidth = getInnerWidth();
569
+ const innerWidth = getWrapWidth();
570
570
  if (innerWidth <= 0) return;
571
571
  const totalRows = inputMath.countLines(input.value || "", innerWidth, (v) => input.strWidth(v));
572
572
  const visibleRows = Math.max(1, input.height || 1);
@@ -255,6 +255,13 @@ function createDaemonCronController(options = {}) {
255
255
  task.timer = null;
256
256
  }
257
257
 
258
+ function detachTimer(timer) {
259
+ if (timer && typeof timer.unref === "function") {
260
+ timer.unref();
261
+ }
262
+ return timer;
263
+ }
264
+
258
265
  function runTask(task) {
259
266
  task.lastRunAt = nowFn();
260
267
  task.tickCount += 1;
@@ -292,17 +299,17 @@ function createDaemonCronController(options = {}) {
292
299
  function attachTaskTimer(task) {
293
300
  if (task.onceAtMs > 0) {
294
301
  const delay = Math.max(0, task.onceAtMs - nowFn());
295
- task.timer = setTimeoutFn(() => {
302
+ task.timer = detachTimer(setTimeoutFn(() => {
296
303
  runTask(task);
297
304
  stopTask(task.id);
298
- }, delay);
305
+ }, delay));
299
306
  return;
300
307
  }
301
308
 
302
- task.timer = setIntervalFn(() => {
309
+ task.timer = detachTimer(setIntervalFn(() => {
303
310
  runTask(task);
304
311
  persistState();
305
- }, task.intervalMs);
312
+ }, task.intervalMs));
306
313
  }
307
314
 
308
315
  function addTask({ intervalMs = 0, onceAtMs = 0, targets = [], prompt = "", title = "" } = {}) {