@pellux/goodvibes-tui 1.24.1 → 1.25.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 (41) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/bin/goodvibes-daemon +24 -0
  4. package/docs/foundation-artifacts/operator-contract.json +3237 -1096
  5. package/package.json +7 -7
  6. package/src/config/goodvibes-home.ts +139 -0
  7. package/src/daemon/cli.ts +17 -6
  8. package/src/daemon/handlers/index.ts +7 -6
  9. package/src/input/commands/calendar-runtime.ts +245 -0
  10. package/src/input/commands/connection-status.ts +258 -0
  11. package/src/input/commands/mail-runtime.ts +267 -0
  12. package/src/input/commands/review-runtime.ts +13 -1
  13. package/src/input/commands.ts +4 -0
  14. package/src/input/settings-modal-connections.ts +102 -0
  15. package/src/input/settings-modal-types.ts +2 -1
  16. package/src/input/settings-modal.ts +27 -0
  17. package/src/main.ts +7 -2
  18. package/src/renderer/settings-modal-connections.ts +63 -0
  19. package/src/renderer/settings-modal-helpers.ts +2 -0
  20. package/src/renderer/settings-modal.ts +5 -0
  21. package/src/runtime/bootstrap-core.ts +1 -1
  22. package/src/runtime/context.ts +9 -0
  23. package/src/runtime/credential-composition.ts +54 -0
  24. package/src/runtime/daemon-handler-composition.ts +0 -4
  25. package/src/runtime/mail-composition.ts +65 -0
  26. package/src/runtime/services.ts +21 -24
  27. package/src/shell/ui-openers.ts +7 -0
  28. package/src/verification/verification-ledger.ts +58 -0
  29. package/src/version.ts +1 -1
  30. package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
  31. package/src/daemon/handlers/calendar/ics.ts +0 -556
  32. package/src/daemon/handlers/calendar/index.ts +0 -316
  33. package/src/daemon/handlers/email/config.ts +0 -164
  34. package/src/daemon/handlers/email/imap-connector.ts +0 -441
  35. package/src/daemon/handlers/email/imap-parsing.ts +0 -499
  36. package/src/daemon/handlers/email/index.ts +0 -43
  37. package/src/daemon/handlers/email/read-handlers.ts +0 -80
  38. package/src/daemon/handlers/email/runtime.ts +0 -140
  39. package/src/daemon/handlers/email/smtp-connector.ts +0 -557
  40. package/src/daemon/handlers/email/validation.ts +0 -147
  41. package/src/daemon/handlers/email/write-handlers.ts +0 -133
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.24.1",
3
+ "version": "1.25.0",
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.2",
110
- "@pellux/goodvibes-terminal-shell": "1.17.2",
109
+ "@pellux/goodvibes-sdk": "1.18.1",
110
+ "@pellux/goodvibes-terminal-shell": "1.18.1",
111
111
  "bash-language-server": "^5.6.0",
112
112
  "fuse.js": "^7.1.0",
113
113
  "graphql": "^16.13.2",
@@ -133,10 +133,10 @@
133
133
  "typescript": "^5.9.3"
134
134
  },
135
135
  "optionalDependencies": {
136
- "@pellux/goodvibes-tui-linux-x64": "1.23.0",
137
- "@pellux/goodvibes-tui-linux-arm64": "1.23.0",
138
- "@pellux/goodvibes-tui-darwin-x64": "1.23.0",
139
- "@pellux/goodvibes-tui-darwin-arm64": "1.23.0"
136
+ "@pellux/goodvibes-tui-linux-x64": "1.25.0",
137
+ "@pellux/goodvibes-tui-linux-arm64": "1.25.0",
138
+ "@pellux/goodvibes-tui-darwin-x64": "1.25.0",
139
+ "@pellux/goodvibes-tui-darwin-arm64": "1.25.0"
140
140
  },
141
141
  "trustedDependencies": [],
142
142
  "overrides": {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * goodvibes-home.ts — where this process's GoodVibes tree lives, resolved in
3
+ * ONE place for every entry point.
4
+ *
5
+ * ## The defect this closes
6
+ *
7
+ * `GOODVIBES_HOME` relocates the tree root. It was read by exactly one file —
8
+ * `src/daemon/cli.ts` — and the CLIENT entry point (`src/main.ts`) called
9
+ * `homedir()` unconditionally, on the same object literal where it did honour
10
+ * `GOODVIBES_WORKING_DIR`. So a harness that set `GOODVIBES_HOME` to a
11
+ * throwaway directory and then ran a client command got a process that read and
12
+ * wrote the REAL tree: settings, workspace state, and — because the daemon-tier
13
+ * secret store is derived from the tree root — `~/.goodvibes/daemon/secrets.enc`.
14
+ *
15
+ * That is not hypothetical. An isolated round stored two throwaway credentials
16
+ * and they landed in the owner's live secret store, where they had to be
17
+ * removed by hand. An isolation boundary a process can walk out of is not a
18
+ * boundary; the honest fix is that no entry point resolves a home of its own.
19
+ *
20
+ * ## What each variable means
21
+ *
22
+ * - `GOODVIBES_HOME` — the tree ROOT, the directory `.goodvibes/` sits under.
23
+ * Setting it relocates settings, workspace, discovery roots, and every tier
24
+ * of the secret store. This is what an isolated harness or a service unit
25
+ * sets.
26
+ * - `GOODVIBES_DAEMON_HOME` — the daemon's own IDENTITY directory (auth users,
27
+ * operator tokens, daemon settings). It falls under the tree root unless set
28
+ * separately, and it names only that directory: it is not a second way to
29
+ * move the tree. Both entry points resolve it the same way now, so a client
30
+ * given only this variable no longer computes the daemon-tier secret path
31
+ * from the real home while everything else points at a sandbox.
32
+ *
33
+ * Both readers take `env` as an argument rather than reaching for
34
+ * `process.env`, so the resolution is exercisable without a test having to
35
+ * mutate the environment of the process running it.
36
+ */
37
+
38
+ import { homedir } from 'node:os';
39
+ import { isAbsolute, join, resolve } from 'node:path';
40
+
41
+ /** The two roots every surface derives its storage from. */
42
+ export interface GoodVibesHomeOwnership {
43
+ /** The tree root: the directory `.goodvibes/` sits under. */
44
+ readonly homeDirectory: string;
45
+ /** The daemon's own identity directory, under the tree root by default. */
46
+ readonly daemonHomeDirectory: string;
47
+ }
48
+
49
+ /**
50
+ * The tree root for this process.
51
+ *
52
+ * A blank or whitespace-only value is treated as absent rather than as the
53
+ * empty path, because `GOODVIBES_HOME=` in a unit file or a shell wrapper means
54
+ * "unset", and resolving it to `''` would put the whole tree at the filesystem
55
+ * root.
56
+ */
57
+ export function resolveGoodVibesHome(env: NodeJS.ProcessEnv = process.env): string {
58
+ const override = env['GOODVIBES_HOME']?.trim();
59
+ if (override) return isAbsolute(override) ? override : resolve(process.cwd(), override);
60
+ // The login home comes from the injected env first so the whole resolution is
61
+ // exercisable without a test mutating the environment it is running in. This
62
+ // is not a behaviour change: on Linux and macOS `homedir()` already prefers
63
+ // $HOME, and where it does not (Windows, where HOME is usually unset) the
64
+ // fallback below is what answers.
65
+ const loginHome = env['HOME']?.trim();
66
+ return loginHome ? loginHome : homedir();
67
+ }
68
+
69
+ /**
70
+ * The daemon's identity directory.
71
+ *
72
+ * @param homeDirectory - The already-resolved tree root, so a caller cannot
73
+ * accidentally combine an overridden root with a default daemon home.
74
+ */
75
+ export function resolveGoodVibesDaemonHome(
76
+ homeDirectory: string,
77
+ env: NodeJS.ProcessEnv = process.env,
78
+ ): string {
79
+ const override = env['GOODVIBES_DAEMON_HOME']?.trim();
80
+ if (!override) return join(homeDirectory, '.goodvibes', 'daemon');
81
+ return isAbsolute(override) ? override : resolve(process.cwd(), override);
82
+ }
83
+
84
+ /**
85
+ * The `.goodvibes` directory itself — the tree, not the root it sits under.
86
+ *
87
+ * This exists because the two were being derived independently and did not
88
+ * agree. `GOODVIBES_HOME` meant the tree ROOT to the runtime
89
+ * (`<GOODVIBES_HOME>/.goodvibes/...`) and meant the `.goodvibes` DIRECTORY to
90
+ * the two reporting scripts, which read it as
91
+ * `process.env.GOODVIBES_HOME ?? join(homedir(), '.goodvibes')`. Set it to
92
+ * `/tmp/sandbox` to isolate a run and the runtime wrote
93
+ * `/tmp/sandbox/.goodvibes/`, while a script inspected `/tmp/sandbox` as though
94
+ * it were already the tree — so the two halves of one round disagreed about
95
+ * which files were even in scope.
96
+ *
97
+ * That is the shape of an incident that has already happened here: a
98
+ * home-redirection variable honoured by one entry point and ignored by another
99
+ * put two throwaway credentials in the owner's live secret store. One variable
100
+ * has one meaning now, and it is the runtime's, because the runtime is what
101
+ * writes secrets and because an isolation harness needs a single root that
102
+ * everything falls under.
103
+ *
104
+ * A caller that genuinely wants to name a `.goodvibes` directory directly —
105
+ * auditing a tree that is not under any home — passes it explicitly rather than
106
+ * through the environment. Both scripts keep a `--home` flag for exactly that.
107
+ */
108
+ export function resolveGoodVibesTreeDirectory(env: NodeJS.ProcessEnv = process.env): string {
109
+ return join(resolveGoodVibesHome(env), '.goodvibes');
110
+ }
111
+
112
+ /** Both roots at once — what an entry point wants. */
113
+ export function resolveGoodVibesHomeOwnership(
114
+ env: NodeJS.ProcessEnv = process.env,
115
+ ): GoodVibesHomeOwnership {
116
+ const homeDirectory = resolveGoodVibesHome(env);
117
+ return {
118
+ homeDirectory,
119
+ daemonHomeDirectory: resolveGoodVibesDaemonHome(homeDirectory, env),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * True when either root was relocated for this process.
125
+ *
126
+ * The daemon lifecycle asks this to decide whether it may adopt the machine's
127
+ * service unit: a process told to run out of somewhere else is a throwaway, and
128
+ * one that adopted the unit had systemd supervise it as the machine's daemon
129
+ * for five hours while it read the real home's credentials.
130
+ *
131
+ * It lives here because the two variables have exactly one reader, and the
132
+ * answer is a question ABOUT them. A caller checking `process.env` for itself is
133
+ * how the second meaning that started all of this gets reintroduced — so the
134
+ * question is answered here rather than asked again somewhere else.
135
+ */
136
+ export function hasOverriddenGoodVibesHome(env: NodeJS.ProcessEnv = process.env): boolean {
137
+ return (env['GOODVIBES_HOME']?.trim() ?? '') !== ''
138
+ || (env['GOODVIBES_DAEMON_HOME']?.trim() ?? '') !== '';
139
+ }
package/src/daemon/cli.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { homedir } from 'node:os';
2
- import { isAbsolute, join, resolve } from 'node:path';
3
2
  import {
4
3
  ConfigManager,
5
4
  deriveControlPlaneBaseUrl,
@@ -8,6 +7,7 @@ import {
8
7
  } from '@pellux/goodvibes-sdk/platform/config';
9
8
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
10
9
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
10
+ import { resolveGoodVibesHomeOwnership, hasOverriddenGoodVibesHome } from '../config/goodvibes-home.ts';
11
11
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
12
12
  import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
13
13
  import { createRuntimeStore } from '../runtime/store/index.ts';
@@ -83,11 +83,11 @@ type DaemonCliTokens = {
83
83
  * daemon home then falls under it unless separately overridden.
84
84
  */
85
85
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
86
- const homeDirectory = process.env['GOODVIBES_HOME']?.trim() || homedir();
87
- const daemonHomeOverride = process.env['GOODVIBES_DAEMON_HOME']?.trim();
88
- const daemonHomeDirectory = daemonHomeOverride
89
- ? (isAbsolute(daemonHomeOverride) ? daemonHomeOverride : resolve(process.cwd(), daemonHomeOverride))
90
- : join(homeDirectory, '.goodvibes', 'daemon');
86
+ // Both roots come from src/config/goodvibes-home.ts, which the CLIENT entry
87
+ // point also uses. They were resolved independently, and the client's copy
88
+ // simply did not read GOODVIBES_HOME — so a redirected client wrote into the
89
+ // real tree while the daemon honoured the redirect.
90
+ const { homeDirectory, daemonHomeDirectory } = resolveGoodVibesHomeOwnership();
91
91
  return {
92
92
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
93
93
  homeDirectory,
@@ -314,6 +314,11 @@ async function main(): Promise<void> {
314
314
  getConversationTitle: () => 'goodvibes daemon',
315
315
  workingDir,
316
316
  homeDirectory,
317
+ // Honour --daemon-home / GOODVIBES_DAEMON_HOME on the CREDENTIAL store, not
318
+ // just the identity directory. Without this a daemon told to run out of a
319
+ // temp tree still read the real home's daemon secrets, so "isolating" a
320
+ // test daemon left it holding the owner's live credentials.
321
+ daemonHomeDirectory,
317
322
  // The standalone daemon observes externally-launched coding-agent sessions
318
323
  // on the host read-only (fleet visibility + steer; never counted, never
319
324
  // stopped). Daemon-side only — the interactive process reads this snapshot
@@ -361,6 +366,12 @@ async function main(): Promise<void> {
361
366
  // undefined for a non-binary install, so a dev run stays host-managed and
362
367
  // never swaps the interpreter. See lifecycle.ts.
363
368
  updateArtifact: resolveDaemonUpdateArtifact({ version: VERSION }),
369
+ // A daemon running out of an overridden home must never adopt the machine's
370
+ // service unit. One did: started from a scratchpad with --daemon-home, it
371
+ // found the unit not running, wrote its own scratchpad ExecStart into
372
+ // ~/.config/systemd/user/goodvibes.service and exited, and systemd then
373
+ // supervised the throwaway as the machine's daemon for five hours.
374
+ hasOverriddenHome: hasOverriddenGoodVibesHome(),
364
375
  // The SAME coordinator this repository's inbox poller registered with (see
365
376
  // runtime/cluster-composition.ts). The facade must reuse it rather than
366
377
  // compose its own: two coordinators in one process are two nodes in the
@@ -56,10 +56,6 @@ export interface DaemonHandlerSurfaceProviders {
56
56
  readonly registerInbox: (ctx: HandlerContext, routing: RoutingRegistration) => Unregister;
57
57
  /** channels.drafts.* */
58
58
  readonly registerDrafts: SurfaceRegister;
59
- /** calendar.* */
60
- readonly registerCalendar: SurfaceRegister;
61
- /** email.* */
62
- readonly registerEmail: SurfaceRegister;
63
59
  /** remote.peers.* — supplies the host DistributedRuntimeRouteService + dispatch adapter. */
64
60
  readonly registerRemote: (ctx: HandlerContext) => RemoteSurfaceRegistration;
65
61
  }
@@ -82,8 +78,13 @@ export function registerDaemonHandlers(
82
78
 
83
79
  teardowns.push(providers.registerInbox(ctx, routing));
84
80
  teardowns.push(providers.registerDrafts(ctx));
85
- teardowns.push(providers.registerCalendar(ctx));
86
- teardowns.push(providers.registerEmail(ctx));
81
+ // calendar.* and email.* are NOT registered here any more. Both are served
82
+ // by the SDK (control-plane/routes/{calendar,email}.ts over the platform
83
+ // CalDAV/Google and IMAP/SMTP implementations), registered through
84
+ // registerGatewayVerbGroups in runtime/services.ts. This product used to
85
+ // carry its own handlers for the same descriptor ids and, registering later,
86
+ // won — two implementations behind one path, which is the drift the hoist
87
+ // exists to end.
87
88
 
88
89
  const remote = providers.registerRemote(ctx);
89
90
  teardowns.push(remote.unregister);
@@ -0,0 +1,245 @@
1
+ /**
2
+ * calendar-runtime.ts — `/calendar`, the terminal surface over the daemon's
3
+ * calendar capability.
4
+ *
5
+ * As with `/mail`, no calendar logic lives here. CalDAV, ICS parsing, settings
6
+ * resolution and the confirmation gate are all in
7
+ * `src/daemon/handlers/calendar/`; this file parses arguments, renders results,
8
+ * and refuses honestly when the capability is not set up.
9
+ *
10
+ * ## What is exposed, and what is not
11
+ *
12
+ * `list` (the agenda) and `get` are exposed: knowing what is next without
13
+ * leaving the terminal is the whole value of a calendar on this surface.
14
+ * `create` is exposed behind the same preview-then-`--confirm` step `/mail
15
+ * send` uses, because the daemon gates it on both `body.confirm` and an
16
+ * explicit user request, and because an event created by a typo is a real
17
+ * nuisance to undo.
18
+ *
19
+ * `calendar.ics.import` and `calendar.ics.export` are deliberately NOT given a
20
+ * command. They are bulk file-level transfers of a whole calendar — an
21
+ * operation whose natural home is a file path and a scheduled job, not a line
22
+ * typed into a coding session. They stay available to any surface through the
23
+ * same catalog; this product simply does not claim to be the right place for
24
+ * them. That is a scoping decision, recorded here so it reads as a choice
25
+ * rather than an omission.
26
+ */
27
+
28
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
29
+ import {
30
+ EXPLICIT_WRITE_INVOCATION_CONTEXT,
31
+ READ_INVOCATION_CONTEXT,
32
+ errorText,
33
+ probeConnection,
34
+ renderConnectionStatus,
35
+ } from './connection-status.ts';
36
+
37
+ /** Agenda item, exactly the daemon's `calendar.events.list` item shape. */
38
+ interface CalendarEvent {
39
+ readonly id: string;
40
+ readonly title: string;
41
+ readonly start: string;
42
+ readonly end: string;
43
+ readonly location?: string;
44
+ readonly description?: string;
45
+ readonly attendees?: readonly string[];
46
+ }
47
+
48
+ const DEFAULT_LIST_LIMIT = 20;
49
+ const MAX_LIST_LIMIT = 200;
50
+
51
+ /**
52
+ * Render a timestamp for a terminal agenda.
53
+ *
54
+ * An unparseable value is printed verbatim rather than replaced with a
55
+ * placeholder — showing what the server actually returned is more useful than
56
+ * hiding it behind "invalid date".
57
+ */
58
+ export function formatWhen(raw: string): string {
59
+ const parsed = new Date(raw);
60
+ if (Number.isNaN(parsed.getTime())) return raw;
61
+ const date = parsed.toISOString().slice(0, 10);
62
+ const time = parsed.toISOString().slice(11, 16);
63
+ return `${date} ${time}`;
64
+ }
65
+
66
+ function truncate(value: string, width: number): string {
67
+ const flat = value.replace(/\s+/g, ' ').trim();
68
+ return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
69
+ }
70
+
71
+ export function renderAgenda(events: readonly CalendarEvent[]): string {
72
+ if (events.length === 0) return 'No events in this window.';
73
+ const lines = [`Agenda — ${events.length} event${events.length === 1 ? '' : 's'}:`];
74
+ for (const event of events) {
75
+ const where = event.location ? ` @ ${truncate(event.location, 24)}` : '';
76
+ lines.push(` ${formatWhen(event.start)} ${truncate(event.title, 44).padEnd(44)}${where}`);
77
+ }
78
+ lines.push('', ' /calendar get <id> — open one event');
79
+ return lines.join('\n');
80
+ }
81
+
82
+ export function renderEvent(event: CalendarEvent): string {
83
+ const lines = [
84
+ `Title: ${event.title}`,
85
+ `Start: ${formatWhen(event.start)}`,
86
+ `End: ${formatWhen(event.end)}`,
87
+ ];
88
+ if (event.location) lines.push(`Where: ${event.location}`);
89
+ const attendees = event.attendees ?? [];
90
+ if (attendees.length > 0) lines.push(`With: ${attendees.join(', ')}`);
91
+ if (event.description && event.description.trim().length > 0) {
92
+ lines.push('', event.description.trimEnd());
93
+ }
94
+ return lines.join('\n');
95
+ }
96
+
97
+ /**
98
+ * Split `title | start | end` on unescaped pipes — same separator rule as
99
+ * `/mail`, for the same reason: titles contain spaces and per-command quoting
100
+ * conventions are their own bug source.
101
+ */
102
+ export function parseEventArgs(raw: string): { title: string; start: string; end: string } | null {
103
+ const parts = raw.split('|').map((part) => part.trim());
104
+ if (parts.length !== 3) return null;
105
+ const [title, start, end] = parts;
106
+ if (!title || !start || !end) return null;
107
+ return { title, start, end };
108
+ }
109
+
110
+ function parseLimit(raw: string | undefined): number {
111
+ const parsed = Number(raw);
112
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_LIMIT;
113
+ return Math.min(MAX_LIST_LIMIT, Math.floor(parsed));
114
+ }
115
+
116
+ const USAGE = [
117
+ 'Usage:',
118
+ ' /calendar connection status and the agenda ahead',
119
+ ' /calendar status connection status only',
120
+ ' /calendar list [n] the next n events (default 20)',
121
+ ' /calendar get <id> open one event',
122
+ ' /calendar create <title | start | end> [--confirm] preview, then create',
123
+ '',
124
+ ' Times are ISO-8601, e.g. 2026-08-01T14:00:00Z',
125
+ ].join('\n');
126
+
127
+ export function registerCalendarRuntimeCommands(registry: CommandRegistry): void {
128
+ registry.register({
129
+ name: 'calendar',
130
+ aliases: ['cal'],
131
+ description: 'Calendar over the daemon: connection status, agenda, event detail, create',
132
+ usage: '[status|list|get|create] …',
133
+ argsHint: '[status|list <n>|get <id>|create]',
134
+ async handler(args, ctx) {
135
+ const gateway = ctx.workspace.gatewayMethods;
136
+ const sub = (args[0] ?? '').toLowerCase();
137
+
138
+ if (sub === 'status') {
139
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'calendar')));
140
+ return;
141
+ }
142
+
143
+ if (!gateway) {
144
+ ctx.print(renderConnectionStatus(await probeConnection(undefined, 'calendar')));
145
+ return;
146
+ }
147
+
148
+ if (sub === '' || sub === 'list') {
149
+ await listEvents(ctx, gateway, parseLimit(args[1]), sub === '');
150
+ return;
151
+ }
152
+ if (sub === 'get') {
153
+ await getEvent(ctx, gateway, args[1]);
154
+ return;
155
+ }
156
+ if (sub === 'create') {
157
+ await createEvent(ctx, gateway, args.slice(1));
158
+ return;
159
+ }
160
+ ctx.print(USAGE);
161
+ },
162
+ });
163
+ }
164
+
165
+ type Gateway = NonNullable<CommandContext['workspace']['gatewayMethods']>;
166
+
167
+ async function withStatusOnFailure(
168
+ ctx: CommandContext,
169
+ gateway: Gateway,
170
+ label: string,
171
+ run: () => Promise<void>,
172
+ ): Promise<void> {
173
+ try {
174
+ await run();
175
+ } catch (error) {
176
+ ctx.print(`[calendar ${label}] ${errorText(error)}`);
177
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'calendar')));
178
+ }
179
+ }
180
+
181
+ async function listEvents(ctx: CommandContext, gateway: Gateway, limit: number, withStatus: boolean): Promise<void> {
182
+ await withStatusOnFailure(ctx, gateway, 'list', async () => {
183
+ const result = await gateway.invoke('calendar.events.list', {
184
+ ...READ_INVOCATION_CONTEXT,
185
+ body: { limit, from: new Date().toISOString() },
186
+ }) as { readonly events?: readonly CalendarEvent[] };
187
+ if (withStatus) {
188
+ ctx.print(renderConnectionStatus({
189
+ surface: 'calendar',
190
+ state: 'ready',
191
+ detail: 'Calendar is connected — the daemon reached the server and returned a result.',
192
+ nextActions: [],
193
+ }));
194
+ }
195
+ ctx.print(renderAgenda(result.events ?? []));
196
+ });
197
+ }
198
+
199
+ async function getEvent(ctx: CommandContext, gateway: Gateway, eventId: string | undefined): Promise<void> {
200
+ if (!eventId) {
201
+ ctx.print('Usage: /calendar get <id> — the id from /calendar list.');
202
+ return;
203
+ }
204
+ await withStatusOnFailure(ctx, gateway, 'get', async () => {
205
+ const event = await gateway.invoke('calendar.events.get', {
206
+ ...READ_INVOCATION_CONTEXT,
207
+ body: { eventId },
208
+ }) as CalendarEvent;
209
+ ctx.print(renderEvent(event));
210
+ });
211
+ }
212
+
213
+ async function createEvent(ctx: CommandContext, gateway: Gateway, rest: readonly string[]): Promise<void> {
214
+ const raw = rest.filter((part) => part !== '--confirm').join(' ');
215
+ const confirmed = rest.includes('--confirm');
216
+ const parsed = parseEventArgs(raw);
217
+ if (parsed === null) {
218
+ ctx.print([
219
+ 'Usage: /calendar create <title | start | end>',
220
+ ' Three fields separated by | — for example:',
221
+ ' /calendar create Design review | 2026-08-01T14:00:00Z | 2026-08-01T15:00:00Z',
222
+ ].join('\n'));
223
+ return;
224
+ }
225
+
226
+ if (!confirmed) {
227
+ ctx.print([
228
+ 'This would create:',
229
+ ` Title: ${parsed.title}`,
230
+ ` Start: ${formatWhen(parsed.start)}`,
231
+ ` End: ${formatWhen(parsed.end)}`,
232
+ '',
233
+ 'Nothing has been created. Re-run the same line with --confirm to create it.',
234
+ ].join('\n'));
235
+ return;
236
+ }
237
+
238
+ await withStatusOnFailure(ctx, gateway, 'create', async () => {
239
+ const result = await gateway.invoke('calendar.events.create', {
240
+ ...EXPLICIT_WRITE_INVOCATION_CONTEXT,
241
+ body: { title: parsed.title, start: parsed.start, end: parsed.end, confirm: true },
242
+ }) as { readonly eventId: string; readonly createdAt: string };
243
+ ctx.print(`Created "${parsed.title}" (event id ${result.eventId}, at ${result.createdAt}).`);
244
+ });
245
+ }