@taicho-ai/framework 0.1.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/README.md +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** `taicho schedule <add|list|remove|run>` — manage durable schedules from the command line (no Ink).
|
|
2
|
+
* add/list/remove are pure store ops; `run` fires one schedule ONCE through the injected `fire` seam
|
|
3
|
+
* (the host wires it to the headless `executeRun` path). The parser is shared with the REPL's
|
|
4
|
+
* `/schedules` command (see `parseScheduleCommand` in scheduler.ts) so both surfaces agree. */
|
|
5
|
+
import type { Schedule } from "@taicho-ai/contracts/schedule";
|
|
6
|
+
import { parseScheduleCommand, describeTrigger, formatScheduleLine } from "./scheduler";
|
|
7
|
+
import { createSchedule, listSchedules, removeSchedule, readSchedule } from "../store/schedules";
|
|
8
|
+
|
|
9
|
+
export interface ScheduleCliDeps {
|
|
10
|
+
ws: string;
|
|
11
|
+
out?: (line: string) => void;
|
|
12
|
+
/** Fire one schedule once (only used by `run`). Undefined ⇒ `run` reports it can't fire here. */
|
|
13
|
+
fire?: (schedule: Schedule) => Promise<{ ok: boolean; runId?: string; outcome?: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Execute a `taicho schedule …` command. Returns `{ ok }` so the caller can pick an exit code. */
|
|
17
|
+
export async function runScheduleCli(deps: ScheduleCliDeps, tokens: string[]): Promise<{ ok: boolean }> {
|
|
18
|
+
const out = deps.out ?? ((l: string) => process.stdout.write(l + "\n"));
|
|
19
|
+
const cmd = parseScheduleCommand(tokens);
|
|
20
|
+
|
|
21
|
+
if (cmd.kind === "error") { out(`taicho: ${cmd.message}`); return { ok: false }; }
|
|
22
|
+
|
|
23
|
+
if (cmd.kind === "list") {
|
|
24
|
+
const all = listSchedules(deps.ws);
|
|
25
|
+
if (!all.length) { out(" (no schedules)"); return { ok: true }; }
|
|
26
|
+
all.forEach((s) => out(formatScheduleLine(s)));
|
|
27
|
+
return { ok: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (cmd.kind === "add") {
|
|
31
|
+
try {
|
|
32
|
+
const s = createSchedule(deps.ws, cmd.spec);
|
|
33
|
+
out(` ⏰ added schedule ${s.id} → ${s.agent}: ${s.goal} (${describeTrigger(s.trigger)}, approvals: ${s.approve})`);
|
|
34
|
+
return { ok: true };
|
|
35
|
+
} catch (e) {
|
|
36
|
+
out(`taicho: could not add schedule — ${e instanceof Error ? e.message : String(e)}`);
|
|
37
|
+
return { ok: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (cmd.kind === "remove") {
|
|
42
|
+
const ok = removeSchedule(deps.ws, cmd.id);
|
|
43
|
+
out(ok ? ` removed schedule ${cmd.id}` : `taicho: no schedule "${cmd.id}"`);
|
|
44
|
+
return { ok };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// run — fire once through the headless path.
|
|
48
|
+
const s = readSchedule(deps.ws, cmd.id);
|
|
49
|
+
if (!s) { out(`taicho: no schedule "${cmd.id}"`); return { ok: false }; }
|
|
50
|
+
if (!deps.fire) { out("taicho: cannot fire a schedule here (no model wired)"); return { ok: false }; }
|
|
51
|
+
out(` ⏰ firing schedule ${s.id} → ${s.agent}: ${s.goal} (approvals: ${s.approve})`);
|
|
52
|
+
const res = await deps.fire(s);
|
|
53
|
+
out(` ${res.ok ? "✓" : "⚠"} schedule ${s.id} ${res.outcome ?? (res.ok ? "done" : "failed")}${res.runId ? ` — run ${res.runId}` : ""}`);
|
|
54
|
+
return { ok: res.ok };
|
|
55
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/** Plan 04 Phase 6 — the schedule/trigger engine. This module is PURE (no fs, no wall-clock of its
|
|
2
|
+
* own): the clock, the file-stat, and the "fire" action are all INJECTED, so "is it time to fire"
|
|
3
|
+
* and "fire → run" are deterministic and unit-testable without real timers.
|
|
4
|
+
*
|
|
5
|
+
* Two pieces live here:
|
|
6
|
+
* - cron evaluation (a 5-field subset, UTC) + `nextCronAfter` — pure functions.
|
|
7
|
+
* - `SchedulerRunner` — holds armed schedules and, on each `tick(now)`, fires the ones that are due
|
|
8
|
+
* via the injected `fire` seam (which the host wires to the headless `executeRun` path). It never
|
|
9
|
+
* re-fires a schedule while its previous run is still in flight (bounds concurrency to 1/schedule).
|
|
10
|
+
* - `parseScheduleCommand` / `parseDuration` / formatters — the shared `/schedules` + `taicho
|
|
11
|
+
* schedule` grammar, kept here so both the REPL (slash) and the CLI use one parser. */
|
|
12
|
+
import { Schedule, Trigger, ScheduleApprove, type ScheduleSpec } from "@taicho-ai/contracts/schedule";
|
|
13
|
+
|
|
14
|
+
// ── cron (5-field, UTC): minute hour day-of-month month day-of-week ────────────────────────────────
|
|
15
|
+
|
|
16
|
+
interface CronRange { min: number; max: number }
|
|
17
|
+
const FIELD_RANGES: CronRange[] = [
|
|
18
|
+
{ min: 0, max: 59 }, // minute
|
|
19
|
+
{ min: 0, max: 23 }, // hour
|
|
20
|
+
{ min: 1, max: 31 }, // day of month
|
|
21
|
+
{ min: 1, max: 12 }, // month
|
|
22
|
+
{ min: 0, max: 6 }, // day of week (0 = Sunday)
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Expand one cron field into the set of matching values. Supports `*` (all), `a-b` (range), `a,b,c`
|
|
26
|
+
* (list), and a `/n` step suffix (e.g. a star-slash-15 for "every 15"). Throws on a malformed field
|
|
27
|
+
* or an out-of-range value so a bad expression is rejected at creation, not at fire. */
|
|
28
|
+
export function parseCronField(field: string, range: CronRange): Set<number> {
|
|
29
|
+
const out = new Set<number>();
|
|
30
|
+
for (const part of field.split(",")) {
|
|
31
|
+
if (!part) throw new Error(`bad cron field "${field}"`);
|
|
32
|
+
let step = 1;
|
|
33
|
+
let spec = part;
|
|
34
|
+
const slash = part.indexOf("/");
|
|
35
|
+
if (slash >= 0) {
|
|
36
|
+
step = Number(part.slice(slash + 1));
|
|
37
|
+
spec = part.slice(0, slash);
|
|
38
|
+
if (!Number.isInteger(step) || step < 1) throw new Error(`bad cron step in "${field}"`);
|
|
39
|
+
}
|
|
40
|
+
let lo: number, hi: number;
|
|
41
|
+
if (spec === "*") { lo = range.min; hi = range.max; }
|
|
42
|
+
else if (spec.includes("-")) {
|
|
43
|
+
const [a, b] = spec.split("-");
|
|
44
|
+
lo = Number(a); hi = Number(b);
|
|
45
|
+
} else {
|
|
46
|
+
lo = hi = Number(spec);
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo > hi || lo < range.min || hi > range.max)
|
|
49
|
+
throw new Error(`bad cron field "${field}" (expected ${range.min}-${range.max})`);
|
|
50
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ParsedCron { minute: Set<number>; hour: Set<number>; dom: Set<number>; month: Set<number>; dow: Set<number>; domStar: boolean; dowStar: boolean; }
|
|
56
|
+
|
|
57
|
+
/** Parse + validate a 5-field cron expression. Throws with an actionable message on anything invalid. */
|
|
58
|
+
export function parseCron(expr: string): ParsedCron {
|
|
59
|
+
const fields = expr.trim().split(/\s+/);
|
|
60
|
+
if (fields.length !== 5) throw new Error(`cron needs 5 fields (min hour dom month dow), got ${fields.length}: "${expr}"`);
|
|
61
|
+
const [mn, hr, dm, mo, dw] = fields;
|
|
62
|
+
return {
|
|
63
|
+
minute: parseCronField(mn!, FIELD_RANGES[0]!),
|
|
64
|
+
hour: parseCronField(hr!, FIELD_RANGES[1]!),
|
|
65
|
+
dom: parseCronField(dm!, FIELD_RANGES[2]!),
|
|
66
|
+
month: parseCronField(mo!, FIELD_RANGES[3]!),
|
|
67
|
+
dow: parseCronField(dw!, FIELD_RANGES[4]!),
|
|
68
|
+
domStar: dm === "*",
|
|
69
|
+
dowStar: dw === "*",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Does `date` (its UTC components) match `expr`? Day-of-month and day-of-week follow the standard
|
|
74
|
+
* Vixie-cron rule: when BOTH are restricted, the day matches if EITHER matches; when one is `*`,
|
|
75
|
+
* only the restricted one gates. */
|
|
76
|
+
export function cronMatches(expr: string, date: Date): boolean {
|
|
77
|
+
const c = parseCron(expr);
|
|
78
|
+
const minMatch = c.minute.has(date.getUTCMinutes());
|
|
79
|
+
const hourMatch = c.hour.has(date.getUTCHours());
|
|
80
|
+
const monthMatch = c.month.has(date.getUTCMonth() + 1);
|
|
81
|
+
const domMatch = c.dom.has(date.getUTCDate());
|
|
82
|
+
const dowMatch = c.dow.has(date.getUTCDay());
|
|
83
|
+
const dayMatch = c.domStar && c.dowStar ? true : c.domStar ? dowMatch : c.dowStar ? domMatch : domMatch || dowMatch;
|
|
84
|
+
return minMatch && hourMatch && monthMatch && dayMatch;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MINUTE_MS = 60_000;
|
|
88
|
+
const CRON_SCAN_LIMIT_MINUTES = 366 * 24 * 60; // a year — a valid expr matches well within this
|
|
89
|
+
|
|
90
|
+
/** The next epoch-ms STRICTLY AFTER `afterMs` at which `expr` matches (scanned minute-by-minute, at
|
|
91
|
+
* second 0). Throws if no match within a year (e.g. an impossible date like Feb 30) — surfaced at
|
|
92
|
+
* create time so an unfireable schedule is rejected, never silently dead. */
|
|
93
|
+
export function nextCronAfter(expr: string, afterMs: number): number {
|
|
94
|
+
parseCron(expr); // validate once up front (throws on a bad expr)
|
|
95
|
+
// Start at the top of the minute after `afterMs` so we never re-fire the same minute we're in.
|
|
96
|
+
let t = Math.floor(afterMs / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
|
|
97
|
+
for (let i = 0; i < CRON_SCAN_LIMIT_MINUTES; i++) {
|
|
98
|
+
if (cronMatches(expr, new Date(t))) return t;
|
|
99
|
+
t += MINUTE_MS;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`cron "${expr}" has no match within a year — is the date impossible?`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Validate a trigger (a cron expr must parse + have a reachable next fire). Throws on invalid. */
|
|
105
|
+
export function validateTrigger(trigger: Trigger, nowMs: number): void {
|
|
106
|
+
if (trigger.kind === "cron") nextCronAfter(trigger.expr, nowMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── SchedulerRunner — tick-driven, injected clock / stat / fire ─────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export interface SchedulerRunnerDeps {
|
|
112
|
+
/** The clock. `Date.now` in production; a counter in tests. */
|
|
113
|
+
now: () => number;
|
|
114
|
+
/** Current mtime (ms) of a watched path, or null if it doesn't exist. Required for `watch` triggers. */
|
|
115
|
+
statMtimeMs?: (path: string) => number | null;
|
|
116
|
+
/** Fire the run for a due schedule. The host wires this to the headless `executeRun` path. May be
|
|
117
|
+
* async; the runner tracks it as in-flight and won't re-fire the same schedule until it settles. */
|
|
118
|
+
fire: (schedule: Schedule) => void | Promise<unknown>;
|
|
119
|
+
/** Persist the scheduling state the runner advances on each fire (lastRunAt/nextDueAt/runCount/
|
|
120
|
+
* lastMtimeMs) so cadence survives a restart. A field-level PATCH — never clobbers lastRunId etc. */
|
|
121
|
+
persist?: (id: string, patch: Partial<Schedule>) => void;
|
|
122
|
+
log?: (msg: string) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Armed { schedule: Schedule; nextDueMs?: number; lastMtimeMs?: number; inFlight: boolean; }
|
|
126
|
+
|
|
127
|
+
export class SchedulerRunner {
|
|
128
|
+
private armed = new Map<string, Armed>();
|
|
129
|
+
constructor(private readonly deps: SchedulerRunnerDeps) {}
|
|
130
|
+
|
|
131
|
+
/** Arm a schedule: seed its next-due time (cron/interval) or its baseline mtime (watch) from the
|
|
132
|
+
* clock NOW. Re-arming an existing id replaces it (fresh cadence). A disabled schedule is still
|
|
133
|
+
* held (so it can be re-enabled) but never fires. */
|
|
134
|
+
add(schedule: Schedule): void {
|
|
135
|
+
const now = this.deps.now();
|
|
136
|
+
const entry: Armed = { schedule, inFlight: false };
|
|
137
|
+
try {
|
|
138
|
+
if (schedule.trigger.kind === "interval") {
|
|
139
|
+
// Resume from the PERSISTED next-due so a nightly REPL restart / hot-reload doesn't reset the
|
|
140
|
+
// cadence — otherwise a long `--every 24h` under nightly restarts would never fire, and
|
|
141
|
+
// `/schedules list` would show a stale `next:`. A next-due already in the PAST (a window missed
|
|
142
|
+
// while the REPL was down) collapses to a SINGLE catch-up fire on the next tick via
|
|
143
|
+
// `max(persisted, now)` — never a boot storm. No persisted value yet (a fresh schedule this
|
|
144
|
+
// session) → the usual `now + everyMs`.
|
|
145
|
+
const persisted = schedule.nextDueAt ? Date.parse(schedule.nextDueAt) : NaN;
|
|
146
|
+
entry.nextDueMs = Number.isNaN(persisted) ? now + schedule.trigger.everyMs : Math.max(persisted, now);
|
|
147
|
+
}
|
|
148
|
+
else if (schedule.trigger.kind === "cron") entry.nextDueMs = nextCronAfter(schedule.trigger.expr, now);
|
|
149
|
+
else entry.lastMtimeMs = this.deps.statMtimeMs?.(schedule.trigger.path) ?? undefined; // watch baseline
|
|
150
|
+
} catch (e) {
|
|
151
|
+
this.deps.log?.(`schedule ${schedule.id} not armed: ${e instanceof Error ? e.message : String(e)}`);
|
|
152
|
+
}
|
|
153
|
+
this.armed.set(schedule.id, entry);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
remove(id: string): boolean {
|
|
157
|
+
return this.armed.delete(id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
has(id: string): boolean {
|
|
161
|
+
return this.armed.has(id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
count(): number {
|
|
165
|
+
return this.armed.size;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Evaluate every armed schedule against the injected clock and fire the ones that are due. Called
|
|
169
|
+
* by the host on a real interval (production) or by a test with explicit clock values. Firing is
|
|
170
|
+
* idempotent within a tick — a schedule already in flight is skipped. */
|
|
171
|
+
tick(): void {
|
|
172
|
+
const now = this.deps.now();
|
|
173
|
+
for (const entry of this.armed.values()) {
|
|
174
|
+
if (!entry.schedule.enabled || entry.inFlight) continue;
|
|
175
|
+
// Per-entry isolation: one schedule's failure must NEVER unwind the whole loop and starve its
|
|
176
|
+
// siblings. The classic poison is a cron whose next match is beyond the year-long scan window
|
|
177
|
+
// (e.g. Feb-29 `0 0 29 2 *`): it passes create-time validation but `nextCronAfter` THROWS at
|
|
178
|
+
// fire time. Without this try/catch that throw escapes tick(), the host's tick wrapper swallows
|
|
179
|
+
// it, and every schedule after the poison is silently never evaluated again. Catch here, disable
|
|
180
|
+
// the offender (so it can't re-throw every tick with a stale nextDueMs), and log it.
|
|
181
|
+
try {
|
|
182
|
+
const trigger = entry.schedule.trigger;
|
|
183
|
+
if (trigger.kind === "watch") {
|
|
184
|
+
const m = this.deps.statMtimeMs?.(trigger.path) ?? null;
|
|
185
|
+
if (m == null || m === entry.lastMtimeMs) { entry.lastMtimeMs = m ?? entry.lastMtimeMs; continue; }
|
|
186
|
+
entry.lastMtimeMs = m;
|
|
187
|
+
this.fireEntry(entry, now, { lastMtimeMs: m });
|
|
188
|
+
} else {
|
|
189
|
+
if (entry.nextDueMs == null || now < entry.nextDueMs) continue;
|
|
190
|
+
const nextDueMs = trigger.kind === "interval" ? now + trigger.everyMs : nextCronAfter(trigger.expr, now);
|
|
191
|
+
entry.nextDueMs = nextDueMs;
|
|
192
|
+
this.fireEntry(entry, now, { nextDueAt: new Date(nextDueMs).toISOString() });
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
this.disableEntry(entry, e);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Disable + flag a schedule whose evaluation threw (an unfireable cron surfaced at fire time), so it
|
|
201
|
+
* can't re-throw every tick or starve its siblings. Persisted (so the disable survives a restart)
|
|
202
|
+
* and logged — a dead schedule is surfaced, never silently dropped. */
|
|
203
|
+
private disableEntry(entry: Armed, e: unknown): void {
|
|
204
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
205
|
+
entry.schedule = { ...entry.schedule, enabled: false, lastStatus: "error" };
|
|
206
|
+
this.deps.persist?.(entry.schedule.id, { enabled: false, lastStatus: "error" });
|
|
207
|
+
this.deps.log?.(`schedule ${entry.schedule.id} disabled — ${reason}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Force-fire an armed schedule now (the `/schedules run` / `taicho schedule run` path), regardless
|
|
211
|
+
* of whether it's due. No-op (returns false) if the id isn't armed or is already in flight. */
|
|
212
|
+
fireNow(id: string): boolean {
|
|
213
|
+
const entry = this.armed.get(id);
|
|
214
|
+
if (!entry || entry.inFlight) return false;
|
|
215
|
+
this.fireEntry(entry, this.deps.now(), {});
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Common fire path: advance the schedule's scheduling state, persist it, mark in-flight, and invoke
|
|
220
|
+
* the injected `fire`. Clears in-flight when the fire settles so the next due can fire. */
|
|
221
|
+
private fireEntry(entry: Armed, now: number, extraPatch: Partial<Schedule>): void {
|
|
222
|
+
entry.inFlight = true;
|
|
223
|
+
const nowIso = new Date(now).toISOString();
|
|
224
|
+
entry.schedule = { ...entry.schedule, lastRunAt: nowIso, runCount: entry.schedule.runCount + 1, ...extraPatch };
|
|
225
|
+
this.deps.persist?.(entry.schedule.id, { lastRunAt: nowIso, runCount: entry.schedule.runCount, ...extraPatch });
|
|
226
|
+
Promise.resolve()
|
|
227
|
+
.then(() => this.deps.fire(entry.schedule))
|
|
228
|
+
.catch((e) => this.deps.log?.(`schedule ${entry.schedule.id} fire failed: ${e instanceof Error ? e.message : String(e)}`))
|
|
229
|
+
.finally(() => {
|
|
230
|
+
entry.inFlight = false;
|
|
231
|
+
// Watch self-trigger guard: re-baseline the mtime AFTER the run settles, so writes the run
|
|
232
|
+
// ITSELF made to the watched path don't re-fire it. A `--watch <path>` on a path the run writes
|
|
233
|
+
// would otherwise self-sustain ~1 run/tick forever. Trade-off: a change that lands DURING the
|
|
234
|
+
// run is folded into the new baseline (treated as already-seen) — acceptable for the
|
|
235
|
+
// watch-then-process use case, where the run has just consumed the path's current state.
|
|
236
|
+
if (entry.schedule.trigger.kind === "watch") {
|
|
237
|
+
const m = this.deps.statMtimeMs?.(entry.schedule.trigger.path) ?? null;
|
|
238
|
+
if (m != null && m !== entry.lastMtimeMs) {
|
|
239
|
+
entry.lastMtimeMs = m;
|
|
240
|
+
this.deps.persist?.(entry.schedule.id, { lastMtimeMs: m });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── shared `/schedules` + `taicho schedule` grammar (pure) ─────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** Parse a human duration (`500ms`, `30s`, `10m`, `2h`, `1d`, or a bare integer = ms) into ms.
|
|
250
|
+
* Returns null on anything unparseable. */
|
|
251
|
+
export function parseDuration(s: string): number | null {
|
|
252
|
+
const m = /^(\d+)(ms|s|m|h|d)?$/.exec(s.trim());
|
|
253
|
+
if (!m) return null;
|
|
254
|
+
const n = Number(m[1]);
|
|
255
|
+
switch (m[2]) {
|
|
256
|
+
case "s": return n * 1000;
|
|
257
|
+
case "m": return n * 60_000;
|
|
258
|
+
case "h": return n * 3_600_000;
|
|
259
|
+
case "d": return n * 86_400_000;
|
|
260
|
+
default: return n; // "ms" or no suffix
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type ScheduleCommand =
|
|
265
|
+
| { kind: "list" }
|
|
266
|
+
| { kind: "add"; spec: ScheduleSpec }
|
|
267
|
+
| { kind: "remove"; id: string }
|
|
268
|
+
| { kind: "run"; id: string }
|
|
269
|
+
| { kind: "error"; message: string };
|
|
270
|
+
|
|
271
|
+
const ADD_USAGE =
|
|
272
|
+
'usage: /schedules add <goal…> (--every <30m|1h> | --cron "<m h dom mon dow>" | --watch <path>) [--agent <id>] [--approve reject|approve] [--id <name>]';
|
|
273
|
+
|
|
274
|
+
/** Parse pre-tokenized args (the CLI passes argv; the REPL passes a quote-aware tokenization) into a
|
|
275
|
+
* schedule command. The goal is every token before the first `--flag`; exactly one trigger flag is
|
|
276
|
+
* required. Shared by the slash command and the `taicho schedule` subcommand. */
|
|
277
|
+
export function parseScheduleCommand(tokens: string[]): ScheduleCommand {
|
|
278
|
+
const sub = (tokens[0] ?? "list").toLowerCase();
|
|
279
|
+
if (sub === "list" || sub === "ls") return { kind: "list" };
|
|
280
|
+
if (sub === "remove" || sub === "rm") {
|
|
281
|
+
const id = tokens[1];
|
|
282
|
+
return id ? { kind: "remove", id } : { kind: "error", message: "usage: /schedules remove <id>" };
|
|
283
|
+
}
|
|
284
|
+
if (sub === "run") {
|
|
285
|
+
const id = tokens[1];
|
|
286
|
+
return id ? { kind: "run", id } : { kind: "error", message: "usage: /schedules run <id>" };
|
|
287
|
+
}
|
|
288
|
+
if (sub !== "add") return { kind: "error", message: `unknown /schedules subcommand "${sub}" (try list, add, remove, run)` };
|
|
289
|
+
|
|
290
|
+
const rest = tokens.slice(1);
|
|
291
|
+
const goalParts: string[] = [];
|
|
292
|
+
let agent: string | undefined;
|
|
293
|
+
let approveRaw: string | undefined;
|
|
294
|
+
let id: string | undefined;
|
|
295
|
+
let every: string | undefined;
|
|
296
|
+
let cron: string | undefined;
|
|
297
|
+
let watch: string | undefined;
|
|
298
|
+
let seenFlag = false;
|
|
299
|
+
for (let i = 0; i < rest.length; i++) {
|
|
300
|
+
const t = rest[i]!;
|
|
301
|
+
const take = () => rest[++i];
|
|
302
|
+
if (t === "--agent" || t === "-a") { agent = take(); seenFlag = true; }
|
|
303
|
+
else if (t === "--approve") { approveRaw = take(); seenFlag = true; }
|
|
304
|
+
else if (t === "--id") { id = take(); seenFlag = true; }
|
|
305
|
+
else if (t === "--every") { every = take(); seenFlag = true; }
|
|
306
|
+
else if (t === "--cron") { cron = take(); seenFlag = true; }
|
|
307
|
+
else if (t === "--watch") { watch = take(); seenFlag = true; }
|
|
308
|
+
else if (t.startsWith("--")) return { kind: "error", message: `unknown flag "${t}". ${ADD_USAGE}` };
|
|
309
|
+
else if (!seenFlag) goalParts.push(t); // goal tokens only before the first flag
|
|
310
|
+
else return { kind: "error", message: `unexpected argument "${t}" after flags. ${ADD_USAGE}` };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const goal = goalParts.join(" ").trim();
|
|
314
|
+
if (!goal) return { kind: "error", message: `a goal is required. ${ADD_USAGE}` };
|
|
315
|
+
|
|
316
|
+
const triggerCount = [every, cron, watch].filter((x) => x != null).length;
|
|
317
|
+
if (triggerCount !== 1) return { kind: "error", message: `exactly one of --every / --cron / --watch is required. ${ADD_USAGE}` };
|
|
318
|
+
|
|
319
|
+
let trigger: Trigger;
|
|
320
|
+
if (every != null) {
|
|
321
|
+
const ms = parseDuration(every);
|
|
322
|
+
if (ms == null || ms <= 0) return { kind: "error", message: `bad --every "${every}" (try 30m, 1h, 500ms)` };
|
|
323
|
+
trigger = { kind: "interval", everyMs: ms };
|
|
324
|
+
} else if (cron != null) {
|
|
325
|
+
const parsed = Trigger.safeParse({ kind: "cron", expr: cron });
|
|
326
|
+
if (!parsed.success) return { kind: "error", message: `bad --cron "${cron}"` };
|
|
327
|
+
trigger = parsed.data;
|
|
328
|
+
} else {
|
|
329
|
+
trigger = { kind: "watch", path: watch! };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let approve: ScheduleApprove | undefined;
|
|
333
|
+
if (approveRaw != null) {
|
|
334
|
+
const p = ScheduleApprove.safeParse(approveRaw);
|
|
335
|
+
if (!p.success) return { kind: "error", message: `--approve must be reject or approve (got "${approveRaw}")` };
|
|
336
|
+
approve = p.data;
|
|
337
|
+
}
|
|
338
|
+
return { kind: "add", spec: { goal, agent, trigger, approve, id } };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function describeTrigger(t: Trigger): string {
|
|
342
|
+
switch (t.kind) {
|
|
343
|
+
case "cron": return `cron "${t.expr}" (UTC)`;
|
|
344
|
+
case "interval": return `every ${t.everyMs}ms`;
|
|
345
|
+
case "watch": return `watch ${t.path}`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** One-line render of a schedule for `/schedules list` and `taicho schedule list`. */
|
|
350
|
+
export function formatScheduleLine(s: Schedule): string {
|
|
351
|
+
const state = s.enabled ? "on" : "off";
|
|
352
|
+
const last = s.lastStatus ? ` · last:${s.lastStatus}` : "";
|
|
353
|
+
const runs = s.runCount ? ` · runs:${s.runCount}` : "";
|
|
354
|
+
const next = s.nextDueAt ? ` · next:${s.nextDueAt}` : "";
|
|
355
|
+
return ` [${s.id}] (${state}) ${s.agent}: ${s.goal} · ${describeTrigger(s.trigger)} · approvals:${s.approve}${runs}${last}${next}`;
|
|
356
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Plan 04 — the background task SCHEDULER: a per-agent concurrency semaphore over detached runs.
|
|
2
|
+
*
|
|
3
|
+
* The engine/UI owns *how* a run starts (build deps, executeRun, settle+notify); this class owns
|
|
4
|
+
* *when* — it holds a persistent queue and enforces `maxConcurrentRuns` per agent (config disposes
|
|
5
|
+
* the concurrency the model proposes via dispatch_task). A task submitted over its agent's cap sits
|
|
6
|
+
* `queued` until a running slot frees; when one settles, the queue pumps the next. Single-threaded
|
|
7
|
+
* Bun means every check-then-act here runs to completion between awaits — no lock needed. */
|
|
8
|
+
|
|
9
|
+
export interface QueuedTask {
|
|
10
|
+
taskId: string;
|
|
11
|
+
agentId: string;
|
|
12
|
+
/** Max concurrent RUNNING tasks allowed for this task's agent (from budgets.maxConcurrentRuns).
|
|
13
|
+
* Undefined ⇒ unbounded. Carried on the task (not looked up) so pump() stays synchronous. */
|
|
14
|
+
cap?: number;
|
|
15
|
+
/** Start the detached run. Returns the controller (for cancel) + a promise that settles when the
|
|
16
|
+
* run (and the caller's settle/notify chain) is fully done. The scheduler only does bookkeeping
|
|
17
|
+
* around it — status writes + notifications live in the `start` closure the caller supplies. */
|
|
18
|
+
start: () => { controller: AbortController; promise: Promise<unknown> };
|
|
19
|
+
/** Invoked if the task is cancelled while still queued (never started). */
|
|
20
|
+
onCancelQueued?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RunningHandle { controller: AbortController; promise: Promise<unknown>; agentId: string; }
|
|
24
|
+
|
|
25
|
+
export class TaskScheduler {
|
|
26
|
+
private running = new Map<string, RunningHandle>();
|
|
27
|
+
private queue: QueuedTask[] = [];
|
|
28
|
+
/** Global background-run ceiling: a hard bound on total in-flight (running) + queued tasks across
|
|
29
|
+
* ALL agents. The per-agent `cap` bounds one agent's fan-out; this bounds the whole system so a
|
|
30
|
+
* model-initiated dispatch chain (which resets per-request budgets on each hop) can't run away.
|
|
31
|
+
* Infinity ⇒ unbounded. Config disposes the number the model proposes via dispatch_task. */
|
|
32
|
+
private readonly globalCap: number;
|
|
33
|
+
|
|
34
|
+
constructor(opts: { globalCap?: number } = {}) {
|
|
35
|
+
this.globalCap = opts.globalCap ?? Infinity;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Total tasks this scheduler is currently accountable for: running + queued. */
|
|
39
|
+
inFlight(): number {
|
|
40
|
+
return this.running.size + this.queue.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The configured global ceiling (Infinity ⇒ unbounded). */
|
|
44
|
+
get ceiling(): number {
|
|
45
|
+
return this.globalCap;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True when a new submit would meet or exceed the global ceiling — the dispatch pre-check refuses
|
|
49
|
+
* over-ceiling so we never create an orphan task record for a task the scheduler can't accept. */
|
|
50
|
+
atCapacity(): boolean {
|
|
51
|
+
return this.inFlight() >= this.globalCap;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Enqueue a task and pump: it starts immediately if the agent is under its cap, else stays queued. */
|
|
55
|
+
submit(task: QueuedTask): void {
|
|
56
|
+
this.queue.push(task);
|
|
57
|
+
this.pump();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private runningForAgent(agentId: string): number {
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const h of this.running.values()) if (h.agentId === agentId) n++;
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Start every queued task whose agent is under its concurrency cap, in FIFO order. Idempotent. */
|
|
67
|
+
private pump(): void {
|
|
68
|
+
const stillQueued: QueuedTask[] = [];
|
|
69
|
+
for (const task of this.queue) {
|
|
70
|
+
if (task.cap != null && this.runningForAgent(task.agentId) >= task.cap) { stillQueued.push(task); continue; }
|
|
71
|
+
const { controller, promise } = task.start();
|
|
72
|
+
this.running.set(task.taskId, { controller, promise, agentId: task.agentId });
|
|
73
|
+
// On settle: free the slot and pump again so a queued task for this agent can start.
|
|
74
|
+
void promise.finally(() => { this.running.delete(task.taskId); this.pump(); });
|
|
75
|
+
}
|
|
76
|
+
this.queue = stillQueued;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Is this task currently running (not queued, not settled)? */
|
|
80
|
+
isRunning(taskId: string): boolean {
|
|
81
|
+
return this.running.has(taskId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** How many tasks are running for an agent right now (used by the dispatch pre-check for a note). */
|
|
85
|
+
runningCount(agentId: string): number {
|
|
86
|
+
return this.runningForAgent(agentId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Await a running task's completion promise (undefined if it isn't running — already settled or
|
|
90
|
+
* queued). `await_task` uses this for a running task and polls the record otherwise. */
|
|
91
|
+
awaitRunning(taskId: string): Promise<unknown> | undefined {
|
|
92
|
+
return this.running.get(taskId)?.promise;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Cancel a task. Running ⇒ abort its signal (the loop stops next iteration). Queued ⇒ drop it and
|
|
96
|
+
* fire onCancelQueued. Returns whether anything was cancelled. */
|
|
97
|
+
cancel(taskId: string): boolean {
|
|
98
|
+
const running = this.running.get(taskId);
|
|
99
|
+
if (running) { running.controller.abort(); return true; }
|
|
100
|
+
const idx = this.queue.findIndex((t) => t.taskId === taskId);
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
const [task] = this.queue.splice(idx, 1);
|
|
103
|
+
task?.onCancelQueued?.();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** `taicho team <list|add|remove|member>` — manage teams from the command line (no Ink, no model).
|
|
2
|
+
* A captain-owned front door onto the same service layer the wizard and the approval-gated tool use.
|
|
3
|
+
* It opens the DB and reindexes first so membership queries see the current agent.md files.
|
|
4
|
+
*
|
|
5
|
+
* list
|
|
6
|
+
* add <id> [--charter "…"] [--lead <agent>] [--member <agent>]…
|
|
7
|
+
* remove <id>
|
|
8
|
+
* member add|remove <agent> <team>
|
|
9
|
+
*
|
|
10
|
+
* The parser is pure and unit-tested (parseTeamCli); runTeamCli performs the store side effects. */
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import { openDb } from "../store/db";
|
|
13
|
+
import { reindex, loadIndex } from "../store/roster";
|
|
14
|
+
import {
|
|
15
|
+
listTeams, membersOf, teamExists, seedDefaultTeam,
|
|
16
|
+
createTeamWithMembers, deleteTeam, setTeamMembers,
|
|
17
|
+
} from "../store/teams";
|
|
18
|
+
import { DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
|
|
19
|
+
|
|
20
|
+
export type TeamCliCommand =
|
|
21
|
+
| { kind: "list" }
|
|
22
|
+
| { kind: "add"; id: string; charter: string; lead?: string; members: string[] }
|
|
23
|
+
| { kind: "remove"; id: string }
|
|
24
|
+
| { kind: "member"; op: "add" | "remove"; agent: string; team: string }
|
|
25
|
+
| { kind: "error"; message: string };
|
|
26
|
+
|
|
27
|
+
/** Parse the tokens after `team` into a command. Tokens are already shell-split (they arrive as argv). */
|
|
28
|
+
export function parseTeamCli(tokens: string[]): TeamCliCommand {
|
|
29
|
+
const sub = (tokens[0] ?? "list").toLowerCase();
|
|
30
|
+
if (sub === "list" || sub === "ls") return { kind: "list" };
|
|
31
|
+
|
|
32
|
+
if (sub === "add") {
|
|
33
|
+
const id = tokens[1];
|
|
34
|
+
if (!id) return { kind: "error", message: "usage: team add <id> [--charter \"…\"] [--lead <agent>] [--member <agent>]…" };
|
|
35
|
+
let charter = "";
|
|
36
|
+
let lead: string | undefined;
|
|
37
|
+
const members: string[] = [];
|
|
38
|
+
const rest = tokens.slice(2);
|
|
39
|
+
for (let i = 0; i < rest.length; i++) {
|
|
40
|
+
const t = rest[i];
|
|
41
|
+
if (t === "--charter") charter = rest[++i] ?? "";
|
|
42
|
+
else if (t === "--lead") lead = rest[++i];
|
|
43
|
+
else if (t === "--member") { const m = rest[++i]; if (m) members.push(m); }
|
|
44
|
+
else return { kind: "error", message: `unknown flag "${t}" (use --charter, --lead, --member)` };
|
|
45
|
+
}
|
|
46
|
+
return { kind: "add", id, charter: charter || id, lead, members };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (sub === "remove" || sub === "rm") {
|
|
50
|
+
const id = tokens[1];
|
|
51
|
+
if (!id) return { kind: "error", message: "usage: team remove <id>" };
|
|
52
|
+
return { kind: "remove", id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (sub === "member") {
|
|
56
|
+
const op = (tokens[1] ?? "").toLowerCase();
|
|
57
|
+
if (op !== "add" && op !== "remove") return { kind: "error", message: "usage: team member add|remove <agent> <team>" };
|
|
58
|
+
const agent = tokens[2];
|
|
59
|
+
const team = tokens[3];
|
|
60
|
+
if (!agent || !team) return { kind: "error", message: "usage: team member add|remove <agent> <team>" };
|
|
61
|
+
return { kind: "member", op, agent, team };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { kind: "error", message: `unknown team subcommand "${sub}" (try list, add, remove, member)` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TeamCliDeps { ws: string; out?: (line: string) => void; }
|
|
68
|
+
|
|
69
|
+
function listOut(ws: string, db: Database, out: (l: string) => void): void {
|
|
70
|
+
const teams = listTeams(ws);
|
|
71
|
+
if (!teams.length) { out(" (no teams)"); return; }
|
|
72
|
+
for (const t of teams) {
|
|
73
|
+
const members = membersOf(db, t.id);
|
|
74
|
+
const how = t.lead ? `lead: ${t.lead}` : "routed by capability";
|
|
75
|
+
out(` ${t.id}: ${t.charter} · ${how} · ${members.length} member${members.length === 1 ? "" : "s"}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Execute a `taicho team …` command against the workspace. Returns `{ ok }` for the exit code. */
|
|
80
|
+
export async function runTeamCli(deps: TeamCliDeps, tokens: string[]): Promise<{ ok: boolean }> {
|
|
81
|
+
const out = deps.out ?? ((l: string) => process.stdout.write(l + "\n"));
|
|
82
|
+
const cmd = parseTeamCli(tokens);
|
|
83
|
+
if (cmd.kind === "error") { out(`taicho: ${cmd.message}`); return { ok: false }; }
|
|
84
|
+
|
|
85
|
+
const db = openDb(deps.ws);
|
|
86
|
+
await reindex(deps.ws, db); // membership queries read the derived index; keep it fresh
|
|
87
|
+
seedDefaultTeam(deps.ws);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (cmd.kind === "list") { listOut(deps.ws, db, out); return { ok: true }; }
|
|
91
|
+
|
|
92
|
+
if (cmd.kind === "add") {
|
|
93
|
+
if (teamExists(deps.ws, cmd.id)) { out(`taicho: team "${cmd.id}" already exists`); return { ok: false }; }
|
|
94
|
+
const team = await createTeamWithMembers(deps.ws, db, { id: cmd.id, charter: cmd.charter, lead: cmd.lead }, cmd.members);
|
|
95
|
+
out(` ✓ created team ${team.id}${team.lead ? ` (lead: ${team.lead})` : ""} with ${cmd.members.length} member${cmd.members.length === 1 ? "" : "s"}`);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (cmd.kind === "remove") {
|
|
100
|
+
const detached = await deleteTeam(deps.ws, db, cmd.id);
|
|
101
|
+
out(` ✓ removed team ${cmd.id} — detached ${detached.length} member${detached.length === 1 ? "" : "s"}`);
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// member add|remove <agent> <team>
|
|
106
|
+
if (cmd.team === DEFAULT_TEAM_ID) { out("taicho: every agent is always a member of the default team"); return { ok: false }; }
|
|
107
|
+
if (!teamExists(deps.ws, cmd.team)) { out(`taicho: no team "${cmd.team}"`); return { ok: false }; }
|
|
108
|
+
if (cmd.op === "add" && !loadIndex(db).some((r) => r.id === cmd.agent)) { out(`taicho: no agent "${cmd.agent}"`); return { ok: false }; }
|
|
109
|
+
const current = new Set(membersOf(db, cmd.team).map((m) => m.id));
|
|
110
|
+
if (cmd.op === "add") current.add(cmd.agent); else current.delete(cmd.agent);
|
|
111
|
+
await setTeamMembers(deps.ws, db, cmd.team, [...current]);
|
|
112
|
+
out(` ✓ ${cmd.op === "add" ? "added" : "removed"} ${cmd.agent} ${cmd.op === "add" ? "to" : "from"} ${cmd.team}`);
|
|
113
|
+
return { ok: true };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
out(`taicho: ${e instanceof Error ? e.message : String(e)}`);
|
|
116
|
+
return { ok: false };
|
|
117
|
+
}
|
|
118
|
+
}
|