Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh

## [Unreleased]

## [0.90.0] — 2026-10-15
### Added
- **HQ: giao việc theo lịch.** The "Giao việc" dialog has "Khi nào": now, once at HH:MM, daily at HH:MM, or every 5–30 min / 1–12 h. HQ runs no timer of its own: it hands the session one prompt to book the task with Claude Code's native `CronCreate` (local time, fires between turns while the session is open, recurring jobs expire after 7 days by design and are not silently re-created). The task row reads `… · ⏰ mỗi 30 phút`. Preset **"R&D trực"**: every 30 min the R&D room researches the next backlog item when it has nothing assigned, or says `rest`.
- **`8sync ccc delegate on --after 1m`.** The critic answers a product/technical question only when the owner has typed nothing in that session for the given time; while the owner is active the question still reaches them. Owner-only topics (Ship, secrets, destructive, money, deploy/merge/release, high-risk) are unchanged. A hook cannot answer a question already on screen, so "no reply for 1 minute" is approximated by "no typing for 1 minute"; an unreadable transcript means the owner is asked.

- **`8sync ccc hook wake`** (Rust, UserPromptSubmit, no model call): a scheduled HQ prompt (marked `[8sync:wake]`) is dropped before it reaches Claude — zero tokens — when the session has an `in_progress` task, ran a scheduled prompt < 4 min ago (queued fires collapse), or, on a subscription login, the owner has typed nothing in that session for 30 min (unattended use needs a Console API key). Claude Code runs UserPromptSubmit hooks on cron and `/loop` fires too (https://code.claude.com/docs/en/hooks). Idea after ProactiveAgent's decision engine + minimum interval (github.com/leomariga/ProactiveAgent, BSD-3).

### Changed
- HQ tab "Chat nhóm" is now **"Chat tổng"**: every room (session) shares one chat.

### Fixed
- `8sync ccc . goal --help` / `-h` printed nothing and stored "--help" as the goal; it now prints usage and leaves the registry alone, and an unknown `-flag` is an error.

## [0.89.1] — 2026-10-15
### Changed
- Release size ceiling 6400 → 6592 KiB, attributed in `scripts/size-gate.sh` (v0.89.0 linux-x86_64 was 6,616,688 B, 63,088 B over; +69,664 B vs v0.88.0). v0.89.0 was tagged but not published; 0.89.1 ships everything listed under 0.89.0.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/cli", "crates/ccc-core"]

[workspace.package]
version = "0.89.1"
version = "0.90.0"
edition = "2021"
license = "MIT"
authors = ["8sync-dev <[email protected]>"]
Expand Down
12 changes: 9 additions & 3 deletions apps/hq-ui/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { capturePlain, listTmux, sendKeys } from "@/lib/server/proc";
import { isSessionId, queueInbox } from "@/lib/server/inbox";
import { ownerMessage, tasksFor } from "@/lib/server/queries";
import { clampText, isTmuxName, validateProjectPath } from "@/lib/server/validate";
import { parseSchedule, scheduleLabel, scheduledPrompt } from "@/lib/schedule";

export const runtime = "nodejs";

Expand Down Expand Up @@ -41,12 +42,17 @@ export async function POST(req: Request) {
if (!hasSession && !role) return bad("session or role is required", 422);
const prio = typeof body.prio === "string" && body.prio ? body.prio.toUpperCase() : null;
if (prio && !PRIOS.includes(prio)) return bad("prio must be one of P0-P3", 422);
const sched = parseSchedule(body.schedule);
if (!sched.ok) return bad(`schedule: ${sched.error}`, 422);

const { db } = o;
let session = hasSession ? (body.session as string) : "";
let started = false;
const extra = [role && `role: ${role}`, prio && `prio: ${prio}`].filter(Boolean).join(", ");
const text = `New task: ${subject.value}${extra ? ` (${extra})` : ""}${details?.ok ? `\n${details.value}` : ""}`;
const plain = `New task: ${subject.value}${extra ? ` (${extra})` : ""}${details?.ok ? `\n${details.value}` : ""}`;
// Scheduled: the session books it with its own CronCreate; the task row says when.
const text = sched.value ? scheduledPrompt(plain, sched.value) : plain;
const title = sched.value ? `${subject.value} · ⏰ ${scheduleLabel(sched.value)} (nhờ phiên lên lịch)` : subject.value;
if (!hasSession) {
const picked = await pickByRole(db, role as string);
if (picked) session = picked;
Expand All @@ -63,7 +69,7 @@ export async function POST(req: Request) {
const taskId = `owner:${stamp()}`;
db.prepare(
"INSERT INTO tasks(session, task_id, subject, role, prio, status, updated_at) VALUES (?, ?, ?, ?, ?, 'todo', ?)",
).run(session, taskId, subject.value, role, prio, stamp());
).run(session, taskId, title, role, prio, stamp());
const task = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(taskId);
queueInbox(session, text);
const message = ownerMessage(db, session, text, "inbox");
Expand All @@ -75,7 +81,7 @@ export async function POST(req: Request) {
const taskId = `owner:${stamp()}`;
db.prepare(
"INSERT INTO tasks(session, task_id, subject, role, prio, status, updated_at) VALUES (?, ?, ?, ?, ?, 'todo', ?)",
).run(session, taskId, subject.value, role, prio, stamp());
).run(session, taskId, title, role, prio, stamp());
const task = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(taskId);
if (started) return json({ task, session, started, message: null, delivery: "first-prompt", error: null }, 201);
let r: Awaited<ReturnType<typeof deliverWatched>>;
Expand Down
60 changes: 58 additions & 2 deletions apps/hq-ui/components/hq/assign-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,20 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ROLES, modelShort, type RoleId } from "@/lib/roles";
import { EVERY_HOURS, EVERY_MINUTES, RD_DUTY, scheduleLabel, type Schedule } from "@/lib/schedule";
import { addressOf, messageTargets, sessionLabel } from "@/lib/labels";
import type { Hq } from "./use-hq";
import type { Message, Project, Session, Task } from "./types";

const PRIOS = ["P0", "P1", "P2", "P3"] as const;
/** "Khi nào": now, once at HH:MM, daily at HH:MM, or every N minutes/hours (`m:30`, `h:2`). */
const WHEN_EVERY = [...EVERY_MINUTES.map((n) => `m:${n}`), ...EVERY_HOURS.map((n) => `h:${n}`)];

function toSchedule(when: string, at: string): Schedule | null {
if (when === "once" || when === "daily") return { kind: when, at };
if (when.startsWith("m:") || when.startsWith("h:")) return { kind: "every", n: Number(when.slice(2)), unit: when[0] as "m" | "h" };
return null;
}
type Target = `role:${string}` | `session:${string}`;

export type AssignDialogProps = {
Expand Down Expand Up @@ -41,6 +50,8 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
const [details, setDetails] = useState("");
const [target, setTarget] = useState<Target>(defaultSession ? `session:${defaultSession}` : `role:${defaultRole ?? "fullstack"}`);
const [prio, setPrio] = useState<string>("P2");
const [when, setWhen] = useState<string>("now");
const [at, setAt] = useState<string>("09:03");
const [projectId, setProjectId] = useState<string>(
String(usable.find((p) => p.id === defaultProjectId)?.id ?? usable[0]?.id ?? ""),
);
Expand All @@ -58,7 +69,8 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
setError(null);
try {
const [kind, value] = [target.split(":")[0], target.slice(target.indexOf(":") + 1)];
const body: Record<string, unknown> = { subject: subject.trim(), details: details.trim() || undefined, prio };
const schedule = toSchedule(when, at);
const body: Record<string, unknown> = { subject: subject.trim(), details: details.trim() || undefined, prio, schedule: schedule ?? undefined };
if (kind === "session") body.session = value;
else {
body.role = value;
Expand All @@ -71,10 +83,12 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
else {
const s = sessions.find((x) => x.name === r.session || x.session_id === r.session);
const who = s ? sessionLabel(s) : r.session.slice(0, 8);
toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}`);
const plan = schedule ? ` · đã nhờ phiên lên lịch ${scheduleLabel(schedule)} (chưa xác nhận: xem câu trả lời của phiên)` : "";
toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}${plan}`);
}
setSubject("");
setDetails("");
setWhen("now");
onOpenChange(false);
} catch (err) {
// Shown inside the dialog: a toast behind the modal is easy to miss.
Expand All @@ -92,6 +106,20 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
<DialogTitle>Giao việc</DialogTitle>
<DialogDescription>Chọn một vai trò (dùng phiên đang chạy hoặc mở phiên mới) hoặc một phiên cụ thể.</DialogDescription>
</DialogHeader>
<Button
type="button"
variant="outline"
size="sm"
className="justify-self-start"
onClick={() => {
setSubject(RD_DUTY.subject);
setDetails(RD_DUTY.details);
setTarget("role:rd");
setWhen(`m:${RD_DUTY.schedule.n}`);
}}
>
Mẫu: R&amp;D trực (mỗi 30 phút)
</Button>
<div className="grid gap-1.5">
<Label htmlFor="assign-subject">Việc cần làm</Label>
<Input id="assign-subject" value={subject} onChange={(e) => setSubject(e.target.value)} maxLength={300} required autoFocus />
Expand Down Expand Up @@ -153,6 +181,34 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="assign-when">Khi nào</Label>
<div className="flex gap-2">
<Select value={when} onValueChange={setWhen}>
<SelectTrigger id="assign-when" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="now">Ngay bây giờ</SelectItem>
<SelectItem value="once">Một lần, lúc…</SelectItem>
<SelectItem value="daily">Hằng ngày, lúc…</SelectItem>
{WHEN_EVERY.map((w) => (
<SelectItem key={w} value={w}>
Lặp mỗi {w.slice(2)} {w[0] === "m" ? "phút" : "giờ"}
</SelectItem>
))}
</SelectContent>
</Select>
{(when === "once" || when === "daily") && (
<Input aria-label="Giờ (giờ máy)" type="time" value={at} onChange={(e) => setAt(e.target.value)} required className="w-32" />
)}
</div>
{when !== "now" && (
<p className="text-xs text-muted-foreground">
Phiên tự lên lịch bằng CronCreate của Claude Code, theo giờ của máy chạy phiên: chạy giữa các lượt khi phiên còn mở, lịch lặp trễ tối đa nửa chu kỳ và tự hết hạn sau 7 ngày, giờ đã qua thì chạy ngày mai. Lượt bị bỏ khi phiên đang bận hoặc (login subscription) bạn vắng quá 30 phút.
</p>
)}
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
Expand Down
4 changes: 2 additions & 2 deletions apps/hq-ui/components/hq/guide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export const GUIDE_STEPS: GuideStep[] = [
},
{
icon: MessagesSquareIcon,
title: "Chat nhóm",
body: "Xem ai nhắn ai theo thời gian. Khi một người chuyển việc cho người khác, bạn sẽ thấy thẻ bàn giao (handoff) \"A → B\".",
title: "Chat tổng",
body: "Mọi phòng (session) chung một cuộc chat: xem ai nhắn ai theo thời gian. Khi một người chuyển việc cho người khác, bạn sẽ thấy thẻ bàn giao (handoff) \"A → B\".",
},
{
icon: BellRingIcon,
Expand Down
6 changes: 3 additions & 3 deletions apps/hq-ui/components/hq/hq-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function HqApp({ token }: { token: string }) {
}, []);

// Everything below the header is filtered to the selected project or session.
// `group` ignores the session scope on purpose: Chat nhóm is every room talking.
// `group` ignores the session scope on purpose: Chat tổng is every room talking.
const scopeOf = useCallback(
(session: string | null) => {
const all = hq.sessions.data;
Expand Down Expand Up @@ -184,7 +184,7 @@ export function HqApp({ token }: { token: string }) {
</div>
<TabsList variant="line" className="mt-2 h-9 w-full max-w-full justify-start gap-4 overflow-x-auto px-4 [scrollbar-width:none] sm:px-6">
<TabsTrigger value="office" className="flex-none px-0">Văn phòng</TabsTrigger>
<TabsTrigger value="group" className="flex-none px-0">Chat nhóm</TabsTrigger>
<TabsTrigger value="group" className="flex-none px-0">Chat tổng</TabsTrigger>
<TabsTrigger value="room" className="flex-none px-0">Chat phòng</TabsTrigger>
<TabsTrigger value="board" className="flex-none px-0">
Board
Expand Down Expand Up @@ -288,7 +288,7 @@ function RoomPicker({ sessions, onPick }: { sessions: Session[]; onPick: (s: Ses
<div className="mx-auto w-full max-w-3xl space-y-3 px-4 py-6 sm:px-6">
<h2 className="text-sm font-semibold">Chọn phòng để chat</h2>
<p className="text-sm text-muted-foreground">
Chat phòng chỉ gồm tin của một session và team của nó. Tin giữa mọi phòng nằm ở Chat nhóm.
Chat phòng chỉ gồm tin của một session và team của nó. Tin giữa mọi phòng nằm ở Chat tổng.
</p>
{sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa có session nào đang chạy.</p>
Expand Down
78 changes: 78 additions & 0 deletions apps/hq-ui/lib/schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* "Giao việc theo lịch": HQ does not run its own timer. It hands the session one prompt asking it to
* schedule the task with Claude Code's native `CronCreate` (https://code.claude.com/docs/en/scheduled-tasks):
* the session's own scheduler fires it between turns, in local time, recurring tasks expire after 7 days
* (a bound Claude Code sets on purpose; HQ does not work around it), and the owner sees and cancels
* the job like any other (`CronList` / `CronDelete`, or Esc on a /loop).
*/
export type Schedule =
| { kind: "once"; at: string }
| { kind: "daily"; at: string }
| { kind: "every"; n: number; unit: "m" | "h" };

/** Steps that divide the hour / day evenly, so the cron step is exact (docs: other steps get rounded). */
export const EVERY_MINUTES = [5, 10, 15, 20, 30] as const;
export const EVERY_HOURS = [1, 2, 3, 4, 6, 8, 12] as const;

const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;

/** Validate `body.schedule` from the API: `null`/absent = deliver now. */
export function parseSchedule(v: unknown): { ok: true; value: Schedule | null } | { ok: false; error: string } {
if (v == null || v === "") return { ok: true, value: null };
if (typeof v !== "object") return { ok: false, error: "must be an object" };
const o = v as Record<string, unknown>;
if (o.kind === "once" || o.kind === "daily") {
if (typeof o.at !== "string" || !HHMM.test(o.at)) return { ok: false, error: "at must be HH:MM (24h)" };
return { ok: true, value: { kind: o.kind, at: o.at } };
}
if (o.kind === "every") {
const n = Number(o.n);
if (o.unit === "m" && (EVERY_MINUTES as readonly number[]).includes(n)) return { ok: true, value: { kind: "every", n, unit: "m" } };
if (o.unit === "h" && (EVERY_HOURS as readonly number[]).includes(n)) return { ok: true, value: { kind: "every", n, unit: "h" } };
return { ok: false, error: `every: n phút ∈ ${EVERY_MINUTES.join(",")} hoặc n giờ ∈ ${EVERY_HOURS.join(",")}` };
}
return { ok: false, error: "kind must be once, daily or every" };
}

/** 5-field cron in the session's local time, and whether it recurs. */
export function cronOf(s: Schedule): { cron: string; recurring: boolean } {
if (s.kind === "every") {
// Minute 7 for hourly steps: off the hour, so it does not stack with other :00 jobs.
return s.unit === "m" ? { cron: `*/${s.n} * * * *`, recurring: true } : { cron: `7 ${s.n === 1 ? "*" : `*/${s.n}`} * * *`, recurring: true };
}
const [h, m] = s.at.split(":").map(Number);
return { cron: `${m} ${h} * * *`, recurring: s.kind === "daily" };
}

/** Short Vietnamese label for the task list and toasts. */
export function scheduleLabel(s: Schedule): string {
if (s.kind === "once") return `lúc ${s.at}`;
if (s.kind === "daily") return `hằng ngày ${s.at}`;
// Recurring runs get jitter (docs: up to half the gap, ≤30 min late; only */5 is exact).
return `${s.n === 5 && s.unit === "m" ? "" : "≈"}mỗi ${s.n} ${s.unit === "m" ? "phút" : "giờ"}`;
}

/** Lets `8sync ccc hook wake` (UserPromptSubmit) skip a fire while the session is busy, at zero tokens. */
export const WAKE_MARK = "[8sync:wake]";

/** The prompt HQ delivers instead of the task itself. */
export function scheduledPrompt(task: string, s: Schedule): string {
const { cron, recurring } = cronOf(s);
return [
`Lên lịch, KHÔNG làm ngay: gọi CronCreate với cron "${cron}" (giờ máy local), recurring: ${recurring}, prompt là nguyên văn phần dưới dòng ---. Xong trả lời một dòng: job id + lần chạy kế (${scheduleLabel(s)}).`,
recurring ? "Lịch lặp tự hết hạn sau 7 ngày (giới hạn của Claude Code); không tự tạo lại, owner sẽ giao lại nếu cần." : "Một lần, tự xoá sau khi chạy.",
"Giữ nguyên dấu [8sync:wake] ở đầu prompt (hook của kit dùng nó để bỏ qua lượt khi phiên đang bận). Tới giờ mà việc không còn ý nghĩa: nói một dòng lý do rồi bỏ qua lượt đó.",
"---",
`${WAKE_MARK} ${task}`,
].join("\n");
}

/** Preset "R&D trực": the R&D room keeps researching when it has nothing assigned. */
export const RD_DUTY = {
subject: "R&D trực: không có việc thì nghiên cứu backlog",
details:
"Nếu phòng đang có việc dở: bỏ qua lượt này. Nếu rảnh: lấy item kế tiếp trong su-code/STATE.md Next / dòng `failure:` trong su-code/KNOWLEDGE.md chưa có ghi chú nghiên cứu, " +
"trả lời một câu hỏi của nó theo /deep-research bản gọn (≤5 lần search, nguồn primary), ghi su-code/research/<slug>.md và báo phòng liên quan qua SendMessage. " +
"Không còn item: nói 'rest: hết backlog' một dòng. Không sửa code sản phẩm.",
schedule: { kind: "every", n: 30, unit: "m" } as const satisfies Schedule,
};
Loading
Loading