@pellux/goodvibes-tui 1.24.0 → 1.24.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.24.1] - 2026-07-27
8
+
9
+ ### Changes
10
+
11
+ - Fixed: stopping the daemon left work running. The inbox retention sweep and the per-account inbound pollers kept ticking after shutdown, and a daemon that never finished starting — a port already in use, a short-lived run — left everything its construction had started running with nothing to stop it.
12
+ - Fixed: agent runs the daemon was hosting are now cancelled when it shuts down. One left running had nowhere to report to and nothing to write into: the registries and event bus it reports through are already gone by then. It also keeps an in-flight provider call alive, sleeping out a retry it no longer has any reason to finish.
13
+ - Fixed: a close arriving while the inbox cursor store was still opening could race it.
14
+
7
15
  ## [1.24.0] - 2026-07-27
8
16
 
9
17
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-1.24.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-1.24.1-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  GoodVibes is a terminal console for coding and operations work with an AI model. You run `goodvibes` in a project directory and get a full-screen terminal app where you talk to a model that can read and edit your files, run shell commands, search the web, and hand work off to background agents — asking your permission before anything that writes or executes. It talks to many model providers (OpenAI, Anthropic, Gemini, Bedrock, Copilot, OpenRouter and other OpenAI-compatible gateways, plus local servers like Ollama and LM Studio that it finds on startup), keeps its settings, sessions, and secrets on your own machine, and shows you the token count and running cost of every turn. Alongside the conversation, panels turn background work — running agents, git state, diffs, tokens, cost — into live control rooms.
8
8
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "1.17.0"
6
+ "version": "1.17.2"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -106,8 +106,8 @@
106
106
  "@anthropic-ai/vertex-sdk": "^0.16.0",
107
107
  "@ast-grep/napi": "^0.42.0",
108
108
  "@aws/bedrock-token-generator": "^1.1.0",
109
- "@pellux/goodvibes-sdk": "1.17.0",
110
- "@pellux/goodvibes-terminal-shell": "1.17.0",
109
+ "@pellux/goodvibes-sdk": "1.17.2",
110
+ "@pellux/goodvibes-terminal-shell": "1.17.2",
111
111
  "bash-language-server": "^5.6.0",
112
112
  "fuse.js": "^7.1.0",
113
113
  "graphql": "^16.13.2",
package/src/daemon/cli.ts CHANGED
@@ -426,15 +426,17 @@ async function main(): Promise<void> {
426
426
  // daemon.stop() drives the ordered inbound teardown: it stops every gated
427
427
  // consumer and only then broadcasts the resignation, so another node on
428
428
  // this network takes over in about a second instead of waiting out the
429
- // crash timeout. Handler surfaces (inbox store, catalog handlers) are
430
- // released afterwards — before this, a SIGTERM left the inbox cursor store
431
- // open and relied on process exit to clear the poll timers.
429
+ // crash timeout.
430
+ //
431
+ // This process built the runtime graph and handed it to DaemonServer, so by
432
+ // the SDK's ownership rule the facade leaves it alone — nothing else stops
433
+ // these pollers. Without dispose() the config watch, fleet tick, memory
434
+ // governor, watcher registry and six more kept ticking until process exit.
435
+ // The handler surfaces (inbox store + its poll timers, catalog handlers) are
436
+ // the FIRST thing dispose() unwinds — they are on the disposal owner list
437
+ // now rather than sequenced by hand here, which is what makes every other
438
+ // shutdown path stop them too instead of only this one.
432
439
  const stop = Promise.allSettled([listener.stop(), daemon.stop()])
433
- .then(() => runtimeServices.daemonHandlers.unregister())
434
- // This process built the runtime graph and handed it to DaemonServer, so
435
- // by the SDK's ownership rule the facade leaves it alone — nothing else
436
- // stops these pollers. Without this the config watch, fleet tick, memory
437
- // governor, watcher registry and six more kept ticking until process exit.
438
440
  .then(() => { runtimeServices.dispose(); })
439
441
  .then(() => 'done' as const);
440
442
  const result = await Promise.race([stop, timeout]);
@@ -140,6 +140,15 @@ export class InboxCursorStore {
140
140
  private readonly setIntervalImpl: typeof setInterval;
141
141
  private readonly clearIntervalImpl: typeof clearInterval;
142
142
  private sweepTimer: ReturnType<typeof setInterval> | null = null;
143
+ /**
144
+ * Set by close(). `init()` is async and arms the retention timer only after
145
+ * two awaits, so a surface that is torn down while its bootstrap is still in
146
+ * flight would otherwise arm a timer AFTER the only thing that could clear it
147
+ * had already run. That is not hypothetical: registerInboxMethods() kicks
148
+ * init() off without awaiting it, so any shutdown inside the first tick of a
149
+ * daemon's life hits exactly this window.
150
+ */
151
+ private closed = false;
143
152
 
144
153
  constructor(
145
154
  workingDirectory: string,
@@ -201,7 +210,7 @@ export class InboxCursorStore {
201
210
  }
202
211
 
203
212
  private startSweepTimer(): void {
204
- if (this.sweepTimer !== null || this.sweepIntervalMs <= 0) return;
213
+ if (this.closed || this.sweepTimer !== null || this.sweepIntervalMs <= 0) return;
205
214
  const handle = this.setIntervalImpl(() => {
206
215
  void this.runSweep();
207
216
  }, this.sweepIntervalMs);
@@ -415,6 +424,8 @@ export class InboxCursorStore {
415
424
 
416
425
  /** Stop the retention timer, flush (best-effort), then close the database. */
417
426
  async close(): Promise<void> {
427
+ if (this.closed) return;
428
+ this.closed = true;
418
429
  this.stopSweepTimer();
419
430
  try {
420
431
  await this.flush();
@@ -51,6 +51,15 @@ export class InboundPoller {
51
51
  private readonly statuses = new Map<string, ProviderStatus>();
52
52
  private readonly inFlight = new Set<string>();
53
53
  private started = false;
54
+ /**
55
+ * Set by stop(), which the surface teardown calls and nothing else — unlike
56
+ * stopProvider(), which a leadership handover uses and which must stay
57
+ * resumable. Once the surface is released no interval may be armed again,
58
+ * including by work that was already in flight: registerInboxMethods() starts
59
+ * the store bootstrap without awaiting it, so a teardown during that window
60
+ * would otherwise be followed by a start() that nothing is left to undo.
61
+ */
62
+ private released = false;
54
63
 
55
64
  constructor(options: PollerOptions) {
56
65
  this.adapters = options.adapters;
@@ -66,7 +75,7 @@ export class InboundPoller {
66
75
 
67
76
  /** Begin per-provider interval loops. Idempotent. Does NOT poll immediately. */
68
77
  start(): void {
69
- if (this.started) return;
78
+ if (this.released || this.started) return;
70
79
  this.started = true;
71
80
  for (const id of this.adapters.keys()) this.startProvider(id);
72
81
  }
@@ -81,7 +90,7 @@ export class InboundPoller {
81
90
  * addressable per provider, and the blanket calls are fan-outs over it.
82
91
  */
83
92
  startProvider(id: string): void {
84
- if (this.timers.has(id)) return;
93
+ if (this.released || this.timers.has(id)) return;
85
94
  const adapter = this.adapters.get(id);
86
95
  if (!adapter) return;
87
96
  const handle = this.setIntervalImpl(() => {
@@ -187,8 +196,9 @@ export class InboundPoller {
187
196
  });
188
197
  }
189
198
 
190
- /** Stop all interval loops. Idempotent. */
199
+ /** Release the poller for good: stop every interval loop, and refuse to arm another. Idempotent. */
191
200
  stop(): void {
201
+ this.released = true;
192
202
  for (const handle of this.timers.values()) {
193
203
  this.clearIntervalImpl(handle);
194
204
  }
@@ -88,7 +88,16 @@ export function registerDaemonHandlers(
88
88
  const remote = providers.registerRemote(ctx);
89
89
  teardowns.push(remote.unregister);
90
90
 
91
+ // Idempotent: teardown is reachable from more than one shutdown path now that
92
+ // the runtime disposal scope owns it, and the surfaces underneath are not all
93
+ // safe to unwind twice — the inbox surface closes a SQLite handle, which a
94
+ // second pass would close again inside a floating promise (an unhandled
95
+ // rejection, not a caught one). Running once is also simply the honest
96
+ // meaning of "release these surfaces".
97
+ let torn = false;
91
98
  const unregister: Unregister = () => {
99
+ if (torn) return;
100
+ torn = true;
92
101
  for (let i = teardowns.length - 1; i >= 0; i -= 1) {
93
102
  try {
94
103
  teardowns[i]!();
@@ -0,0 +1,78 @@
1
+ /**
2
+ * agent-graph-composition.ts — the graph that runs agents.
3
+ *
4
+ * Six collaborators that are only meaningful as a set, so they are built as
5
+ * one: a message bus, the archetype loader, the orchestrator that executes a
6
+ * run, the manager that owns the records, the context-accounting holder, and
7
+ * the WRFC controller. Every one of them holds a reference to at least one
8
+ * other, and two of the links are circular — the orchestrator writes
9
+ * conversation snapshots back through the manager, and the manager drives the
10
+ * WRFC controller which was built from the manager. Assembled anywhere but in
11
+ * one place, a half-wired graph looks correct and silently drops either the
12
+ * snapshot bridge or the review loop.
13
+ *
14
+ * It is also the graph whose runs `cancelHostedAgentRuns` cancels at disposal:
15
+ * the manager returned here is the one the runtime hands to the SDK's
16
+ * `cancelAllAgentRuns`.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { AgentMessageBus, AgentOrchestrator, ArchetypeLoader, WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
20
+ import { AgentManager, ContextAccountingHolder } from '@pellux/goodvibes-sdk/platform/tools';
21
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
22
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
23
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
24
+
25
+ export interface AgentGraph {
26
+ readonly agentMessageBus: AgentMessageBus;
27
+ readonly archetypeLoader: ArchetypeLoader;
28
+ readonly agentOrchestrator: AgentOrchestrator;
29
+ readonly agentManager: AgentManager;
30
+ readonly contextAccountingHolder: ContextAccountingHolder;
31
+ readonly wrfcController: WrfcController;
32
+ }
33
+
34
+ /** Build the agent-execution graph, fully wired in both directions. */
35
+ export function createAgentGraph(options: {
36
+ readonly runtimeBus: RuntimeEventBus;
37
+ readonly workingDirectory: string;
38
+ readonly configManager: ConfigManager;
39
+ readonly providerRegistry: ProviderRegistry;
40
+ }): AgentGraph {
41
+ const agentMessageBus = new AgentMessageBus();
42
+ agentMessageBus.setRuntimeBus(options.runtimeBus);
43
+ const archetypeLoader = new ArchetypeLoader(join(options.workingDirectory, '.goodvibes', 'agents'));
44
+ const agentOrchestrator = new AgentOrchestrator({
45
+ messageBus: agentMessageBus,
46
+ });
47
+ agentOrchestrator.setRuntimeBus(options.runtimeBus);
48
+ const agentManager = new AgentManager({
49
+ archetypeLoader,
50
+ messageBus: agentMessageBus,
51
+ executor: agentOrchestrator,
52
+ configManager: options.configManager,
53
+ // The live registry lets a bare model id in a spawn() override resolve
54
+ // through the shared resolver instead of being rejected as unqualified.
55
+ providerRegistry: options.providerRegistry,
56
+ });
57
+ const contextAccountingHolder = new ContextAccountingHolder();
58
+ // Conversation-snapshot bridge (mirrors the SDK's own createRuntimeServices).
59
+ agentOrchestrator.setConversationSink({
60
+ register: (agentId, source) => agentManager.registerConversationSource(agentId, source),
61
+ release: (agentId) => agentManager.releaseConversationSource(agentId),
62
+ });
63
+ agentManager.setRuntimeBus(options.runtimeBus);
64
+ const wrfcController = new WrfcController(options.runtimeBus, agentMessageBus, {
65
+ agentManager,
66
+ configManager: options.configManager,
67
+ projectRoot: options.workingDirectory,
68
+ });
69
+ agentManager.setWrfcController(wrfcController);
70
+ return {
71
+ agentMessageBus,
72
+ archetypeLoader,
73
+ agentOrchestrator,
74
+ agentManager,
75
+ contextAccountingHolder,
76
+ wrfcController,
77
+ };
78
+ }
@@ -38,6 +38,15 @@ export { createDisposalScope };
38
38
  export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 'stopConfigWatch'> {
39
39
  /** Fork-only: the repeating crash-residue sweep (durability-services.ts). */
40
40
  readonly stopDurabilityHousekeeping: () => void;
41
+ /**
42
+ * Fork-only: the daemon handler surfaces (daemon-handler-composition.ts).
43
+ *
44
+ * `unregister()` detaches the gateway handlers AND stops the two pollers this
45
+ * product's inbox surface owns — the retention sweep inside `InboxCursorStore`
46
+ * and the per-provider `InboundPoller` intervals. The SDK does not know either
47
+ * exists, so if this surface does not stop them nothing does.
48
+ */
49
+ readonly daemonHandlers: { readonly unregister: () => void };
41
50
  }
42
51
 
43
52
  /**
@@ -63,4 +72,12 @@ export function registerSurfaceRuntimePollers(
63
72
  ): void {
64
73
  registerRuntimePollers(registry, { ...services, stopConfigWatch: extras.stopConfigWatch });
65
74
  registry.add('durability housekeeping', services.stopDurabilityHousekeeping);
75
+ // Registered LAST so it tears down FIRST (the scope unwinds in reverse), which
76
+ // is the order daemon/cli.ts already used by hand: release the handler surfaces
77
+ // — closing the inbox store and stopping its poll timers — before the pollers
78
+ // the rest of the graph owns. Being on this list is what makes a plain
79
+ // `dispose()` total: every shutdown path (daemon/cli.ts, main.ts's teardown
80
+ // registry, the one-shot CLI commands) now stops these two, not just the one
81
+ // that remembered to call `unregister()` itself.
82
+ registry.add('daemon handler surfaces', () => services.daemonHandlers.unregister());
66
83
  }
@@ -20,7 +20,7 @@ import { createKnowledgeServices } from './knowledge-services.ts';
20
20
  import { MediaProviderRegistry, ensureBuiltinMediaProviders } from '@pellux/goodvibes-sdk/platform/media';
21
21
  import { MultimodalService } from '@pellux/goodvibes-sdk/platform/multimodal';
22
22
  import { AgentMessageBus, AgentOrchestrator, ArchetypeLoader, WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
23
- import { AgentManager, ContextAccountingHolder, OverflowHandler, ProcessManager, createWorkflowServices, type WorkflowServices } from '@pellux/goodvibes-sdk/platform/tools';
23
+ import { AgentManager, ContextAccountingHolder, OverflowHandler, ProcessManager, cancelAllAgentRuns, createWorkflowServices, type WorkflowServices } from '@pellux/goodvibes-sdk/platform/tools';
24
24
  import { FileStateCache, FileUndoManager, MemoryConsolidationScheduler, MemoryEmbeddingProviderRegistry, MemoryRegistry, MemoryStore, ModeManager, ProjectIndex, resolveCanonicalMemoryDbPath, type CodeIndexStore, type CodeIndexReindexScheduler } from '@pellux/goodvibes-sdk/platform/state';
25
25
  import type { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
26
26
  import type { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
@@ -56,6 +56,7 @@ import { ApiTokenAuditor, UserAuthManager } from '@pellux/goodvibes-sdk/platform
56
56
  import { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
57
57
  import { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
58
58
  import { createRemoteExecutionServices } from './remote-execution-composition.ts';
59
+ import { createAgentGraph } from './agent-graph-composition.ts';
59
60
  import { BenchmarkStore, CacheHitTracker, FavoritesStore, ModelLimitsService, ProviderCapabilityRegistry, ProviderOptimizer, ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
60
61
  import { KeybindingsManager } from '../input/keybindings.ts';
61
62
  import { AdaptivePlanner, DeterministicReplayEngine, ExecutionPlanManager, SessionLineageTracker, SessionMemoryStore } from '@pellux/goodvibes-sdk/platform/core';
@@ -232,6 +233,15 @@ export interface RuntimeServices {
232
233
  readonly integrationHelpers: IntegrationHelperService;
233
234
  /** Re-root path-bound stores (MemoryStore, ProjectIndex) to a new working directory, called by WorkspaceSwapManager after verification; stores needing a process restart just warn-log and keep serving the old path until the daemon restarts with the new --working-dir. */
234
235
  rerootStores(newWorkingDir: string): Promise<void>;
236
+ /**
237
+ * Cancel the agent runs this graph is hosting, returning how many.
238
+ *
239
+ * Required by the SDK's RuntimePollerOwners: by dispose() time the fleet
240
+ * registry, orchestration engine, process registry and bus these runs report
241
+ * through are already down, so a run still described as "running" is orphaned
242
+ * rather than preserved.
243
+ */
244
+ cancelHostedAgentRuns(): number;
235
245
  dispose(): void; // Stop every poller this graph started; best-effort, total, idempotent. This surface owns its graph — see disposal-wiring.ts.
236
246
  }
237
247
 
@@ -342,34 +352,14 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
342
352
  runtimeStore: options.runtimeStore,
343
353
  runtimeBus: options.runtimeBus,
344
354
  });
345
- const agentMessageBus = new AgentMessageBus();
346
- agentMessageBus.setRuntimeBus(options.runtimeBus);
347
- const archetypeLoader = new ArchetypeLoader(join(workingDirectory, '.goodvibes', 'agents'));
348
- const agentOrchestrator = new AgentOrchestrator({
349
- messageBus: agentMessageBus,
350
- });
351
- agentOrchestrator.setRuntimeBus(options.runtimeBus);
352
- const agentManager = new AgentManager({
353
- archetypeLoader,
354
- messageBus: agentMessageBus,
355
- executor: agentOrchestrator,
356
- configManager,
357
- // The live registry lets a bare model id in a spawn() override resolve
358
- // through the shared resolver instead of being rejected as unqualified.
359
- providerRegistry,
360
- });
361
- const contextAccountingHolder = new ContextAccountingHolder();
362
- agentOrchestrator.setConversationSink({ // Conversation-snapshot bridge (mirrors the SDK's own createRuntimeServices)
363
- register: (agentId, source) => agentManager.registerConversationSource(agentId, source),
364
- release: (agentId) => agentManager.releaseConversationSource(agentId),
365
- });
366
- agentManager.setRuntimeBus(options.runtimeBus);
367
- const wrfcController = new WrfcController(options.runtimeBus, agentMessageBus, {
368
- agentManager,
369
- configManager,
370
- projectRoot: workingDirectory,
355
+ // The agent-execution graph, wired in both directions; see
356
+ // agent-graph-composition.ts for why the six are built as one.
357
+ const {
358
+ agentMessageBus, archetypeLoader, agentOrchestrator,
359
+ agentManager, contextAccountingHolder, wrfcController,
360
+ } = createAgentGraph({
361
+ runtimeBus: options.runtimeBus, workingDirectory, configManager, providerRegistry,
371
362
  });
372
- agentManager.setWrfcController(wrfcController);
373
363
  const hookDispatcher = new HookDispatcher({ agentManager, toolLLM, projectRoot: workingDirectory }, hookActivityTracker);
374
364
  configManager.attachHookDispatcher(hookDispatcher);
375
365
  const hookWorkbench = createHookWorkbench({
@@ -794,6 +784,13 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
794
784
  workspaceCheckpointManager,
795
785
  integrationHelpers,
796
786
  rerootStores: createStoreRerooter({ codeIndexStore, projectIndex }),
787
+ // Cancels the agent runs this graph was hosting. By dispose() time the
788
+ // fleet registry, orchestration engine, process registry and bus these runs
789
+ // report through are already down, so a run still described as "running" is
790
+ // orphaned rather than preserved — and this is the only shutdown-reachable
791
+ // way to abort its in-flight provider call instead of letting it sleep out
792
+ // a retry backoff nobody is waiting on.
793
+ cancelHostedAgentRuns: () => cancelAllAgentRuns(agentManager),
797
794
  dispose: (): void => disposalScope.dispose(),
798
795
  };
799
796
  registerSurfaceRuntimePollers(disposalScope.registry, services, { stopConfigWatch }); return services;
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.24.0';
9
+ let _version = '1.24.1';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file