ofiere-openclaw-plugin 4.45.0 → 4.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofiere-openclaw-plugin",
3
- "version": "4.45.0",
3
+ "version": "4.47.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin for Ofiere PM - 16 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, space file management, execution plan builder, SOP management, agent brain, talent management, and corporate frameworks",
6
6
  "keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
@@ -15,7 +15,7 @@ import {
15
15
  invalidateAgentTier,
16
16
  } from "./agent-tier.js";
17
17
  import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
18
- import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, resolveStaffModels, type SubagentRow } from "./staffPersona.js";
18
+ import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
19
19
 
20
20
  interface ToolResult {
21
21
  content: Array<{ type: "text"; text: string }>;
@@ -427,7 +427,27 @@ export function registerAttachmentContextHook(args: {
427
427
  // Cycle 7b — staff persona injection. Only fires when subagent_id is
428
428
  // present in dispatch metadata (task-dispatcher / scheduler / explicit
429
429
  // dispatch params). Plain user chats with the chief never persona-swap.
430
- const subagentId = readDispatchSubagentId(ctx);
430
+ //
431
+ // Cycle 7b BUGSHOOT-1 (BUG 2) — OpenClaw gateway core rejects unknown
432
+ // root key `metadata` on chat.send, so the dispatcher can't ride
433
+ // metadata through the wire. After the metadata path comes up empty,
434
+ // try the dispatch_context table keyed by sessionKey. The metadata
435
+ // path is preferred so a future gateway upgrade keeps working.
436
+ const sessionKey: string | null =
437
+ ctx?.sessionKey || ctx?.session_key ||
438
+ (typeof _event === "object" ? (_event?.sessionKey || _event?.context?.sessionKey || null) : null) ||
439
+ null;
440
+ let dispatchCtxRow: DispatchContextRow | null = null;
441
+ let subagentId = readDispatchSubagentId(ctx);
442
+ if (!subagentId && sessionKey) {
443
+ dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
444
+ if (dispatchCtxRow?.subagent_id) {
445
+ subagentId = dispatchCtxRow.subagent_id;
446
+ api.logger?.debug?.(
447
+ `[ofiere-staff] subagent_id resolved via dispatch_context (session=${sessionKey})`,
448
+ );
449
+ }
450
+ }
431
451
  let staffPrefix = "";
432
452
  let staffRow: SubagentRow | null = null;
433
453
  if (subagentId) {
@@ -460,7 +480,23 @@ export function registerAttachmentContextHook(args: {
460
480
  // can stash explicit `attached_sop_ids` / `attached_framework_ids` on the
461
481
  // chat.send frame's metadata. When present, prefer them over the
462
482
  // most-recent-conversation lookup and bypass cache (per-dispatch ids).
463
- const dispatchIds = readDispatchAttachmentIds(ctx);
483
+ let dispatchIds = readDispatchAttachmentIds(ctx);
484
+ // Cycle 7b BUGSHOOT-1 (BUG 2) — DB-backed dispatch-context fallback for
485
+ // attachment ids. Same rationale as the subagent_id fallback above.
486
+ if (!dispatchIds.sopIds.length && !dispatchIds.fwIds.length && sessionKey) {
487
+ if (!dispatchCtxRow) {
488
+ dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
489
+ }
490
+ if (dispatchCtxRow && (dispatchCtxRow.attached_sop_ids.length || dispatchCtxRow.attached_framework_ids.length)) {
491
+ dispatchIds = {
492
+ sopIds: dispatchCtxRow.attached_sop_ids,
493
+ fwIds: dispatchCtxRow.attached_framework_ids,
494
+ };
495
+ api.logger?.debug?.(
496
+ `[ofiere-attach] dispatch ids via dispatch_context (session=${sessionKey}) sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
497
+ );
498
+ }
499
+ }
464
500
  if (dispatchIds.sopIds.length || dispatchIds.fwIds.length) {
465
501
  api.logger?.debug?.(
466
502
  `[ofiere-attach] dispatch ids agent=${resolvedAgentId} sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
@@ -59,6 +59,52 @@ export function readDispatchSubagentId(ctx: any, event?: any): string | null {
59
59
  return null;
60
60
  }
61
61
 
62
+ // Cycle 7b BUGSHOOT-1 (BUG 2) — DB-backed dispatch context fallback.
63
+ // OpenClaw gateway core rejects unknown root key `metadata` on chat.send, so
64
+ // the task-dispatcher edge function can't ride dispatch metadata through the
65
+ // wire. The dispatcher writes a row to public.dispatch_context keyed by
66
+ // (user_id, session_key) before chat.send fires; this loader reads it back
67
+ // during before_prompt_build when the metadata path comes up empty.
68
+ //
69
+ // Returns the dispatch context for the most recent matching row, or null if
70
+ // none exists. The metadata path is preferred so a future gateway upgrade
71
+ // that natively accepts metadata keeps working with no flag day.
72
+ export interface DispatchContextRow {
73
+ subagent_id: string | null;
74
+ attached_sop_ids: string[];
75
+ attached_framework_ids: string[];
76
+ task_id: string | null;
77
+ conversation_id: string | null;
78
+ }
79
+
80
+ export async function loadDispatchContextBySession(
81
+ supabase: SupabaseClient,
82
+ userId: string,
83
+ sessionKey: string,
84
+ ): Promise<DispatchContextRow | null> {
85
+ if (!sessionKey) return null;
86
+ const { data, error } = await supabase
87
+ .from("dispatch_context")
88
+ .select("subagent_id, attached_sop_ids, attached_framework_ids, task_id, conversation_id")
89
+ .eq("user_id", userId)
90
+ .eq("session_key", sessionKey)
91
+ .order("created_at", { ascending: false })
92
+ .limit(1)
93
+ .maybeSingle();
94
+ if (error || !data) return null;
95
+ return {
96
+ subagent_id: (data.subagent_id as string | null) ?? null,
97
+ attached_sop_ids: Array.isArray(data.attached_sop_ids)
98
+ ? (data.attached_sop_ids as unknown[]).filter((x): x is string => typeof x === "string")
99
+ : [],
100
+ attached_framework_ids: Array.isArray(data.attached_framework_ids)
101
+ ? (data.attached_framework_ids as unknown[]).filter((x): x is string => typeof x === "string")
102
+ : [],
103
+ task_id: (data.task_id as string | null) ?? null,
104
+ conversation_id: (data.conversation_id as string | null) ?? null,
105
+ };
106
+ }
107
+
62
108
  export type StaffModelSlot =
63
109
  | "primary_model"
64
110
  | "coding_model"
package/src/tools.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  registerAttachmentContextHook,
20
20
  } from "./attachments.js";
21
21
  import { invalidateAgentTier } from "./agent-tier.js";
22
- import { loadSubagentRow, readDispatchSubagentId } from "./staffPersona.js";
22
+ import { loadSubagentRow, readDispatchSubagentId, loadDispatchContextBySession, type DispatchContextRow } from "./staffPersona.js";
23
23
 
24
24
  // ─── Tool result shape (matches OpenClaw SDK) ────────────────────────────────
25
25
 
@@ -458,12 +458,145 @@ async function handleListTasks(
458
458
  }
459
459
  }
460
460
 
461
+ // Cycle 7b BUGSHOOT-2 (BUG 5) — IANA timezone offset in hours. Works regardless
462
+ // of the VPS clock TZ because Intl.DateTimeFormat resolves the IANA zone
463
+ // directly without going through `new Date(local-string)` parsing.
464
+ function getTimezoneOffsetHours(tz: string): number {
465
+ try {
466
+ const parts = new Intl.DateTimeFormat("en-US", {
467
+ timeZone: tz,
468
+ timeZoneName: "shortOffset",
469
+ }).formatToParts(new Date());
470
+ const offsetPart = parts.find((p) => p.type === "timeZoneName");
471
+ const txt = offsetPart?.value || "GMT+0";
472
+ const m = txt.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
473
+ if (!m) return 0;
474
+ const hours = parseInt(m[1], 10);
475
+ const minutes = m[2] ? parseInt(m[2], 10) : 0;
476
+ return hours + (hours < 0 ? -minutes / 60 : minutes / 60);
477
+ } catch {
478
+ return 7;
479
+ }
480
+ }
481
+
482
+ // Cycle 7b BUGSHOOT-2 (BUG 5) — load the caller's IANA timezone from their
483
+ // profile row. The dashboard writes it on sign-in (browser-detected). Falls
484
+ // back to the plugin-config default if unset/missing.
485
+ async function loadUserTimezone(
486
+ supabase: SupabaseClient,
487
+ userId: string,
488
+ fallback: string,
489
+ ): Promise<string> {
490
+ try {
491
+ const { data } = await supabase
492
+ .from("profiles")
493
+ .select("timezone")
494
+ .eq("id", userId)
495
+ .maybeSingle();
496
+ const tz = typeof data?.timezone === "string" && data.timezone.trim()
497
+ ? data.timezone.trim()
498
+ : null;
499
+ return tz || fallback;
500
+ } catch {
501
+ return fallback;
502
+ }
503
+ }
504
+
505
+ // Cycle 7b BUGSHOOT-2 (BUG 5) — normalize a start_date string to UTC against
506
+ // the user's IANA timezone. Returns the canonical UTC ISO string, the epoch
507
+ // for next_run_at, and the local clock components for scheduler_events.
508
+ //
509
+ // Three input shapes are handled:
510
+ // - explicit UTC marker (Z, +00, +0000, +00:00, UTC) → no conversion
511
+ // - has time component, no UTC marker → treat as user-local, subtract offset
512
+ // - date only → default to 09:00 user-local, subtract offset
513
+ //
514
+ // Critical: parses date/time COMPONENTS from the raw string via regex, NOT via
515
+ // `new Date(str)`. JS Date parsing of naive strings is TZ-dependent on the
516
+ // host clock — the VPS may be WIB or UTC, and we cannot trust either.
517
+ function normalizeStartDate(
518
+ startDate: string,
519
+ explicitScheduledTime: string | undefined,
520
+ tzOffsetHours: number,
521
+ ): {
522
+ startDateUTC: string;
523
+ nextRunAtEpoch: number;
524
+ scheduledTimeLocal: string;
525
+ scheduledDateLocal: string;
526
+ } | null {
527
+ const trimmed = startDate.trim();
528
+ const dateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
529
+ if (!dateMatch) return null;
530
+ const year = parseInt(dateMatch[1], 10);
531
+ const month = parseInt(dateMatch[2], 10);
532
+ const day = parseInt(dateMatch[3], 10);
533
+
534
+ const utcMarker = /Z$|[+-]00:?00$|UTC$/i.test(trimmed);
535
+ const timeMatch = trimmed.match(/[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
536
+ const offsetMin = Math.round(tzOffsetHours * 60);
537
+ const pad = (n: number) => String(n).padStart(2, "0");
538
+
539
+ // explicit scheduled_time: agent passed local time alongside a date.
540
+ if (explicitScheduledTime) {
541
+ const stMatch = explicitScheduledTime.match(/^(\d{2}):(\d{2})/);
542
+ const localH = stMatch ? parseInt(stMatch[1], 10) : 0;
543
+ const localM = stMatch ? parseInt(stMatch[2], 10) : 0;
544
+ const utcMs = Date.UTC(year, month - 1, day, localH, localM, 0) - offsetMin * 60 * 1000;
545
+ return {
546
+ startDateUTC: new Date(utcMs).toISOString(),
547
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
548
+ scheduledTimeLocal: `${pad(localH)}:${pad(localM)}`,
549
+ scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
550
+ };
551
+ }
552
+
553
+ if (utcMarker && timeMatch) {
554
+ // Input is already UTC. Use components verbatim.
555
+ const utcH = parseInt(timeMatch[1], 10);
556
+ const utcM = parseInt(timeMatch[2], 10);
557
+ const utcS = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
558
+ const utcMs = Date.UTC(year, month - 1, day, utcH, utcM, utcS);
559
+ // Recover local clock by adding the user's offset.
560
+ const localMs = utcMs + offsetMin * 60 * 1000;
561
+ const local = new Date(localMs);
562
+ return {
563
+ startDateUTC: new Date(utcMs).toISOString(),
564
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
565
+ scheduledTimeLocal: `${pad(local.getUTCHours())}:${pad(local.getUTCMinutes())}`,
566
+ scheduledDateLocal: `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`,
567
+ };
568
+ }
569
+
570
+ if (timeMatch) {
571
+ // Local-no-Z input: user-local time, subtract offset to get UTC.
572
+ const localH = parseInt(timeMatch[1], 10);
573
+ const localM = parseInt(timeMatch[2], 10);
574
+ const localS = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
575
+ const utcMs = Date.UTC(year, month - 1, day, localH, localM, localS) - offsetMin * 60 * 1000;
576
+ return {
577
+ startDateUTC: new Date(utcMs).toISOString(),
578
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
579
+ scheduledTimeLocal: `${pad(localH)}:${pad(localM)}`,
580
+ scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
581
+ };
582
+ }
583
+
584
+ // Date-only input — default to 09:00 user-local.
585
+ const utcMs = Date.UTC(year, month - 1, day, 9, 0, 0) - offsetMin * 60 * 1000;
586
+ return {
587
+ startDateUTC: new Date(utcMs).toISOString(),
588
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
589
+ scheduledTimeLocal: "09:00",
590
+ scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
591
+ };
592
+ }
593
+
461
594
  async function handleCreateTask(
462
595
  supabase: SupabaseClient,
463
596
  userId: string,
464
597
  resolveAgent: (id?: string) => Promise<string | null>,
465
598
  params: Record<string, unknown>,
466
- timezone: string = "Asia/Jakarta",
599
+ fallbackTimezone: string = "Asia/Jakarta",
467
600
  ): Promise<ToolResult> {
468
601
  try {
469
602
  if (!params.title) return err("Missing required field: title");
@@ -471,6 +604,11 @@ async function handleCreateTask(
471
604
  const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
472
605
  const now = new Date().toISOString();
473
606
 
607
+ // BUG 5 fix: resolve user's IANA timezone from their profile row. Plugin
608
+ // config default is only used when the profile row is missing/unset.
609
+ const timezone = await loadUserTimezone(supabase, userId, fallbackTimezone);
610
+ const TZ_OFFSET_HOURS = getTimezoneOffsetHours(timezone);
611
+
474
612
  // Handle explicit "none"/"unassigned"
475
613
  const rawAgentId = params.agent_id as string | undefined;
476
614
  const isUnassigned =
@@ -576,6 +714,17 @@ async function handleCreateTask(
576
714
  }
577
715
  }
578
716
 
717
+ // BUG 5 fix (cycle 7b BUGSHOOT-2): normalize start_date BEFORE the INSERT
718
+ // so DB and tool-response carry the SAME canonical UTC ISO. Old code
719
+ // INSERTed the raw input then reassigned insertData.start_date afterward,
720
+ // which left the DB row with the unconverted value and the response with
721
+ // the converted one — confusing and unsafe for the scheduler.
722
+ const rawStartDate = (params.start_date as string) || null;
723
+ const normalized = rawStartDate
724
+ ? normalizeStartDate(rawStartDate, params.scheduled_time as string | undefined, TZ_OFFSET_HOURS)
725
+ : null;
726
+ const canonicalStartDate = normalized?.startDateUTC ?? rawStartDate;
727
+
579
728
  const insertData: Record<string, unknown> = {
580
729
  id,
581
730
  user_id: userId,
@@ -588,8 +737,8 @@ async function handleCreateTask(
588
737
  priority: params.priority !== undefined ? params.priority : 1,
589
738
  space_id: resolvedSpaceId,
590
739
  folder_id: resolvedFolderId,
591
- start_date: (params.start_date as string) || null,
592
- due_date: (params.due_date as string) || (params.start_date as string) || null,
740
+ start_date: canonicalStartDate,
741
+ due_date: (params.due_date as string) || canonicalStartDate || null,
593
742
  tags: (params.tags as string[]) || [],
594
743
  progress: 0,
595
744
  sort_order: 0,
@@ -615,98 +764,15 @@ async function handleCreateTask(
615
764
  }
616
765
 
617
766
  // ── Auto-create scheduler event if task has a start_date ──────────────
618
- // This bridges the plugin scheduler so the pg_cron task-dispatcher
619
- // Edge Function picks up the task at the right time.
620
- //
621
- // TIMEZONE: scheduled_time is treated as the user's LOCAL time (default WIB, UTC+7).
622
- // We convert to UTC epoch for next_run_at so the edge function fires correctly.
623
- const startDate = params.start_date as string | undefined;
767
+ // start_date is already normalized above. Scheduler event uses the same
768
+ // canonical UTC ISO + the local-clock components for the dashboard UI.
769
+ const startDate = rawStartDate;
624
770
  const effectiveAgentId = (insertData.agent_id as string) || assignee;
625
-
626
- // Resolve timezone offset from IANA timezone string (configurable per user)
627
- const getTimezoneOffsetHours = (tz: string): number => {
628
- try {
629
- const now = new Date();
630
- const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
631
- const tzStr = now.toLocaleString('en-US', { timeZone: tz });
632
- const diffMs = new Date(tzStr).getTime() - new Date(utcStr).getTime();
633
- return Math.round(diffMs / 3600000);
634
- } catch {
635
- return 7; // Fallback to WIB (UTC+7)
636
- }
637
- };
638
- const TZ_OFFSET_HOURS = getTimezoneOffsetHours(timezone);
639
- if (startDate && effectiveAgentId) {
771
+ if (startDate && effectiveAgentId && normalized) {
640
772
  try {
641
- // Parse start_date robustly it can be:
642
- // "2026-04-19" (date only)
643
- // "2026-04-19T18:45:00" (local datetime)
644
- // "2026-04-19 11:45:00+00" (Supabase timestamptz)
645
- // "2026-04-19T11:45:00.000Z" (ISO UTC)
646
- const parsedDate = new Date(startDate);
647
- const explicitScheduledTime = params.scheduled_time as string | undefined;
648
-
649
- let nextRunAtEpoch: number;
650
- let scheduledTimeFinal: string;
651
- let scheduledDateFinal: string;
652
-
653
- if (!isNaN(parsedDate.getTime())) {
654
- // Valid date — check if it includes a meaningful time component
655
- const hasTimeInfo = /[T ]\d{2}:\d{2}/.test(startDate);
656
-
657
- if (explicitScheduledTime) {
658
- // Agent explicitly passed a scheduled_time — treat as user’s local time
659
- const dateStr = parsedDate.toISOString().split("T")[0]; // YYYY-MM-DD
660
- const [localH, localM] = explicitScheduledTime.split(":").map(Number);
661
- const utcH = localH - TZ_OFFSET_HOURS;
662
- const dt = new Date(`${dateStr}T00:00:00Z`);
663
- dt.setUTCHours(utcH, localM, 0, 0);
664
- nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
665
- scheduledTimeFinal = explicitScheduledTime; // Store as user's local time
666
- scheduledDateFinal = dateStr;
667
- // Normalize start_date to ISO UTC for consistent downstream parsing
668
- insertData.start_date = dt.toISOString();
669
- } else if (hasTimeInfo) {
670
- // start_date already contains time — assume it’s in the user’s local timezone
671
- // Extract the local hour:minute from the string, NOT from UTC parsing
672
- const timeMatch = startDate.match(/(\d{2}):(\d{2})/);
673
- if (timeMatch) {
674
- const localH = parseInt(timeMatch[1], 10);
675
- const localM = parseInt(timeMatch[2], 10);
676
- const dateStr = parsedDate.toISOString().split("T")[0];
677
- const dt = new Date(`${dateStr}T00:00:00Z`);
678
- dt.setUTCHours(localH - TZ_OFFSET_HOURS, localM, 0, 0);
679
- nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
680
- scheduledTimeFinal = `${String(localH).padStart(2, "0")}:${String(localM).padStart(2, "0")}`;
681
- scheduledDateFinal = dateStr;
682
- // Normalize start_date to ISO UTC
683
- insertData.start_date = dt.toISOString();
684
- } else {
685
- // Can't extract time — fall back to UTC parsing
686
- nextRunAtEpoch = Math.floor(parsedDate.getTime() / 1000);
687
- scheduledTimeFinal = `${String(parsedDate.getUTCHours()).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`;
688
- scheduledDateFinal = parsedDate.toISOString().split("T")[0];
689
- }
690
- } else {
691
- // Date only, no time — default to 09:00 user local time
692
- const dateStr = parsedDate.toISOString().split("T")[0];
693
- const dt = new Date(`${dateStr}T00:00:00Z`);
694
- dt.setUTCHours(9 - TZ_OFFSET_HOURS, 0, 0, 0); // 09:00 local = UTC - offset
695
- nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
696
- scheduledTimeFinal = "09:00"; // Stored as user's local time
697
- scheduledDateFinal = dateStr;
698
- // Normalize start_date to ISO UTC
699
- insertData.start_date = dt.toISOString();
700
- }
701
- } else {
702
- // Unparseable date — fallback to now + 60s
703
- nextRunAtEpoch = Math.floor(Date.now() / 1000) + 60;
704
- scheduledTimeFinal = "00:00";
705
- scheduledDateFinal = new Date().toISOString().split("T")[0];
706
- }
707
-
708
- // Safety net: if computed time is in the past, schedule for now + 60s
773
+ let { nextRunAtEpoch } = normalized;
709
774
  const nowEpoch = Math.floor(Date.now() / 1000);
775
+ // Safety net: if computed time is in the past, schedule for now + 60s
710
776
  if (nextRunAtEpoch <= nowEpoch) {
711
777
  nextRunAtEpoch = nowEpoch + 60;
712
778
  }
@@ -719,8 +785,8 @@ async function handleCreateTask(
719
785
  subagent_id: subagentId,
720
786
  title: params.title,
721
787
  description: (params.description as string) || (params.instructions as string) || null,
722
- scheduled_date: scheduledDateFinal,
723
- scheduled_time: scheduledTimeFinal,
788
+ scheduled_date: normalized.scheduledDateLocal,
789
+ scheduled_time: normalized.scheduledTimeLocal,
724
790
  duration_minutes: 30,
725
791
  recurrence_type: (params.recurrence_type as string) || "none",
726
792
  recurrence_interval: (params.recurrence_interval as number) || 1,
@@ -5387,14 +5453,36 @@ async function handleCreateSubagent(supabase: SupabaseClient, userId: string, pa
5387
5453
  return err(`Maximum ${MAX_SUBAGENTS_PER_CHIEF} subagents per department chief`);
5388
5454
  }
5389
5455
 
5390
- const role = (params.role as string) || "Staff";
5391
- const codename = (params.codename as string) ?? null;
5392
- const colorHex = (params.color_hex as string) || "#64748b";
5456
+ // BUG 1 fix (cycle 7b BUGSHOOT-1): persist persona/model/MCP fields on
5457
+ // create. Schema accepts them (see SUBAGENT_INPUT_SCHEMA above) but the
5458
+ // previous insert dropped everything except identity/UI fields, forcing a
5459
+ // follow-up update_subagent. Mirror the field whitelist used in
5460
+ // handleUpdateSubagent so create/update stay in lockstep.
5461
+ const insertRow: Record<string, unknown> = {
5462
+ user_id: userId,
5463
+ chief_agent_id: chiefAgentId,
5464
+ name,
5465
+ role: (params.role as string) || "Staff",
5466
+ codename: (params.codename as string) ?? null,
5467
+ color_hex: (params.color_hex as string) || "#64748b",
5468
+ };
5469
+ const personaFields = [
5470
+ "system_prompt", "mission", "responsibilities", "instructions",
5471
+ "primary_model", "coding_model", "tool_call_model", "function_call_model", "vision_model",
5472
+ ] as const;
5473
+ for (const key of personaFields) {
5474
+ if (key in params && params[key] !== undefined) insertRow[key] = params[key];
5475
+ }
5476
+ if ("mcp_server_ids" in params && Array.isArray(params.mcp_server_ids)) {
5477
+ insertRow.mcp_server_ids = (params.mcp_server_ids as unknown[]).filter(
5478
+ (x): x is string => typeof x === "string",
5479
+ );
5480
+ }
5393
5481
 
5394
5482
  const { data, error } = await supabase
5395
5483
  .from("agent_subagents")
5396
- .insert({ user_id: userId, chief_agent_id: chiefAgentId, name, role, codename, color_hex: colorHex })
5397
- .select("id, user_id, chief_agent_id, name, role, codename, color_hex, created_at")
5484
+ .insert(insertRow)
5485
+ .select()
5398
5486
  .single();
5399
5487
  if (error) return err(error.message);
5400
5488
  return ok({ subagent: data, message: `Subagent "${name}" created under chief ${chiefAgentId}` });
@@ -6536,7 +6624,24 @@ function registerBrainExtractionHook(
6536
6624
  // scoped memory note ("Staff X reported on task Y: ...") and POST a
6537
6625
  // webhook so the dashboard can surface the report. The staff itself
6538
6626
  // gets no memory entry (req 3 — staff has no memory of its own).
6539
- const staffDispatchSubagentId = readDispatchSubagentId(ctx, event);
6627
+ //
6628
+ // Cycle 7b BUGSHOOT-2 (BUG 7) — metadata path is dead since BUG 2 made
6629
+ // the dispatcher stop sending `metadata` on chat.send. Fall back to
6630
+ // the dispatch_context row keyed by sessionKey before giving up.
6631
+ let staffDispatchSubagentId = readDispatchSubagentId(ctx, event);
6632
+ let dispatchCtxRow: DispatchContextRow | null = null;
6633
+ let dispatchTaskIdFromCtx: string | null = null;
6634
+ let dispatchConversationIdFromCtx: string | null = null;
6635
+ if (sessionKey) {
6636
+ dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
6637
+ if (dispatchCtxRow) {
6638
+ if (!staffDispatchSubagentId && dispatchCtxRow.subagent_id) {
6639
+ staffDispatchSubagentId = dispatchCtxRow.subagent_id;
6640
+ }
6641
+ dispatchTaskIdFromCtx = dispatchCtxRow.task_id;
6642
+ dispatchConversationIdFromCtx = dispatchCtxRow.conversation_id;
6643
+ }
6644
+ }
6540
6645
  if (staffDispatchSubagentId) {
6541
6646
  (async () => {
6542
6647
  try {
@@ -6549,9 +6654,12 @@ function registerBrainExtractionHook(
6549
6654
  api.logger.warn?.(`[ofiere-staff-report] chief_mismatch (subagent ${staffDispatchSubagentId} reports to ${staff.chief_agent_id}, run was on ${resolvedAgentId}) — skipping report`);
6550
6655
  return;
6551
6656
  }
6657
+ // BUG 7 fix (BUGSHOOT-2): include dispatch_context.task_id as a
6658
+ // fallback now that metadata is dead on the wire.
6552
6659
  const taskId =
6553
6660
  ctx?.metadata?.task_id || ctx?.metadata?.dispatch?.task_id ||
6554
- event?.metadata?.task_id || event?.context?.metadata?.task_id || null;
6661
+ event?.metadata?.task_id || event?.context?.metadata?.task_id ||
6662
+ dispatchTaskIdFromCtx || null;
6555
6663
  const excerpt = lastAssistant.slice(0, 1500);
6556
6664
  const reportBody = taskId
6557
6665
  ? `Staff ${staff.name}${staff.role ? ` (${staff.role})` : ""} reported on task ${taskId}:\n\n${excerpt}`
@@ -6567,6 +6675,54 @@ function registerBrainExtractionHook(
6567
6675
  });
6568
6676
  api.logger.info?.(`[ofiere-staff-report] memory written for chief ${staff.chief_agent_id} from staff ${staff.name}`);
6569
6677
 
6678
+ // BUG 9 fix (BUGSHOOT-2): mark task_dispatch_log row complete
6679
+ // with the assistant's response preview. Without this the log
6680
+ // stays at status='dispatched' forever, breaking dashboard
6681
+ // dispatch-history views and audit trails.
6682
+ if (taskId) {
6683
+ try {
6684
+ await supabase.from("task_dispatch_log")
6685
+ .update({
6686
+ status: "completed",
6687
+ response_preview: excerpt.slice(0, 500),
6688
+ completed_at: new Date().toISOString(),
6689
+ })
6690
+ .eq("task_id", taskId)
6691
+ .eq("status", "dispatched");
6692
+ } catch (logErr) {
6693
+ api.logger.debug?.(`[ofiere-staff-report] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
6694
+ }
6695
+ }
6696
+
6697
+ // BUG 6 fix (BUGSHOOT-2): mirror the assistant turn into the
6698
+ // dashboard's hidden conversation_messages so the dashboard can
6699
+ // render staff-run history. Dispatcher pre-created the
6700
+ // conversations row and stamped its id on dispatch_context.
6701
+ if (dispatchConversationIdFromCtx) {
6702
+ try {
6703
+ await supabase.from("conversation_messages").insert({
6704
+ conversation_id: dispatchConversationIdFromCtx,
6705
+ user_id: userId,
6706
+ role: "user",
6707
+ content: lastUser.slice(0, 4000),
6708
+ created_at: new Date(Date.now() - 1000).toISOString(),
6709
+ });
6710
+ await supabase.from("conversation_messages").insert({
6711
+ conversation_id: dispatchConversationIdFromCtx,
6712
+ user_id: userId,
6713
+ role: "assistant",
6714
+ content: excerpt,
6715
+ created_at: new Date().toISOString(),
6716
+ });
6717
+ await supabase.from("conversations")
6718
+ .update({ updated_at: new Date().toISOString() })
6719
+ .eq("id", dispatchConversationIdFromCtx)
6720
+ .eq("user_id", userId);
6721
+ } catch (cmErr) {
6722
+ api.logger.debug?.(`[ofiere-staff-report] conversation_messages insert failed: ${cmErr instanceof Error ? cmErr.message : String(cmErr)}`);
6723
+ }
6724
+ }
6725
+
6570
6726
  // Best-effort webhook to dashboard (existing OPENCLAW_WEBHOOK_SECRET).
6571
6727
  const webhookUrl = process.env.OFIERE_DASHBOARD_WEBHOOK_URL || "";
6572
6728
  const webhookSecret = process.env.OPENCLAW_WEBHOOK_SECRET || "";
@@ -6602,6 +6758,24 @@ function registerBrainExtractionHook(
6602
6758
  return;
6603
6759
  }
6604
6760
 
6761
+ // BUG 9 fix (BUGSHOOT-2): mark task_dispatch_log row complete for
6762
+ // non-staff scheduled dispatches too. Without this the log row stays
6763
+ // at 'dispatched' even after the chief finished the run.
6764
+ if (dispatchTaskIdFromCtx) {
6765
+ try {
6766
+ await supabase.from("task_dispatch_log")
6767
+ .update({
6768
+ status: "completed",
6769
+ response_preview: lastAssistant.slice(0, 500),
6770
+ completed_at: new Date().toISOString(),
6771
+ })
6772
+ .eq("task_id", dispatchTaskIdFromCtx)
6773
+ .eq("status", "dispatched");
6774
+ } catch (logErr) {
6775
+ api.logger.debug?.(`[ofiere-brain] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
6776
+ }
6777
+ }
6778
+
6605
6779
  // ── Fast Stream: Write L1_focus + L2_episode in parallel ──
6606
6780
  const rawContent = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
6607
6781
  const immediateWrites: PromiseLike<any>[] = [];