u-foo 1.7.5 → 1.8.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.
Files changed (41) 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 +31 -5
  15. package/src/chat/index.js +152 -36
  16. package/src/chat/inputListenerController.js +14 -0
  17. package/src/chat/inputSubmitHandler.js +9 -5
  18. package/src/chat/transientAgentState.js +64 -0
  19. package/src/cli/groupCoreCommands.js +21 -12
  20. package/src/cli.js +23 -1
  21. package/src/daemon/groupOrchestrator.js +581 -97
  22. package/src/daemon/index.js +418 -3
  23. package/src/daemon/ops.js +25 -7
  24. package/src/daemon/promptLoop.js +16 -0
  25. package/src/daemon/promptRequest.js +126 -2
  26. package/src/daemon/reporting.js +18 -0
  27. package/src/daemon/soloBootstrap.js +435 -0
  28. package/src/daemon/status.js +5 -1
  29. package/src/globalMode.js +33 -0
  30. package/src/group/bootstrap.js +157 -0
  31. package/src/group/promptProfiles.js +646 -0
  32. package/src/group/templateValidation.js +99 -0
  33. package/src/group/validateTemplate.js +36 -5
  34. package/src/init/index.js +13 -7
  35. package/src/report/store.js +6 -0
  36. package/src/shared/eventContract.js +1 -0
  37. package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
  38. package/templates/groups/product-discovery.json +79 -0
  39. package/templates/groups/ui-polish.json +87 -0
  40. package/templates/groups/verify-ship.json +79 -0
  41. 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)
@@ -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(),
@@ -687,15 +726,16 @@ async function runChat(projectRoot, options = {}) {
687
726
  { label: "Start new session", value: false },
688
727
  ];
689
728
  let selectedResumeIndex = autoResume ? 0 : 1;
729
+ let selectedCronIndex = -1;
690
730
  const DASH_HINTS = {
691
731
  agents: "←/→ select · Enter · ↓ mode · ↑ back",
692
732
  agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
693
733
  agentsEmpty: "↓ mode · ↑ back",
694
734
  mode: "←/→ select · Enter · ↓ provider · ↑ back",
695
735
  provider: "←/→ select · Enter · ↓ cron · ↑ back",
696
- cron: "Ctrl+X close · ↑ back",
736
+ cron: "←/→ switch · Ctrl+X stop · ↑ back",
697
737
  resume: "",
698
- projects: "Use /project switch <index|path>",
738
+ projects: "Use /open <path> or /project switch <index|path>",
699
739
  projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
700
740
  projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
701
741
  };
@@ -926,9 +966,13 @@ async function runChat(projectRoot, options = {}) {
926
966
  rows = [];
927
967
  }
928
968
  rows = filterVisibleProjectRuntimes(rows);
969
+ if (globalMode) {
970
+ rows = rows.filter((row) => !isGlobalControllerProjectRoot(resolveRuntimeProjectRoot(row)));
971
+ }
929
972
  const normalizedActive = String(activeProjectRoot || "");
930
973
  if (
931
974
  normalizedActive
975
+ && !(globalMode && isGlobalControllerProjectRoot(normalizedActive))
932
976
  && !rows.some((row) => resolveRuntimeProjectRoot(row) === normalizedActive)
933
977
  ) {
934
978
  rows.unshift({
@@ -989,18 +1033,24 @@ async function runChat(projectRoot, options = {}) {
989
1033
  }
990
1034
 
991
1035
  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";
1036
+ // Determine scope prefix (only in global mode)
1037
+ let prefix = "";
1038
+ if (globalMode && globalScope === "controller") {
1039
+ prefix = "g";
1040
+ } else if (globalMode && globalScope === "project") {
1041
+ prefix = truncateText(path.basename(activeProjectRoot), 10, "");
1003
1042
  }
1043
+
1044
+ // Build content: [prefix]>[>@agent]
1045
+ const content = targetAgent
1046
+ ? `${prefix}>@${getAgentLabel(targetAgent)}`
1047
+ : `${prefix}>`;
1048
+
1049
+ promptBox.setContent(content);
1050
+ promptBox.width = content.length + 1; // content + spacer
1051
+ input.left = promptBox.width;
1052
+ input.width = `100%-${promptBox.width}`;
1053
+
1004
1054
  if (!input.parent || !promptBox.parent) return;
1005
1055
  resizeInput();
1006
1056
  if (typeof input._updateCursor === "function") {
@@ -1126,6 +1176,7 @@ async function runChat(projectRoot, options = {}) {
1126
1176
  function renderDashboard() {
1127
1177
  const computed = computeDashboardContent({
1128
1178
  globalMode,
1179
+ globalScope,
1129
1180
  focusMode,
1130
1181
  dashboardView,
1131
1182
  activeAgents,
@@ -1147,8 +1198,9 @@ async function runChat(projectRoot, options = {}) {
1147
1198
  : "";
1148
1199
  }
1149
1200
  if (metaState) return metaState;
1150
- const transientState = transientAgentStateMap.get(agentId);
1151
- return typeof transientState === "string" ? transientState : "";
1201
+ return getTransientAgentState(transientAgentStateMap, agentId, {
1202
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1203
+ });
1152
1204
  },
1153
1205
  launchMode,
1154
1206
  agentProvider,
@@ -1156,6 +1208,7 @@ async function runChat(projectRoot, options = {}) {
1156
1208
  selectedModeIndex,
1157
1209
  selectedProviderIndex,
1158
1210
  selectedResumeIndex,
1211
+ selectedCronIndex,
1159
1212
  cronTasks,
1160
1213
  providerOptions,
1161
1214
  resumeOptions,
@@ -1197,20 +1250,19 @@ async function runChat(projectRoot, options = {}) {
1197
1250
 
1198
1251
  function updateDashboard(status) {
1199
1252
  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
- }
1253
+ pruneTransientAgentStates(transientAgentStateMap, activeAgents, {
1254
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1255
+ });
1208
1256
  if (globalMode) {
1209
1257
  refreshProjectRuntimes();
1210
1258
  }
1211
- reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
1259
+ const publicPending = Number.isFinite(status?.reports?.pending_total)
1212
1260
  ? status.reports.pending_total
1213
1261
  : 0;
1262
+ const controllerPending = Number.isFinite(status?.controller?.pending_total)
1263
+ ? status.controller.pending_total
1264
+ : 0;
1265
+ reportPendingTotal = publicPending + controllerPending;
1214
1266
  cronTasks = Array.isArray(status?.cron?.tasks) ? status.cron.tasks : [];
1215
1267
  const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1216
1268
  let fallbackMap = null;
@@ -1286,6 +1338,12 @@ async function runChat(projectRoot, options = {}) {
1286
1338
  selectedAgentIndex = 0;
1287
1339
  }
1288
1340
  clampAgentWindow();
1341
+ } else if (dashboardView === "cron") {
1342
+ if (cronTasks.length === 0) {
1343
+ selectedCronIndex = -1;
1344
+ } else if (selectedCronIndex < 0 || selectedCronIndex >= cronTasks.length) {
1345
+ selectedCronIndex = Math.max(0, Math.min(selectedCronIndex, cronTasks.length - 1));
1346
+ }
1289
1347
  }
1290
1348
  }
1291
1349
  syncTargetFromSelection();
@@ -1298,7 +1356,14 @@ async function runChat(projectRoot, options = {}) {
1298
1356
  dashboardView = globalMode ? "projects" : "agents";
1299
1357
  if (globalMode) {
1300
1358
  refreshProjectRuntimes();
1301
- syncSelectedProjectToActive();
1359
+ if (globalScope === "project") {
1360
+ syncSelectedProjectToActive();
1361
+ } else {
1362
+ // Controller scope: no active project in list, init to 0 for navigation
1363
+ if (projectRuntimes.length > 0 && (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length)) {
1364
+ selectedProjectIndex = 0;
1365
+ }
1366
+ }
1302
1367
  } else {
1303
1368
  selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1304
1369
  agentListWindowStart = 0;
@@ -1307,6 +1372,7 @@ async function runChat(projectRoot, options = {}) {
1307
1372
  selectedModeIndex = Math.max(0, MODE_OPTIONS.indexOf(launchMode));
1308
1373
  selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
1309
1374
  selectedResumeIndex = autoResume ? 0 : 1;
1375
+ selectedCronIndex = cronTasks.length > 0 ? 0 : -1;
1310
1376
  // Immediately set @target when first agent is selected.
1311
1377
  if (!globalMode && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1312
1378
  targetAgent = activeAgents[selectedAgentIndex];
@@ -1334,6 +1400,7 @@ async function runChat(projectRoot, options = {}) {
1334
1400
  selectedModeIndex: { get: () => selectedModeIndex, set: (value) => { selectedModeIndex = value; } },
1335
1401
  selectedProviderIndex: { get: () => selectedProviderIndex, set: (value) => { selectedProviderIndex = value; } },
1336
1402
  selectedResumeIndex: { get: () => selectedResumeIndex, set: (value) => { selectedResumeIndex = value; } },
1403
+ selectedCronIndex: { get: () => selectedCronIndex, set: (value) => { selectedCronIndex = value; } },
1337
1404
  launchMode: { get: () => launchMode },
1338
1405
  agentProvider: { get: () => agentProvider },
1339
1406
  autoResume: { get: () => autoResume },
@@ -1382,6 +1449,19 @@ async function runChat(projectRoot, options = {}) {
1382
1449
  clampAgentWindowWithSelection,
1383
1450
  requestProjectSwitch: requestProjectSwitchByIndex,
1384
1451
  requestCloseProject: requestCloseProjectByIndex,
1452
+ requestCron: (payload = {}) => {
1453
+ send({
1454
+ type: IPC_REQUEST_TYPES.CRON,
1455
+ ...payload,
1456
+ });
1457
+ },
1458
+ setGlobalScope: (scope, targetProjectRoot) => {
1459
+ setGlobalScope(scope, targetProjectRoot).catch((err) => {
1460
+ const message = err && err.message ? err.message : String(err || "scope switch failed");
1461
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(message)}`);
1462
+ });
1463
+ },
1464
+ getGlobalScope: () => globalScope,
1385
1465
  renderDashboard,
1386
1466
  renderAgentDashboard,
1387
1467
  renderScreen: () => screen.render(),
@@ -1518,7 +1598,7 @@ async function runChat(projectRoot, options = {}) {
1518
1598
  hasStream: (publisher) => streamTracker.hasStream(publisher),
1519
1599
  setTransientAgentState: (agentId, state) => {
1520
1600
  if (!agentId || !state) return;
1521
- transientAgentStateMap.set(agentId, state);
1601
+ setTransientAgentStateValue(transientAgentStateMap, agentId, state);
1522
1602
  },
1523
1603
  clearTransientAgentState: (agentId) => {
1524
1604
  if (!agentId) return;
@@ -1630,6 +1710,7 @@ async function runChat(projectRoot, options = {}) {
1630
1710
  if (globalMode) {
1631
1711
  refreshProjectRuntimes();
1632
1712
  syncSelectedProjectToActive();
1713
+ updatePromptBox();
1633
1714
  renderDashboard();
1634
1715
  screen.render();
1635
1716
  }
@@ -1645,6 +1726,44 @@ async function runChat(projectRoot, options = {}) {
1645
1726
  }
1646
1727
  }
1647
1728
 
1729
+ async function setGlobalScope(scope, targetProjectRoot) {
1730
+ if (!globalMode) return;
1731
+
1732
+ if (scope === "controller") {
1733
+ if (globalScope === "controller") return;
1734
+ const controllerRoot = resolveGlobalControllerProjectRoot();
1735
+ if (activeProjectRoot !== controllerRoot) {
1736
+ const result = await requestProjectSwitchByTarget(controllerRoot);
1737
+ if (!result || !result.ok) {
1738
+ const reason = (result && result.error) || "switch to controller failed";
1739
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(reason)}`);
1740
+ return;
1741
+ }
1742
+ }
1743
+ globalScope = "controller";
1744
+ targetAgent = null;
1745
+ updatePromptBox();
1746
+ if (projectRuntimes.length > 0 && (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length)) {
1747
+ selectedProjectIndex = 0;
1748
+ }
1749
+ renderDashboard();
1750
+ screen.render();
1751
+ } else if (scope === "project") {
1752
+ if (!targetProjectRoot) return;
1753
+ targetAgent = null;
1754
+ const result = await requestProjectSwitchByTarget(targetProjectRoot);
1755
+ if (!result || !result.ok) {
1756
+ const reason = (result && result.error) || "switch to project failed";
1757
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(reason)}`);
1758
+ return;
1759
+ }
1760
+ globalScope = "project";
1761
+ updatePromptBox();
1762
+ renderDashboard();
1763
+ screen.render();
1764
+ }
1765
+ }
1766
+
1648
1767
  let projectSwitching = false;
1649
1768
  let pendingProjectSwitchRoot = null;
1650
1769
  let projectSwitchDebounceTimer = null;
@@ -1815,9 +1934,12 @@ async function runChat(projectRoot, options = {}) {
1815
1934
  listProjects: () => listProjectRuntimes({ validate: true, cleanupTmp: true }),
1816
1935
  getCurrentProject: () => ({
1817
1936
  project_root: activeProjectRoot,
1818
- project_name: path.basename(activeProjectRoot),
1937
+ project_name: globalMode && isGlobalControllerProjectRoot(activeProjectRoot)
1938
+ ? "global-controller"
1939
+ : path.basename(activeProjectRoot),
1819
1940
  }),
1820
1941
  switchProject: async ({ target } = {}) => requestProjectSwitchByTarget(target),
1942
+ globalMode,
1821
1943
  });
1822
1944
 
1823
1945
  async function executeCommand(text) {
@@ -1933,13 +2055,6 @@ async function runChat(projectRoot, options = {}) {
1933
2055
  focusInput();
1934
2056
  });
1935
2057
 
1936
- // Escape in input mode only clears @target, never exits
1937
- input.key(["escape"], () => {
1938
- if (targetAgent) {
1939
- clearTargetAgent();
1940
- }
1941
- });
1942
-
1943
2058
  focusInput();
1944
2059
  if (screen.program && typeof screen.program.decset === "function") {
1945
2060
  screen.program.decset(2004);
@@ -1957,6 +2072,7 @@ async function runChat(projectRoot, options = {}) {
1957
2072
  if (globalMode) {
1958
2073
  refreshProjectRuntimes();
1959
2074
  }
2075
+ updatePromptBox();
1960
2076
  renderDashboard();
1961
2077
  resizeInput();
1962
2078
  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
  }
@@ -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)")