kibi-opencode 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +36 -12
  2. package/dist/brief-intent.d.ts +15 -4
  3. package/dist/brief-intent.js +63 -25
  4. package/dist/briefing-runtime.js +2 -1
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +9 -0
  7. package/dist/e2e-coverage-signals.d.ts +6 -0
  8. package/dist/e2e-coverage-signals.js +186 -0
  9. package/dist/file-entity-links.d.ts +15 -0
  10. package/dist/file-entity-links.js +254 -0
  11. package/dist/file-operation-reminders.d.ts +24 -0
  12. package/dist/file-operation-reminders.js +55 -0
  13. package/dist/file-operation-state.d.ts +29 -0
  14. package/dist/file-operation-state.js +113 -0
  15. package/dist/idle-brief-audit.d.ts +36 -0
  16. package/dist/idle-brief-audit.js +186 -0
  17. package/dist/idle-brief-paths.d.ts +6 -0
  18. package/dist/idle-brief-paths.js +120 -0
  19. package/dist/idle-brief-reader.d.ts +25 -0
  20. package/dist/idle-brief-reader.js +142 -0
  21. package/dist/idle-brief-runtime.d.ts +48 -0
  22. package/dist/idle-brief-runtime.js +443 -0
  23. package/dist/idle-brief-store.d.ts +96 -0
  24. package/dist/idle-brief-store.js +209 -0
  25. package/dist/index.d.ts +14 -1
  26. package/dist/index.js +626 -50
  27. package/dist/init-kibi-alias.d.ts +14 -0
  28. package/dist/init-kibi-alias.js +38 -0
  29. package/dist/init-kibi-capability.d.ts +32 -0
  30. package/dist/init-kibi-capability.js +202 -0
  31. package/dist/logger.js +9 -3
  32. package/dist/plugin-startup.d.ts +1 -0
  33. package/dist/plugin-startup.js +11 -2
  34. package/dist/prompt.d.ts +15 -3
  35. package/dist/prompt.js +103 -33
  36. package/dist/reconcile-engine.d.ts +15 -0
  37. package/dist/reconcile-engine.js +112 -0
  38. package/dist/scheduler.d.ts +1 -0
  39. package/dist/scheduler.js +37 -1
  40. package/dist/session-edit-state.d.ts +25 -0
  41. package/dist/session-edit-state.js +177 -0
  42. package/dist/session-fingerprint.d.ts +11 -0
  43. package/dist/session-fingerprint.js +21 -0
  44. package/dist/source-linked-guidance.d.ts +1 -2
  45. package/dist/source-linked-guidance.js +5 -168
  46. package/dist/startup-notifier.js +42 -31
  47. package/dist/toast.d.ts +21 -22
  48. package/dist/toast.js +36 -14
  49. package/dist/tui-brief-delivery.d.ts +47 -0
  50. package/dist/tui-brief-delivery.js +138 -0
  51. package/package.json +4 -3
@@ -0,0 +1,177 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ // ---------------------------------------------------------------------------
5
+ // Constants
6
+ // ---------------------------------------------------------------------------
7
+ const SENTINEL_HASH = "<deleted>";
8
+ // ---------------------------------------------------------------------------
9
+ // Implementation
10
+ // ---------------------------------------------------------------------------
11
+ export function createSessionEditState(opts) {
12
+ const worktree = opts.worktree;
13
+ const now = opts.now ?? Date.now;
14
+ // ---- Per-instance state (no module globals) ----
15
+ /**
16
+ * Tracked files keyed by relative path.
17
+ * Undefined baselineHash means we haven't taken a snapshot yet.
18
+ */
19
+ const tracked = new Map();
20
+ // ---- Internal helpers ----
21
+ function resolveToRelative(filePath) {
22
+ if (path.isAbsolute(filePath)) {
23
+ const rel = path.relative(worktree, filePath);
24
+ // Normalise away any leading ./ or ../ that escapes worktree
25
+ return rel.startsWith("..") ? filePath : rel;
26
+ }
27
+ return filePath;
28
+ }
29
+ function resolveToAbsolute(relPath) {
30
+ return path.join(worktree, relPath);
31
+ }
32
+ function hashContent(content) {
33
+ return crypto.createHash("sha256").update(content).digest("hex");
34
+ }
35
+ function hashFile(absPath) {
36
+ try {
37
+ const content = fs.readFileSync(absPath, "utf-8");
38
+ return hashContent(content);
39
+ }
40
+ catch {
41
+ return SENTINEL_HASH;
42
+ }
43
+ }
44
+ /**
45
+ * Take a baseline snapshot if we haven't yet.
46
+ * Returns the baseline hash.
47
+ */
48
+ function ensureBaseline(entry, relPath) {
49
+ if (entry.baselineHash !== undefined) {
50
+ return entry.baselineHash;
51
+ }
52
+ const abs = resolveToAbsolute(relPath);
53
+ const h = hashFile(abs);
54
+ entry.baselineHash = h;
55
+ return h;
56
+ }
57
+ // ---- Public API ----
58
+ function recordEventHint(filePath, kind, timestamp) {
59
+ const rel = resolveToRelative(filePath);
60
+ let entry = tracked.get(rel);
61
+ if (!entry) {
62
+ entry = {
63
+ baselineHash: undefined,
64
+ currentHash: undefined,
65
+ lastReconciledAt: 0,
66
+ eventHints: [],
67
+ };
68
+ tracked.set(rel, entry);
69
+ }
70
+ entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
71
+ }
72
+ function reconcilePath(filePath) {
73
+ const rel = resolveToRelative(filePath);
74
+ let entry = tracked.get(rel);
75
+ if (!entry) {
76
+ entry = {
77
+ baselineHash: undefined,
78
+ currentHash: undefined,
79
+ lastReconciledAt: 0,
80
+ eventHints: [],
81
+ };
82
+ tracked.set(rel, entry);
83
+ }
84
+ // Lazy baseline snapshot
85
+ ensureBaseline(entry, rel);
86
+ // Current hash
87
+ const abs = resolveToAbsolute(rel);
88
+ const current = hashFile(abs);
89
+ entry.currentHash = current;
90
+ entry.lastReconciledAt = now();
91
+ }
92
+ function reconcileKnownPaths() {
93
+ for (const relPath of tracked.keys()) {
94
+ reconcilePath(relPath);
95
+ }
96
+ }
97
+ /**
98
+ * Return surviving session edits: files whose current hash differs from baseline.
99
+ * Sorted by lastReconciledAt ascending (oldest first).
100
+ */
101
+ function getSessionEdits() {
102
+ const results = [];
103
+ for (const [relPath, entry] of tracked) {
104
+ if (entry.baselineHash === undefined || entry.currentHash === undefined) {
105
+ // Not yet reconciled
106
+ continue;
107
+ }
108
+ if (entry.currentHash !== entry.baselineHash) {
109
+ results.push({
110
+ filePath: relPath,
111
+ baselineHash: entry.baselineHash,
112
+ currentHash: entry.currentHash,
113
+ lastReconciledAt: entry.lastReconciledAt,
114
+ });
115
+ }
116
+ }
117
+ results.sort((a, b) => a.lastReconciledAt - b.lastReconciledAt);
118
+ return results;
119
+ }
120
+ /**
121
+ * Focus edit = the last reconciled surviving edit (highest lastReconciledAt).
122
+ */
123
+ function getFocusEdit() {
124
+ const edits = getSessionEdits();
125
+ if (edits.length === 0)
126
+ return null;
127
+ // edits are sorted ascending by lastReconciledAt, so last = most recent
128
+ return edits[edits.length - 1];
129
+ }
130
+ function hasSessionEdits() {
131
+ for (const [, entry] of tracked) {
132
+ if (entry.baselineHash !== undefined &&
133
+ entry.currentHash !== undefined &&
134
+ entry.currentHash !== entry.baselineHash) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+ /**
141
+ * Force a file to be treated as a session edit without requiring a prior baseline.
142
+ * Used for eventless edits where the host signals a change via transform hook
143
+ * but no file.edited event was emitted to establish a pre-change baseline.
144
+ */
145
+ function forceEdit(filePath, kind, timestamp) {
146
+ const rel = resolveToRelative(filePath);
147
+ let entry = tracked.get(rel);
148
+ if (!entry) {
149
+ entry = {
150
+ baselineHash: undefined,
151
+ currentHash: undefined,
152
+ lastReconciledAt: 0,
153
+ eventHints: [],
154
+ };
155
+ tracked.set(rel, entry);
156
+ }
157
+ // Set a synthetic baseline that will never match real file content
158
+ if (entry.baselineHash === undefined) {
159
+ entry.baselineHash = hashContent(`__FORCED_BASELINE__${rel}`);
160
+ }
161
+ const abs = resolveToAbsolute(rel);
162
+ entry.currentHash = hashFile(abs);
163
+ entry.lastReconciledAt = timestamp ?? now();
164
+ if (kind) {
165
+ entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
166
+ }
167
+ }
168
+ return {
169
+ recordEventHint,
170
+ reconcilePath,
171
+ reconcileKnownPaths,
172
+ getSessionEdits,
173
+ getFocusEdit,
174
+ hasSessionEdits,
175
+ forceEdit,
176
+ };
177
+ }
@@ -0,0 +1,11 @@
1
+ export interface SessionFingerprintInput {
2
+ sessionId?: string | undefined;
3
+ branch: string;
4
+ worktree: string;
5
+ }
6
+ export interface SessionBaselineState<Cursor> {
7
+ fingerprint: string | null;
8
+ cursor: Cursor | null;
9
+ }
10
+ export declare function buildSessionFingerprint(input: SessionFingerprintInput): string;
11
+ export declare function syncSessionBaselineState<Cursor>(state: SessionBaselineState<Cursor>, input: SessionFingerprintInput, captureBaseline: () => Cursor | null): SessionBaselineState<Cursor>;
@@ -0,0 +1,21 @@
1
+ export function buildSessionFingerprint(
2
+ // implements REQ-opencode-kibi-briefing-v6
3
+ input) {
4
+ return [
5
+ input.sessionId?.trim() || "unknown",
6
+ input.branch,
7
+ input.worktree,
8
+ ].join("\0");
9
+ }
10
+ export function syncSessionBaselineState(
11
+ // implements REQ-opencode-kibi-briefing-v6
12
+ state, input, captureBaseline) {
13
+ const fingerprint = buildSessionFingerprint(input);
14
+ if (state.fingerprint === fingerprint) {
15
+ return state;
16
+ }
17
+ return {
18
+ fingerprint,
19
+ cursor: captureBaseline(),
20
+ };
21
+ }
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
3
3
  * read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
4
- * the edited file path. Preference is given to relationships[type=implements].target
5
- * (in file order) then static links as a fallback, preserving file order.
4
+ * the edited file path via implements relationships.
6
5
  *
7
6
  * Supports both YAML formats: top-level array and { symbols: [...] } object.
8
7
  * This function is purely synchronous and makes no runtime KB queries.
@@ -1,179 +1,16 @@
1
1
  // implements REQ-opencode-smart-enforcement-v1
2
- import { existsSync, readFileSync } from "node:fs";
3
- import * as path from "node:path";
4
- import { loadKbSyncPaths } from "./file-filter.js";
2
+ import { getFileLinkedTargetsByType } from "./file-entity-links.js";
5
3
  /**
6
4
  * Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
7
5
  * read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
8
- * the edited file path. Preference is given to relationships[type=implements].target
9
- * (in file order) then static links as a fallback, preserving file order.
6
+ * the edited file path via implements relationships.
10
7
  *
11
8
  * Supports both YAML formats: top-level array and { symbols: [...] } object.
12
9
  * This function is purely synchronous and makes no runtime KB queries.
13
10
  */
14
11
  // implements REQ-opencode-smart-enforcement-v1
15
12
  export function getSourceLinkedRequirementIds(worktree, editedAbsolutePath) {
16
- try {
17
- const paths = loadKbSyncPaths(worktree);
18
- const symbolsPathRaw = paths.symbols;
19
- if (!symbolsPathRaw)
20
- return [];
21
- const symbolsPath = path.isAbsolute(symbolsPathRaw)
22
- ? symbolsPathRaw
23
- : path.join(worktree, symbolsPathRaw);
24
- if (!existsSync(symbolsPath))
25
- return [];
26
- const content = readFileSync(symbolsPath, "utf8");
27
- const symbols = parseSymbolsYaml(content);
28
- const relEdited = path
29
- .relative(worktree, editedAbsolutePath)
30
- .split(path.sep)
31
- .join("/");
32
- const matchedRows = symbols.filter((s) => s.sourceFile === relEdited);
33
- if (matchedRows.length === 0)
34
- return [];
35
- const seen = new Set();
36
- const orderedIds = [];
37
- // First pass: collect implements relationships in file order
38
- for (const row of matchedRows) {
39
- for (const r of row.relationships ?? []) {
40
- if (r.type === "implements") {
41
- const id = r.target;
42
- if (!seen.has(id)) {
43
- seen.add(id);
44
- orderedIds.push(id);
45
- if (orderedIds.length >= 3)
46
- return orderedIds.slice(0, 3);
47
- }
48
- }
49
- }
50
- }
51
- // Second pass: fall back to static links, preserving file order
52
- for (const row of matchedRows) {
53
- for (const l of row.links ?? []) {
54
- if (!seen.has(l)) {
55
- seen.add(l);
56
- orderedIds.push(l);
57
- if (orderedIds.length >= 3)
58
- return orderedIds.slice(0, 3);
59
- }
60
- }
61
- }
62
- return orderedIds.slice(0, 3);
63
- }
64
- catch {
65
- return [];
66
- }
67
- }
68
- // ── Lightweight YAML parser (symbols.yaml subset) ────────────────────
69
- //
70
- // Handles:
71
- // symbols:
72
- // - id: SYM-xxx
73
- // sourceFile: path/to/file
74
- // links:
75
- // - REQ-xxx
76
- // relationships:
77
- // - type: implements
78
- // target: REQ-xxx
79
- //
80
- // And bare array format (no wrapping `symbols:` key):
81
- // - id: SYM-xxx
82
- // ...
83
- function parseSymbolsYaml(content) {
84
- const entries = [];
85
- const lines = content.split("\n");
86
- let current = null;
87
- let section = "none";
88
- let pendingRel = null;
89
- function flushRel() {
90
- if (pendingRel?.type && pendingRel.target && current?.relationships) {
91
- current.relationships.push({
92
- type: pendingRel.type,
93
- target: pendingRel.target,
94
- });
95
- }
96
- pendingRel = null;
97
- }
98
- function flushEntry() {
99
- flushRel();
100
- if (current?.id && current?.sourceFile) {
101
- entries.push(current);
102
- }
103
- current = null;
104
- section = "none";
105
- }
106
- for (const raw of lines) {
107
- if (raw.trim().startsWith("#"))
108
- continue;
109
- // New entry: " - id: ..."
110
- const entryMatch = raw.match(/^\s+-\s+id:\s*(.+)$/);
111
- if (entryMatch) {
112
- flushEntry();
113
- const entryId = entryMatch[1];
114
- if (entryId === undefined)
115
- continue;
116
- current = { id: entryId.trim(), links: [], relationships: [] };
117
- section = "none";
118
- continue;
119
- }
120
- if (!current)
121
- continue;
122
- // sourceFile
123
- const srcMatch = raw.match(/^\s+sourceFile:\s*(.+)$/);
124
- if (srcMatch) {
125
- const sourceFile = srcMatch[1];
126
- if (sourceFile === undefined)
127
- continue;
128
- current.sourceFile = sourceFile.trim();
129
- section = "none";
130
- continue;
131
- }
132
- // links section header
133
- if (/^\s+links:\s*$/.test(raw)) {
134
- flushRel();
135
- section = "links";
136
- continue;
137
- }
138
- // relationships section header
139
- if (/^\s+relationships:\s*$/.test(raw)) {
140
- flushRel();
141
- section = "relationships";
142
- continue;
143
- }
144
- // Link item: " - REQ-xxx"
145
- if (section === "links") {
146
- const linkMatch = raw.match(/^\s+-\s+(REQ-[A-Za-z0-9_-]+)\s*$/);
147
- if (linkMatch) {
148
- const linkId = linkMatch[1];
149
- if (linkId !== undefined && current.links) {
150
- current.links.push(linkId);
151
- }
152
- continue;
153
- }
154
- }
155
- // Relationship type: " - type: implements"
156
- if (section === "relationships") {
157
- const relTypeMatch = raw.match(/^\s+-\s+type:\s*(.+)$/);
158
- if (relTypeMatch) {
159
- flushRel();
160
- const relationType = relTypeMatch[1];
161
- if (relationType === undefined)
162
- continue;
163
- pendingRel = { type: relationType.trim() };
164
- continue;
165
- }
166
- // Relationship target: " target: REQ-..."
167
- const relTargetMatch = raw.match(/^\s+target:\s*(.+)$/);
168
- if (relTargetMatch && pendingRel) {
169
- const target = relTargetMatch[1];
170
- if (target === undefined)
171
- continue;
172
- pendingRel.target = target.trim();
173
- continue;
174
- }
175
- }
176
- }
177
- flushEntry();
178
- return entries;
13
+ // Delegate to the shared file-entity-links resolver with implements-only filter.
14
+ // implements relationships always target REQ- IDs, so no additional filtering needed.
15
+ return getFileLinkedTargetsByType(worktree, editedAbsolutePath, ["implements"]).slice(0, 3);
179
16
  }
@@ -1,4 +1,4 @@
1
- import { hasLegacyToast, hasShowToast, sendToast, } from "./toast.js";
1
+ import { sendToast, } from "./toast.js";
2
2
  // implements REQ-opencode-kibi-plugin-v1
3
3
  export function notifyStartup(client, cfg) {
4
4
  const message = "kibi-opencode started";
@@ -9,39 +9,50 @@ export function notifyStartup(client, cfg) {
9
9
  duration: 4000,
10
10
  };
11
11
  if (!cfg.suppressToast) {
12
- if (hasShowToast(client)) {
13
- void Promise.resolve(sendToast(client, toastPayload))
14
- .then((result) => void Promise.resolve(client.app.log({
15
- body: {
16
- service: "kibi-opencode",
17
- level: "info",
18
- message: "startup toast result",
19
- result: String(result),
20
- ...(cfg.directory ? { directory: cfg.directory } : {}),
21
- },
22
- })).catch((logErr) => {
23
- console.error("[kibi-opencode] startup toast result log failed:", logErr);
24
- }))
25
- .catch((err) => {
26
- console.error("[kibi-opencode] startup toast failed:", err);
27
- void Promise.resolve(client.app.log({
12
+ void sendToast(client, toastPayload).then((result) => {
13
+ const base = {
14
+ service: "kibi-opencode",
15
+ ...(cfg.directory ? { directory: cfg.directory } : {}),
16
+ };
17
+ if (result.status === "delivered") {
18
+ void client.app.log({
28
19
  body: {
29
- service: "kibi-opencode",
20
+ ...base,
21
+ level: "info",
22
+ message: "startup toast delivered",
23
+ transport: result.transport,
24
+ },
25
+ }).catch(() => {
26
+ // Advisory log failure stays silent
27
+ });
28
+ }
29
+ else if (result.status === "unavailable") {
30
+ void client.app.log({
31
+ body: {
32
+ ...base,
33
+ level: "info",
34
+ message: "startup toast unavailable",
35
+ reason: result.reason,
36
+ },
37
+ }).catch(() => {
38
+ // Advisory log failure stays silent
39
+ });
40
+ }
41
+ else if (result.status === "failed") {
42
+ void client.app.log({
43
+ body: {
44
+ ...base,
30
45
  level: "warn",
31
- message: "startup toast failed",
32
- error: String(err),
33
- ...(cfg.directory ? { directory: cfg.directory } : {}),
46
+ message: "startup toast delivery failed",
47
+ transport: result.transport,
48
+ reason: result.reason,
49
+ ...(result.error ? { error: result.error } : {}),
34
50
  },
35
- })).catch((logErr) => {
36
- console.error("[kibi-opencode] startup toast log failed:", logErr);
51
+ }).catch(() => {
52
+ // Advisory log failure stays silent
37
53
  });
38
- });
39
- }
40
- else if (hasLegacyToast(client)) {
41
- void Promise.resolve(sendToast(client, toastPayload)).catch((err) => {
42
- console.error("[kibi-opencode] startup toast failed:", err);
43
- });
44
- }
54
+ }
55
+ });
45
56
  }
46
57
  void Promise.resolve(client.app.log({
47
58
  body: {
@@ -52,6 +63,6 @@ export function notifyStartup(client, cfg) {
52
63
  ...(cfg.directory ? { directory: cfg.directory } : {}),
53
64
  },
54
65
  })).catch((err) => {
55
- console.error("[kibi-opencode] startup log failed:", err);
66
+ // Advisory log failure stays silent
56
67
  });
57
68
  }
package/dist/toast.d.ts CHANGED
@@ -4,29 +4,28 @@ export type ToastPayload = {
4
4
  message: string;
5
5
  duration?: number;
6
6
  };
7
- type ShowToastPayload = {
8
- body: ToastPayload;
9
- };
10
- type ShowToast = (payload: ShowToastPayload) => void | Promise<void>;
11
- type LegacyToast = (payload: ToastPayload) => void | Promise<void>;
12
- type ToastUi = {
13
- showToast?: ShowToast;
14
- toast?: LegacyToast;
7
+ export type SendToastResult = {
8
+ status: "delivered";
9
+ transport: "legacy" | "sdk";
10
+ } | {
11
+ status: "unavailable";
12
+ reason: "missing-capability";
13
+ } | {
14
+ status: "failed";
15
+ transport: "legacy" | "sdk";
16
+ reason: string;
17
+ error?: string;
15
18
  };
16
19
  export type ToastCapableClient = {
17
- tui?: ToastUi;
18
- };
19
- type ClientWithShowToast = ToastCapableClient & {
20
- tui: ToastUi & {
21
- showToast: ShowToast;
22
- };
23
- };
24
- type ClientWithLegacyToast = ToastCapableClient & {
25
- tui: ToastUi & {
26
- toast: LegacyToast;
20
+ tui?: {
21
+ /** Legacy direct TUI toast (works in plugin context) */
22
+ toast?: (payload: ToastPayload) => void | Promise<void>;
23
+ /** SDK toast - receives { body: ToastPayload } */
24
+ showToast?: (payload: {
25
+ body: ToastPayload;
26
+ }) => void | Promise<void>;
27
+ clearPrompt?: () => void | Promise<void>;
28
+ submitPrompt?: () => void | Promise<void>;
27
29
  };
28
30
  };
29
- export declare function hasShowToast(client: ToastCapableClient): client is ClientWithShowToast;
30
- export declare function hasLegacyToast(client: ToastCapableClient): client is ClientWithLegacyToast;
31
- export declare function sendToast(client: ToastCapableClient, payload: ToastPayload): Promise<void>;
32
- export {};
31
+ export declare function sendToast(client: ToastCapableClient, payload: ToastPayload): Promise<SendToastResult>;
package/dist/toast.js CHANGED
@@ -1,18 +1,40 @@
1
1
  // implements REQ-opencode-kibi-plugin-v1
2
- export function hasShowToast(client) {
3
- return typeof client.tui?.showToast === "function";
4
- }
5
- // implements REQ-opencode-kibi-plugin-v1
6
- export function hasLegacyToast(client) {
7
- return typeof client.tui?.toast === "function";
8
- }
9
- // implements REQ-opencode-kibi-plugin-v1
10
- export function sendToast(client, payload) {
11
- if (hasShowToast(client)) {
12
- return Promise.resolve(client.tui.showToast({ body: payload }));
2
+ export async function sendToast(client, payload) {
3
+ if (typeof client.tui?.toast === "function") {
4
+ try {
5
+ await client.tui.toast(payload);
6
+ return { status: "delivered", transport: "legacy" };
7
+ }
8
+ catch (err) {
9
+ return {
10
+ status: "failed",
11
+ transport: "legacy",
12
+ reason: "rejected",
13
+ error: err instanceof Error ? err.message : String(err),
14
+ };
15
+ }
13
16
  }
14
- if (hasLegacyToast(client)) {
15
- return Promise.resolve(client.tui.toast(payload));
17
+ if (typeof client.tui?.showToast === "function") {
18
+ try {
19
+ const result = client.tui.showToast({ body: payload });
20
+ if (result && typeof result.then === "function") {
21
+ const timeout = new Promise((_, reject) => {
22
+ setTimeout(() => reject(new Error("showToast timed out")), 3000);
23
+ });
24
+ await Promise.race([result, timeout]);
25
+ }
26
+ return { status: "delivered", transport: "sdk" };
27
+ }
28
+ catch (err) {
29
+ return {
30
+ status: "failed",
31
+ transport: "sdk",
32
+ reason: err instanceof Error && err.message === "showToast timed out"
33
+ ? "timed-out"
34
+ : "rejected",
35
+ error: err instanceof Error ? err.message : String(err),
36
+ };
37
+ }
16
38
  }
17
- return Promise.resolve();
39
+ return { status: "unavailable", reason: "missing-capability" };
18
40
  }
@@ -0,0 +1,47 @@
1
+ import type { IdleBriefEnvelope } from "./idle-brief-store.js";
2
+ export type ToastPayload = {
3
+ variant?: "info" | "success" | "warning" | "error";
4
+ title?: string;
5
+ message: string;
6
+ duration?: number;
7
+ };
8
+ export type ToastCapableClient = {
9
+ tui?: {
10
+ showToast?: (payload: {
11
+ body: ToastPayload;
12
+ }) => void | Promise<void>;
13
+ };
14
+ };
15
+ export type SharedBriefPolicy = {
16
+ briefs: {
17
+ enabled: boolean;
18
+ channels: {
19
+ tui: boolean;
20
+ vscode: boolean;
21
+ };
22
+ tui: {
23
+ toast: boolean;
24
+ };
25
+ };
26
+ };
27
+ export type LocalBriefConfig = {
28
+ autoSubmit: boolean;
29
+ };
30
+ export type DeliverResult = {
31
+ delivered: boolean;
32
+ };
33
+ /**
34
+ * Delivers a Kibi briefing to the TUI via toast notification.
35
+ *
36
+ * Uses the REAL OpenCode plugin API:
37
+ * - client.tui.showToast(payload) — primary (and only) delivery mechanism
38
+ *
39
+ * The toast contains a rich summary from the envelope and is displayed
40
+ * for 8 seconds so users can read the content.
41
+ *
42
+ * @param client - OpenCode client with optional TUI capabilities
43
+ * @param envelope - Idle brief envelope containing briefing content
44
+ * @param sharedPolicy - Shared brief policy from `.kb/config.json`
45
+ * @param localConfig - Local OpenCode config
46
+ */
47
+ export declare function deliverBriefTui(client: ToastCapableClient, envelope: IdleBriefEnvelope, sharedPolicy: SharedBriefPolicy, _localConfig: LocalBriefConfig): Promise<DeliverResult>;