@pellux/goodvibes-tui 1.24.2 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/bin/goodvibes-daemon +24 -0
- package/docs/foundation-artifacts/operator-contract.json +3237 -1096
- package/package.json +7 -7
- package/src/config/goodvibes-home.ts +139 -0
- package/src/daemon/cli.ts +17 -6
- package/src/daemon/handlers/index.ts +7 -6
- package/src/input/commands/calendar-runtime.ts +245 -0
- package/src/input/commands/connection-status.ts +258 -0
- package/src/input/commands/mail-runtime.ts +267 -0
- package/src/input/commands/review-runtime.ts +13 -1
- package/src/input/commands.ts +4 -0
- package/src/input/settings-modal-connections.ts +102 -0
- package/src/input/settings-modal-types.ts +2 -1
- package/src/input/settings-modal.ts +27 -0
- package/src/main.ts +7 -2
- package/src/renderer/settings-modal-connections.ts +63 -0
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +5 -0
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/context.ts +9 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +0 -4
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/services.ts +21 -24
- package/src/shell/ui-openers.ts +7 -0
- package/src/verification/verification-ledger.ts +58 -0
- package/src/version.ts +1 -1
- package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
- package/src/daemon/handlers/calendar/ics.ts +0 -556
- package/src/daemon/handlers/calendar/index.ts +0 -316
- package/src/daemon/handlers/email/config.ts +0 -164
- package/src/daemon/handlers/email/imap-connector.ts +0 -441
- package/src/daemon/handlers/email/imap-parsing.ts +0 -499
- package/src/daemon/handlers/email/index.ts +0 -43
- package/src/daemon/handlers/email/read-handlers.ts +0 -80
- package/src/daemon/handlers/email/runtime.ts +0 -140
- package/src/daemon/handlers/email/smtp-connector.ts +0 -557
- package/src/daemon/handlers/email/validation.ts +0 -147
- 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.
|
|
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.
|
|
110
|
-
"@pellux/goodvibes-terminal-shell": "1.
|
|
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.
|
|
137
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.
|
|
138
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.
|
|
139
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
+
}
|