@dv.nghiem/flowdeck 0.4.0 → 0.4.1

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.
@@ -1,21 +1,86 @@
1
- type NotifyLevel = "info" | "critical";
1
+ export type NotifyLevel = "info" | "critical";
2
+ /**
3
+ * Structured reasons that can trigger a notification.
4
+ * Helps consumers understand why a notification fired.
5
+ */
6
+ export type NotificationReason = "completed" | "input_required" | "confirmation_required" | "error";
7
+ /**
8
+ * Normalise a raw command string to a bare command name.
9
+ * "/fd-discuss" → "discuss", "fd-plan" → "plan", "new-feature" → "new-feature"
10
+ */
11
+ export declare function normalizeCommandName(raw: string): string;
2
12
  /**
3
13
  * Fire a desktop notification without blocking the caller.
4
14
  * Silently ignores failures — notification is best-effort only.
5
15
  */
6
16
  export declare function notify(title: string, body: string, level?: NotifyLevel): void;
17
+ export type NotifyFn = (title: string, body: string, level?: NotifyLevel) => void;
18
+ /**
19
+ * Event-driven notification controller.
20
+ *
21
+ * Lifecycle:
22
+ * 1. onCommandExecuted() — records that a command was dispatched (NOT yet processed)
23
+ * 2. onSessionIdle() — fires notification when the agent finishes processing
24
+ * 3. onSessionError() — fires notification on critical failure
25
+ *
26
+ * Deduplication: the same (command + lifecycle-state) pair is never notified twice,
27
+ * even if session.idle fires multiple times in a row.
28
+ *
29
+ * @param notifyFn — injectable notify function (defaults to the real OS notifier; pass
30
+ * a test stub to avoid spawning OS processes in tests).
31
+ * @param log — optional diagnostic logger.
32
+ */
33
+ export declare class NotificationController {
34
+ /** The command currently awaiting a session.idle notification, or null. */
35
+ private pendingCommand;
36
+ /** Key of the last notification that was fired; used for deduplication. */
37
+ private lastNotifiedKey;
38
+ private readonly notifyFn;
39
+ private readonly log;
40
+ constructor(notifyFn?: NotifyFn, log?: (msg: string) => void);
41
+ /**
42
+ * Called when the `command.executed` event fires.
43
+ * Records the command so the next session.idle can produce the right notification.
44
+ * Must NOT fire a notification — the command has only been dispatched, not completed.
45
+ */
46
+ onCommandExecuted(rawCommand: string): void;
47
+ /**
48
+ * Called when the `session.idle` event fires.
49
+ * Fires at most one notification per pending command.
50
+ * If no command is pending, fires a generic completion notification only when
51
+ * the agent actually edited files (hasEdits = true).
52
+ *
53
+ * @param hasEdits — true when the session file tracker has recorded edits this turn.
54
+ */
55
+ onSessionIdle(hasEdits: boolean): void;
56
+ /**
57
+ * Called when the `session.error` event fires.
58
+ * Always fires unless the identical error was already reported.
59
+ */
60
+ onSessionError(errorMsg: string): void;
61
+ /**
62
+ * Reset all state. Useful in tests or when starting a new session.
63
+ */
64
+ reset(): void;
65
+ getPendingCommand(): string | null;
66
+ getLastNotifiedKey(): string | null;
67
+ }
7
68
  /**
8
- * Fires a notification when a command that needs user interaction starts.
9
- * Call this from command.execute.before after the command result is generated.
69
+ * Fires a notification when a permission is requested.
70
+ * This is event-driven (permission.ask hook fires after the agent's request)
71
+ * so it does not need to go through the NotificationController.
10
72
  */
11
- export declare function notifyCommandInteraction(command: string): void;
73
+ export declare function notifyPermissionNeeded(tool: string): void;
12
74
  /**
13
- * Fires a notification when the session becomes idle (task complete).
75
+ * @deprecated Use NotificationController.onSessionIdle() instead.
76
+ * Kept for backward compatibility — does nothing now that the controller
77
+ * handles all session-idle notifications.
14
78
  */
15
79
  export declare function notifySessionIdle(): void;
16
80
  /**
17
- * Fires a notification when a permission is requested.
81
+ * @deprecated Use NotificationController.onCommandExecuted() + onSessionIdle() instead.
82
+ * This function fired notifications on command ENTRY (too early).
83
+ * It is preserved as a no-op so that any lingering callers compile without error.
18
84
  */
19
- export declare function notifyPermissionNeeded(tool: string): void;
20
- export {};
85
+ export declare function notifyCommandInteraction(_command: string): void;
21
86
  //# sourceMappingURL=notifications.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"notifications.d.ts","sourceRoot":"","sources":["../../src/hooks/notifications.ts"],"names":[],"mappings":"AAsBA,KAAK,WAAW,GAAG,MAAM,GAAG,UAAU,CAAA;AAEtC;;;GAGG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,WAAoB,GAAG,IAAI,CAoCrF;AAUD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAiB9D;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAMxC;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAMzD"}
1
+ {"version":3,"file":"notifications.d.ts","sourceRoot":"","sources":["../../src/hooks/notifications.ts"],"names":[],"mappings":"AAsBA,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,UAAU,CAAA;AAE7C;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAC1B,WAAW,GACX,gBAAgB,GAChB,uBAAuB,GACvB,OAAO,CAAA;AAEX;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,WAAoB,GAAG,IAAI,CAoCrF;AAUD,MAAM,MAAM,QAAQ,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,WAAW,KAAK,IAAI,CAAA;AAEjF;;;;;;;;;;;;;;GAcG;AACH,qBAAa,sBAAsB;IACjC,2EAA2E;IAC3E,OAAO,CAAC,cAAc,CAAsB;IAC5C,2EAA2E;IAC3E,OAAO,CAAC,eAAe,CAAsB;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAuB;gBAE/B,QAAQ,GAAE,QAAiB,EAAE,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAe;IAK9E;;;;OAIG;IACH,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAc3C;;;;;;;OAOG;IACH,aAAa,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IA4CtC;;;OAGG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAgBtC;;OAEG;IACH,KAAK,IAAI,IAAI;IAOb,iBAAiB,IAAI,MAAM,GAAG,IAAI;IAClC,kBAAkB,IAAI,MAAM,GAAG,IAAI;CACpC;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAMzD;AAID;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAE/D"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * NotificationController Tests
3
+ *
4
+ * Covers the core timing contract:
5
+ * - No notification when a command is merely entered (command.execute.before)
6
+ * - Notification fires on session.idle after a completion command
7
+ * - Notification fires on session.idle after an interactive command
8
+ * - Notification fires on session.error
9
+ * - Duplicate notifications are suppressed
10
+ * - Long-running command only notifies at the correct lifecycle point
11
+ * - Generic (non-command) idle notification fires only when edits exist
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=notifications.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications.test.d.ts","sourceRoot":"","sources":["../../src/hooks/notifications.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Session Idle Hook
3
3
  * Fires when OpenCode's session becomes idle (task completed).
4
- * 1. Sends a desktop notification (if notify() succeeds)
5
- * 2. Logs a summary of edited files via client.app.log
4
+ * Logs a summary of edited files via client.app.log.
6
5
  *
7
- * Inspired by oh-my-openagent's session notification + ECC's session.idle handler.
6
+ * NOTE: Desktop notifications are no longer sent from this hook.
7
+ * They are handled by NotificationController in notifications.ts, which
8
+ * fires at the correct lifecycle points (session.idle after a command,
9
+ * session.error on failure) and deduplicates properly.
8
10
  */
9
11
  import type { SessionFileTracker } from "./file-tracker";
10
12
  export declare function createSessionIdleHook(client: {
@@ -1 +1 @@
1
- {"version":3,"file":"session-idle-hook.d.ts","sourceRoot":"","sources":["../../src/hooks/session-idle-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAExD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE;IAAE,GAAG,EAAE;QAAE,GAAG,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE;gBAAE,OAAO,EAAE,MAAM,CAAC;gBAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;gBAAC,OAAO,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAA;CAAE,EAC5I,OAAO,EAAE,kBAAkB,uBAiC5B"}
1
+ {"version":3,"file":"session-idle-hook.d.ts","sourceRoot":"","sources":["../../src/hooks/session-idle-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAExD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE;IAAE,GAAG,EAAE;QAAE,GAAG,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE;gBAAE,OAAO,EAAE,MAAM,CAAC;gBAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;gBAAC,OAAO,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAA;CAAE,EAC5I,OAAO,EAAE,kBAAkB,uBA0B5B"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAgGjD,QAAA,MAAM,MAAM,EAAE,MAqTb,CAAA;AAED,eAAe,MAAM,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAgGjD,QAAA,MAAM,MAAM,EAAE,MAkVb,CAAA;AAED,eAAe,MAAM,CAAA"}
package/dist/index.js CHANGED
@@ -2553,6 +2553,9 @@ var COMPLETION_COMMANDS = new Set([
2553
2553
  "execute",
2554
2554
  "verify"
2555
2555
  ]);
2556
+ function normalizeCommandName(raw) {
2557
+ return raw.replace(/^\//, "").replace(/^fd-/, "");
2558
+ }
2556
2559
  function notify(title, body, level = "info") {
2557
2560
  const platform = process.platform;
2558
2561
  try {
@@ -2584,16 +2587,80 @@ function tryTerminalBell() {
2584
2587
  process.stdout.write("\x07");
2585
2588
  } catch {}
2586
2589
  }
2587
- function notifyCommandInteraction(command) {
2588
- const name = command.replace(/^\//, "").replace(/^fd-/, "");
2589
- if (INTERACTIVE_COMMANDS.has(name)) {
2590
- notify(`FlowDeck: /${name}`, "Your input is needed — please check OpenCode", "critical");
2591
- } else if (COMPLETION_COMMANDS.has(name)) {
2592
- notify(`FlowDeck: /${name} complete`, "Review the output and choose your next step", "info");
2590
+
2591
+ class NotificationController {
2592
+ pendingCommand = null;
2593
+ lastNotifiedKey = null;
2594
+ notifyFn;
2595
+ log;
2596
+ constructor(notifyFn = notify, log = () => {}) {
2597
+ this.notifyFn = notifyFn;
2598
+ this.log = log;
2599
+ }
2600
+ onCommandExecuted(rawCommand) {
2601
+ const name = normalizeCommandName(rawCommand);
2602
+ if (!INTERACTIVE_COMMANDS.has(name) && !COMPLETION_COMMANDS.has(name)) {
2603
+ this.log(`[notify] command.executed: "${name}" — not a tracked command, skipping`);
2604
+ return;
2605
+ }
2606
+ this.log(`[notify] command.executed: "${name}" recorded as pending`);
2607
+ this.pendingCommand = name;
2608
+ this.lastNotifiedKey = null;
2609
+ }
2610
+ onSessionIdle(hasEdits) {
2611
+ if (this.pendingCommand) {
2612
+ const name = this.pendingCommand;
2613
+ const dedupeKey = `idle:${name}`;
2614
+ if (this.lastNotifiedKey === dedupeKey) {
2615
+ this.log(`[notify] suppressed duplicate: state=session.idle command=${name}`);
2616
+ return;
2617
+ }
2618
+ const reason = INTERACTIVE_COMMANDS.has(name) ? "input_required" : "completed";
2619
+ this.log(`[notify] firing notification: reason=${reason} command=${name} source=session.idle`);
2620
+ if (reason === "input_required") {
2621
+ this.notifyFn(`FlowDeck: /${name}`, "Your input is needed — please check OpenCode", "critical");
2622
+ } else {
2623
+ this.notifyFn(`FlowDeck: /${name} complete`, "Review the output and choose your next step", "info");
2624
+ }
2625
+ this.lastNotifiedKey = dedupeKey;
2626
+ this.pendingCommand = null;
2627
+ return;
2628
+ }
2629
+ if (hasEdits) {
2630
+ const dedupeKey = "idle:generic";
2631
+ if (this.lastNotifiedKey === dedupeKey) {
2632
+ this.log(`[notify] suppressed duplicate: state=session.idle source=generic`);
2633
+ return;
2634
+ }
2635
+ this.log(`[notify] firing notification: reason=completed source=session.idle (generic, has edits)`);
2636
+ this.notifyFn("FlowDeck Task Completed", "Agent is idle and waiting for your next instruction", "info");
2637
+ this.lastNotifiedKey = dedupeKey;
2638
+ } else {
2639
+ this.log(`[notify] session.idle — no pending command, no edits — suppressed`);
2640
+ }
2641
+ }
2642
+ onSessionError(errorMsg) {
2643
+ const snippet = errorMsg.slice(0, 60);
2644
+ const dedupeKey = `error:${snippet}`;
2645
+ if (this.lastNotifiedKey === dedupeKey) {
2646
+ this.log(`[notify] suppressed duplicate: state=session.error`);
2647
+ return;
2648
+ }
2649
+ this.log(`[notify] firing notification: reason=error source=session.error`);
2650
+ this.notifyFn("FlowDeck Error", snippet || "An error occurred", "critical");
2651
+ this.lastNotifiedKey = dedupeKey;
2652
+ this.pendingCommand = null;
2653
+ }
2654
+ reset() {
2655
+ this.pendingCommand = null;
2656
+ this.lastNotifiedKey = null;
2657
+ }
2658
+ getPendingCommand() {
2659
+ return this.pendingCommand;
2660
+ }
2661
+ getLastNotifiedKey() {
2662
+ return this.lastNotifiedKey;
2593
2663
  }
2594
- }
2595
- function notifySessionIdle() {
2596
- notify("FlowDeck Task Completed", "Agent is idle and waiting for your next instruction", "info");
2597
2664
  }
2598
2665
  function notifyPermissionNeeded(tool17) {
2599
2666
  notify("FlowDeck Permission Required", `Agent needs approval to use tool: ${tool17}`, "critical");
@@ -3097,7 +3164,6 @@ function createSessionIdleHook(client, tracker) {
3097
3164
  const edited = tracker.getEditedPaths();
3098
3165
  if (edited.length === 0)
3099
3166
  return;
3100
- notifySessionIdle();
3101
3167
  const summary = `[FlowDeck] Session idle — ${edited.length} file(s) modified this session`;
3102
3168
  await client.app.log({ body: { service: "flowdeck", level: "info", message: summary } }).catch(() => {});
3103
3169
  const preview = edited.slice(0, 10);
@@ -7223,6 +7289,7 @@ var plugin = async (input, _options) => {
7223
7289
  const orchestratorGuard = new OrchestratorGuard;
7224
7290
  const appLog = (msg) => client.app.log({ body: { service: "flowdeck", level: "info", message: msg } }).catch(() => {});
7225
7291
  const autoLearnHook = createAutoLearnHook(client, fileTracker, directory, appLog);
7292
+ const notifCtrl = new NotificationController(undefined, appLog);
7226
7293
  const agentConfigs = getAgentConfigs({});
7227
7294
  const mcps = createFlowDeckMcps();
7228
7295
  return {
@@ -7323,9 +7390,7 @@ var plugin = async (input, _options) => {
7323
7390
  "file.edited": fileEdited,
7324
7391
  "file.watcher.updated": fileWatcherUpdated,
7325
7392
  "experimental.session.compacting": compactionHook,
7326
- "command.execute.before": async (input2, _output) => {
7327
- notifyCommandInteraction(input2.command);
7328
- },
7393
+ "command.execute.before": async (_input, _output) => {},
7329
7394
  "permission.ask": async (input2, _output) => {
7330
7395
  notifyPermissionNeeded(input2.title);
7331
7396
  },
@@ -7334,9 +7399,17 @@ var plugin = async (input, _options) => {
7334
7399
  if (type === "session.created" || type === "session.started") {
7335
7400
  await sessionStartHook({ directory });
7336
7401
  }
7402
+ if (type === "command.executed") {
7403
+ const commandName = event?.properties?.name ?? "";
7404
+ if (commandName) {
7405
+ notifCtrl.onCommandExecuted(commandName);
7406
+ }
7407
+ }
7337
7408
  await contextMonitor.event({ event });
7338
7409
  orchestratorGuard.onEvent(event);
7339
7410
  if (type === "session.idle") {
7411
+ const hasEdits = fileTracker.getEditedPaths().length > 0;
7412
+ notifCtrl.onSessionIdle(hasEdits);
7340
7413
  try {
7341
7414
  await sessionIdleHook();
7342
7415
  await autoLearnHook();
@@ -7344,6 +7417,11 @@ var plugin = async (input, _options) => {
7344
7417
  fileTracker.clear();
7345
7418
  }
7346
7419
  }
7420
+ if (type === "session.error") {
7421
+ const err = event?.properties?.error;
7422
+ const errorMsg = (err && typeof err === "object" && "message" in err ? String(err.message) : undefined) ?? (typeof err === "string" ? err : undefined) ?? "An unexpected error occurred";
7423
+ notifCtrl.onSessionError(errorMsg);
7424
+ }
7347
7425
  },
7348
7426
  "tool.execute.before": async (toolInput, toolOutput) => {
7349
7427
  if ((toolInput.tool === "read" || toolInput.tool === "view") && toolOutput?.args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dv.nghiem/flowdeck",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "FlowDeck — structured planning and execution workflows for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",