@sleep2agi/commhub-server 0.9.0-preview.139 → 0.9.0-preview.140

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/agent-department-http.test.ts +148 -0
  3. package/src/chain-attempt-dedup.test.ts +447 -0
  4. package/src/create-node-tool-schema.test.ts +1 -0
  5. package/src/create-node-validate.test.ts +6 -0
  6. package/src/create-node-validate.ts +7 -2
  7. package/src/db.ts +190 -21
  8. package/src/departments.ts +16 -0
  9. package/src/env-transport-gate-http.test.ts +146 -0
  10. package/src/env-transport-gate.ts +74 -0
  11. package/src/env-transport-policy.ts +21 -0
  12. package/src/host-supervisor-identity-http.test.ts +20 -4
  13. package/src/hub-catalog-read-http.test.ts +187 -0
  14. package/src/node-secret-rest-http.test.ts +190 -0
  15. package/src/observer-avatar-http.test.ts +13 -2
  16. package/src/report-status-resume-id-handover.test.ts +34 -0
  17. package/src/report-status-secret-keys.test.ts +140 -0
  18. package/src/requirements-http.test.ts +1 -1
  19. package/src/requirements-people.ts +8 -4
  20. package/src/rest-projections.ts +2 -0
  21. package/src/schedule-agent.ts +1 -1
  22. package/src/scheduled-backoff-http.test.ts +193 -0
  23. package/src/scheduled-backoff.ts +64 -0
  24. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  25. package/src/scheduled-outcome-guidance.ts +9 -0
  26. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  27. package/src/scheduled-requirement-wake.ts +125 -0
  28. package/src/scheduled-tasks.ts +78 -23
  29. package/src/scheduled-work-outcome-http.test.ts +154 -0
  30. package/src/scheduled-work-outcome.ts +50 -0
  31. package/src/scoped-secrets.ts +15 -0
  32. package/src/server.ts +162 -3
  33. package/src/tools.ts +99 -16
package/src/tools.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseScheduledWorkOutcome, qualifyScheduledWorkOutcome, saveScheduledWorkOutcome } from "./scheduled-work-outcome.js";
1
2
  import { selectHumanInboxIds } from "./human-inbox.js";
2
3
  import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -728,12 +729,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
728
729
 
729
730
  server.tool(
730
731
  "report_status",
731
- "Report agent status. Returns inbox_count so you know if there are pending tasks.",
732
+ "Report agent status. Returns inbox_count.",
732
733
  {
733
734
  resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
734
735
  alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
735
736
  status: z.enum(["working", "idle", "blocked", "error", "waiting_input", "offline"]),
736
- task: z.string().max(TASK_CONTENT_MAX).optional().describe("Current task description (the hub stores only the first 200 chars on the session row; the full text is matched against tasks.content)"),
737
+ task: z.string().max(TASK_CONTENT_MAX).optional().describe("Task (session stores 200 chars; full text matches tasks.content)"),
737
738
  output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
738
739
  score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
739
740
  progress: z.number().min(0).max(100).optional().describe("Progress 0-100"),
@@ -741,6 +742,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
741
742
  hostname: z.string().max(200).optional().describe("Agent hostname"),
742
743
  agent: z.string().max(100).optional().describe("Agent type (claude-code / codex / opencode)"),
743
744
  project_dir: z.string().max(1000).optional().describe("Agent working directory"),
745
+ // #1198 — OS account the process runs as (platform identity only; no env fallback).
746
+ os_user: z.string().min(1).max(256).optional().describe("OS account"),
747
+ // #1474 — names of the env keys this node actually has. Never values; the
748
+ // node-side collector parses and drops them. Capped at 50 * 64 chars so a
749
+ // pathological secrets.env cannot grow this row without bound.
750
+ secret_keys: z.array(z.string().regex(/^[A-Z_][A-Z0-9_]{0,63}$/)).max(50).optional().describe("Env key names only"),
751
+ // #1510 — how the node reaches this hub. Reported by the node because only
752
+ // it knows the last hop. Reporting only; no write path reads it yet.
753
+ env_transport: z.enum(["loopback", "https", "plain"]).optional().describe("Hub link kind"),
744
754
  version: z.string().max(100).optional().describe("Agent version"),
745
755
  tmux_name: z.string().max(200).optional().describe("tmux session name"),
746
756
  // V2 fields
@@ -963,7 +973,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
963
973
  // instead of rejecting the whole report (see node-health-store.ts).
964
974
  health: nodeHealthSchema,
965
975
  },
966
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable, health: nodeHealth }) => {
976
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, os_user: osUser, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable, health: nodeHealth, secret_keys: secretKeys, env_transport: envTransport }) => {
967
977
  const effectiveNetId = getNetworkId(netId);
968
978
  const sessionNetId = effectiveNetId ?? "default";
969
979
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -1110,7 +1120,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1110
1120
  channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
1111
1121
  disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
1112
1122
  process_uptime_seconds, process_in_flight_count, external_schedules, registered_at,
1113
- rules_file_capable, skills_capable, files_capable, logs_capable
1123
+ rules_file_capable, skills_capable, files_capable, logs_capable, os_user, secret_keys
1114
1124
  FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
1115
1125
  ORDER BY updated_at DESC LIMIT 1`,
1116
1126
  effectiveAlias, resume_id, sessionNetId,
@@ -1155,6 +1165,43 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1155
1165
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
1156
1166
  [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, sessionTask, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
1157
1167
  );
1168
+ // #1198 — os_user is its own UPDATE so the INSERT/UPSERT shape stays
1169
+ // pinned (test698). Absent means "not reported": keep the stored value.
1170
+ // A resume_id handover DELETE+INSERTs a row that starts NULL, so copy
1171
+ // the replaced row only when this report omits the field. COALESCE
1172
+ // keeps a value already on the surviving row.
1173
+ if (osUser) {
1174
+ db.run(
1175
+ `UPDATE sessions SET os_user = ?2, updated_at = datetime('now') WHERE resume_id = ?1`,
1176
+ [resume_id, osUser],
1177
+ );
1178
+ }
1179
+ // #1474 — its own UPDATE for the same reason as os_user: the INSERT /
1180
+ // UPSERT placeholder bytes stay untouched. An EMPTY array is a real
1181
+ // report ("this node has no secrets") and must overwrite the old list;
1182
+ // only `undefined` (field absent) keeps the previous value.
1183
+ if (envTransport) {
1184
+ db.run(
1185
+ `UPDATE sessions SET env_transport = ?2, updated_at = datetime('now') WHERE resume_id = ?1`,
1186
+ [resume_id, envTransport],
1187
+ );
1188
+ }
1189
+ if (Array.isArray(secretKeys)) {
1190
+ db.run(
1191
+ `UPDATE sessions SET secret_keys = ?2, updated_at = datetime('now') WHERE resume_id = ?1`,
1192
+ [resume_id, JSON.stringify(secretKeys)],
1193
+ );
1194
+ } else if (typeof handover?.secret_keys === "string" && handover.secret_keys) {
1195
+ db.run(
1196
+ `UPDATE sessions SET secret_keys = COALESCE(secret_keys, ?2) WHERE resume_id = ?1`,
1197
+ [resume_id, handover.secret_keys],
1198
+ );
1199
+ } else if (typeof handover?.os_user === "string" && handover.os_user) {
1200
+ db.run(
1201
+ `UPDATE sessions SET os_user = COALESCE(os_user, ?2) WHERE resume_id = ?1`,
1202
+ [resume_id, handover.os_user],
1203
+ );
1204
+ }
1158
1205
  if (handover) {
1159
1206
  // 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
1160
1207
  // 描述性列从被替换的那行接手。INSERT 语句与参数列表保持原样 —— test698 的变异
@@ -1908,8 +1955,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1908
1955
  // (db.ts) write back into A's task result + inbox — cross-tenant
1909
1956
  // write. Verify ownership, reject on mismatch.
1910
1957
  if (parentIn) {
1911
- const parentRow = db.get<{ network_id: string | null }>(
1912
- "SELECT network_id FROM tasks WHERE task_id = ?1",
1958
+ const parentRow = db.get<{ network_id: string | null; from_name: string; to_name: string }>(
1959
+ "SELECT network_id, from_name, to_name FROM tasks WHERE task_id = ?1",
1913
1960
  [parentIn]
1914
1961
  );
1915
1962
  if (!parentRow) {
@@ -1924,6 +1971,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1924
1971
  ok: false, error: "cross_network_parent",
1925
1972
  message: "parent_task_id belongs to a different network",
1926
1973
  }) }] };
1974
+ } else {
1975
+ // Node-TMAI#4 (D): same-network is NOT sufficient. The dispatcher
1976
+ // must be a party to the task it claims as parent — either its
1977
+ // originator or its assignee. Without this, a same-network caller
1978
+ // could attach its dispatch to any third party's task and the
1979
+ // chain would later surface that child's result to the third
1980
+ // party's originator. Compare canonical aliases so a committed
1981
+ // rename on either side cannot defeat the check.
1982
+ const parentFromAlias = resolveCanonicalAlias(effectiveNetId, parentRow.from_name).alias;
1983
+ const parentToAlias = resolveCanonicalAlias(effectiveNetId, parentRow.to_name).alias;
1984
+ const childFromAlias = resolveCanonicalAlias(effectiveNetId, from_session).alias;
1985
+ if (childFromAlias !== parentFromAlias && childFromAlias !== parentToAlias) {
1986
+ console.log(`[${ts()}] 🚫 send_task: non-participant parent rejected, parent=${parentIn.slice(0, 8)} caller=${childFromAlias}`);
1987
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1988
+ ok: false, error: "parent_not_participant",
1989
+ message: "parent_task_id is not a task this sender took part in",
1990
+ }) }] };
1991
+ }
1927
1992
  }
1928
1993
  }
1929
1994
 
@@ -2212,6 +2277,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2212
2277
  };
2213
2278
  const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
2214
2279
  const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta, thread_id: threadId, turn_id: turnId } = args;
2280
+ const workSignal = parseScheduledWorkOutcome(meta, replyStatus);
2281
+ if (!workSignal.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: workSignal.error, reply_queued: false }) }] };
2282
+ if (workSignal.outcome && !in_reply_to) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "scheduled_work_outcome_task_required", reply_queued: false }) }] };
2215
2283
  const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2216
2284
  const effectiveNetId = getNetworkId(netId);
2217
2285
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
@@ -2297,6 +2365,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2297
2365
  lateSql = addScope(lateSql, lateParams, effectiveNetId);
2298
2366
  const lateCandidate = db.get<LateTask>(lateSql, ...lateParams) ?? null;
2299
2367
  if (lateCandidate && !["created", "delivered", "acked", "running"].includes(lateCandidate.status)) {
2368
+ if (workSignal.outcome) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "scheduled_work_outcome_terminal", reply_queued: false }) }] };
2300
2369
  // Old runtimes do not send turn identity and retain the old result.
2301
2370
  if (!threadId || !turnId) {
2302
2371
  return { content: [{ type: "text" as const, text: JSON.stringify({
@@ -2460,6 +2529,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2460
2529
 
2461
2530
  }
2462
2531
 
2532
+ let workBinding: Extract<ReturnType<typeof qualifyScheduledWorkOutcome>, { ok: true }> | undefined;
2533
+ if (workSignal.outcome) {
2534
+ if (!in_reply_to || !callerTokenIsNetwork || !callerTokenId || !enforceNetworkId || effectiveNetId !== enforceNetworkId)
2535
+ return { ok: false as const, error: "scheduled_work_outcome_bound_node_required" };
2536
+ const token = db.get<{ bound_node_id: string | null }>(
2537
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2", callerTokenId, enforceNetworkId);
2538
+ const qualified = qualifyScheduledWorkOutcome(in_reply_to, enforceNetworkId, token?.bound_node_id ?? null);
2539
+ if (!qualified.ok) return qualified;
2540
+ workBinding = qualified;
2541
+ }
2542
+
2463
2543
  // #507 — write meta_json on inbox insert (parity with send_task L952).
2464
2544
  // Prior to this the attachments field on a send_reply call was
2465
2545
  // silently stripped by MCP Zod, leaving `ok:true` with no persisted
@@ -2514,11 +2594,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2514
2594
  if (result.changes === 0) {
2515
2595
  throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
2516
2596
  }
2597
+ if (workBinding && workSignal.outcome) saveScheduledWorkOutcome(workBinding.binding, workSignal.outcome);
2517
2598
  syncScheduledRunForTask(in_reply_to, effectiveNetId);
2518
2599
  // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
2519
2600
  // 只出 blocked;working 可能真在忙别的任务,不碰。
2520
2601
  releaseBlockedSession(from_session, effectiveNetId);
2521
- return { ok: true as const, replyLogged: true };
2602
+ return { ok: true as const, replyLogged: true, ...(workSignal.outcome ? { workOutcome: workSignal.outcome } : {}) };
2522
2603
  }
2523
2604
  return { ok: true as const, replyLogged: false };
2524
2605
  });
@@ -2632,6 +2713,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2632
2713
  text: JSON.stringify({
2633
2714
  ok: true,
2634
2715
  message_id: id,
2716
+ ...("workOutcome" in replyOutcome ? { scheduled_work_outcome: replyOutcome.workOutcome } : {}),
2635
2717
  session_status: session?.status ?? "unknown",
2636
2718
  ...(warning ? { warning } : {}),
2637
2719
  ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
@@ -4389,6 +4471,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4389
4471
  // These options belong under the strict flags envelope.
4390
4472
  opencodeGeneration: z.never().optional(),
4391
4473
  opencodeUnsafeTools: z.never().optional(),
4474
+ opencodeNewServer: z.never().optional(),
4392
4475
  channels: z.array(z.unknown()).optional(),
4393
4476
  // app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
4394
4477
  // 缺席 = 老行为:落 daemon 的 cwd。
@@ -6556,7 +6639,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6556
6639
  project_id: z.string().max(200).nullable().optional(),
6557
6640
  owner: reqPerson.nullable().optional().describe("负责人 (a person): {kind:'user', id | username} (ids / usernames from requirements_people; names resolve in the task's network); a node is rejected with owner_must_be_human — use agent_owner"),
6558
6641
  agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id | alias}"),
6559
- participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole list (written like owner / agent_owner; omitted people are removed, [] clears). To add or remove a few, use participants_add / participants_remove on requirements_update."),
6642
+ participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole list (like owner/agent_owner; omitted people are removed, [] clears). For a few, use participants_add/participants_remove."),
6560
6643
  external_url: z.string().max(500).nullable().optional(),
6561
6644
  parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
6562
6645
  };
@@ -6591,13 +6674,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6591
6674
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
6592
6675
  department_id: z.string().max(64).optional().describe("owner (or its Agent's owner) in this department or below"),
6593
6676
  top_level: z.boolean().optional(), q: z.string().max(200).optional(),
6594
- overdue: z.boolean().optional().describe("true = open tasks past their due (date-only due: before today, Asia/Shanghai); false = everything else"),
6677
+ overdue: z.boolean().optional().describe("true = open tasks whose due is past (date-only: before today, Asia/Shanghai); false = rest"),
6595
6678
  due_within_days: z.number().int().min(0).max(365).optional().describe("open tasks not yet overdue, due by the end of today + N days (0 = later today)"),
6596
6679
  limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
6597
6680
  cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
6598
- view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
6599
- changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included; with include_last_event also ones with a newer event, e.g. a comment) + deleted ids + server_time"),
6600
- include_last_event: z.boolean().optional().describe("default false; true adds last_event per row (newest event incl. comments: type, field, actor, at, summary; null if none), ~150 B/row"),
6681
+ view: z.enum(["full", "summary"]).optional().describe("default summary (no bodies; has_description/checklist_count instead); full = everything"),
6682
+ changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (incl. archived; +deleted ids +server_time; with include_last_event also newer events)"),
6683
+ include_last_event: z.boolean().optional().describe("default false; true adds last_event per row (~150 B/row: type, field, actor, at, summary; null if none)"),
6601
6684
  };
6602
6685
  const reqListKeys = Object.keys(reqListShape);
6603
6686
  server.registerTool(
@@ -6791,16 +6874,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6791
6874
  const schedId = z.string();
6792
6875
  const schedFields = {
6793
6876
  name: z.string(), task: z.string(), target_node_id: z.string().optional(),
6794
- schedule: z.any().describe("once{run_at}|interval{every_seconds>=60}|daily{time}|weekly{time,weekdays}"),
6795
- timezone: z.string().optional(), priority: z.string().optional(),
6877
+ schedule: z.any().describe("once|interval|daily|weekly"),
6878
+ timezone: z.string().optional(), priority: z.string().optional(), no_change_backoff: z.boolean().optional(),
6796
6879
  };
6797
6880
  server.tool("schedule_list", "Hub schedules for or by you.", { status: z.string().optional() },
6798
6881
  async ({ status }) => scheduleCall(id => agentListSchedules(id, status)));
6799
6882
  server.tool("schedule_get", "One schedule.", { schedule_id: schedId },
6800
6883
  async ({ schedule_id }) => scheduleCall(id => agentGetSchedule(id, schedule_id)));
6801
- server.tool("schedule_create", "Schedule a task to target_node_id (default you).", schedFields,
6884
+ server.tool("schedule_create", "Create your schedule.", schedFields,
6802
6885
  async (args) => scheduleCall((id, w) => agentCreateSchedule(id, w, args as Record<string, unknown>)));
6803
- server.tool("schedule_update", "Edit your schedule; status=paused|active.",
6886
+ server.tool("schedule_update", "Edit yours.",
6804
6887
  { ...schedFields, schedule_id: schedId, base_revision: z.number().int().min(0).optional().describe("Reject if current revision differs."), name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
6805
6888
  async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
6806
6889
  server.tool("schedule_batch_interval", "Batch interval edit; per-ID results.",