@ccmsg/cli 0.14.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -1,4 +1,3 @@
1
- import { realpathSync } from "node:fs";
2
1
  import { realpath } from "node:fs/promises";
3
2
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
4
3
  import type { FileKind, Role, Sid } from "@ccmsg/protocol";
@@ -28,7 +27,7 @@ export interface SessionRoots {
28
27
 
29
28
  /** Who states a session's allowlists. */
30
29
  export interface RootsSource {
31
- roots(sid: Sid): SessionRoots | undefined;
30
+ roots(sid: Sid): Promise<SessionRoots | undefined>;
32
31
  }
33
32
 
34
33
  /** One path, decided.
@@ -71,7 +70,7 @@ export class Containment {
71
70
 
72
71
  /** A path named by kind, as an op's arguments give it. */
73
72
  async locate(args: PathArgs, viewer: Viewer = {}): Promise<Located> {
74
- const roots = this.rootsFor(args.sid, viewer);
73
+ const roots = await this.rootsFor(args.sid, viewer);
75
74
  const named = await this.absolute(args, roots);
76
75
  const real = await canonical(named);
77
76
  return { ...(await this.admit(args.kind, real, roots)), named };
@@ -85,7 +84,7 @@ export class Containment {
85
84
  async identify(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located | undefined> {
86
85
  let roots: SessionRoots;
87
86
  try {
88
- roots = this.rootsFor(sid, viewer);
87
+ roots = await this.rootsFor(sid, viewer);
89
88
  } catch {
90
89
  return undefined;
91
90
  }
@@ -108,7 +107,7 @@ export class Containment {
108
107
  * rather than as forbidden — the path is reachable, and only writing there
109
108
  * is not. */
110
109
  async inbox(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located> {
111
- const roots = this.rootsFor(sid, viewer);
110
+ const roots = await this.rootsFor(sid, viewer);
112
111
  const cwd = roots.cwd;
113
112
  if (cwd === undefined || !isAbsolute(cwd)) {
114
113
  throw new OpError("path_forbidden", `${sid} states no working directory to write into`);
@@ -128,14 +127,14 @@ export class Containment {
128
127
  return this.locate({ sid: args.sid, kind: args.kind, path: args.path ?? "" }, viewer);
129
128
  }
130
129
 
131
- private rootsFor(sid: Sid, viewer: Viewer): SessionRoots {
130
+ private async rootsFor(sid: Sid, viewer: Viewer): Promise<SessionRoots> {
132
131
  if (!sees(sid, viewer)) {
133
132
  throw new OpError(
134
133
  "path_forbidden",
135
134
  `the files of ${sid} are outside this connection's range`,
136
135
  );
137
136
  }
138
- const roots = this.source.roots(sid);
137
+ const roots = await this.source.roots(sid);
139
138
  if (roots === undefined) {
140
139
  throw new OpError("path_forbidden", `nothing is known about the files of ${sid}`);
141
140
  }
@@ -256,22 +255,6 @@ export async function canonical(path: string): Promise<string> {
256
255
  }
257
256
  }
258
257
 
259
- /** The synchronous form required while `session.status` states its value synchronously (DESIGN §6); an asynchronous topic value uses `canonical` as the single path. */
260
- export function canonicalSync(path: string): string {
261
- const absolute = resolve(path);
262
- try {
263
- return realpathSync(absolute);
264
- } catch {
265
- const parent = dirname(absolute);
266
- if (parent === absolute) return absolute;
267
- try {
268
- return join(realpathSync(parent), basename(absolute));
269
- } catch {
270
- return absolute;
271
- }
272
- }
273
- }
274
-
275
258
  /** Whether a resolved path is the root or below it. Shared with the launcher,
276
259
  * whose roots come from config rather than from a session: what "inside" means
277
260
  * is the same question, and two spellings of it could come apart. */
@@ -51,7 +51,7 @@ import {
51
51
  SessionStatus,
52
52
  } from "../sessions/index.ts";
53
53
  import { topicHandlers, Topics } from "../topics/index.ts";
54
- import { TranscriptFiles, Transcripts } from "../transcript/index.ts";
54
+ import { FoldCache, TranscriptFiles, Transcripts } from "../transcript/index.ts";
55
55
  import {
56
56
  type AuthorizedUpgrade,
57
57
  ConnRegistry,
@@ -502,6 +502,9 @@ export class Instance {
502
502
  this.#transcripts = new Transcripts({
503
503
  self: this.self,
504
504
  pathOf: (sid) => transcriptFiles.path(sid),
505
+ // What a past reading of each transcript reached, so a transcript is
506
+ // read from its beginning once rather than once per start.
507
+ cache: new FoldCache(join(paths.cacheDir, "transcripts")),
505
508
  publish: (topic, data) => {
506
509
  this.#topics.publish(topic, data);
507
510
  },
@@ -536,7 +539,7 @@ export class Instance {
536
539
  },
537
540
  ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
538
541
  onChanged: () => {
539
- this.#status.refresh();
542
+ void this.#status.refresh();
540
543
  // A session that is live again is one route (a) can be tried against,
541
544
  // which is what the inbox is waiting for (DESIGN §6.7).
542
545
  void this.#delivery.retry();
@@ -559,12 +562,15 @@ export class Instance {
559
562
  release: (sid) => {
560
563
  this.#transcripts.release(sid);
561
564
  },
565
+ ready: (sid) => this.#transcripts.ready(sid),
562
566
  publish: (topic, data) => {
563
567
  this.#topics.publish(topic, data);
564
568
  },
565
569
  });
566
570
 
567
- const inbox = new Inbox(inboxPath(paths.stateDir));
571
+ const inbox = new Inbox(inboxPath(paths.stateDir), (msg, fields) => {
572
+ this.log.write(msg, fields);
573
+ });
568
574
  inbox.load();
569
575
  this.#persisted.push(inbox);
570
576
  // Route (a) is the harness's own way in (DESIGN §6.5): Claude Code's messaging
@@ -662,10 +668,11 @@ export class Instance {
662
668
  // greeting says where the session works, and the fold says which folders
663
669
  // its editor names and which files outside them its transcript named.
664
670
  const files = new Containment({
665
- roots: (sid): SessionRoots | undefined => {
671
+ roots: async (sid): Promise<SessionRoots | undefined> => {
666
672
  const where = this.#sessions.where(sid);
667
673
  if (where.root === undefined && where.cwd === undefined) return undefined;
668
- const status = sessionStatusOf(sid, this.#transcripts.facts(sid), where);
674
+ await this.#transcripts.ready(sid);
675
+ const status = await sessionStatusOf(sid, this.#transcripts.facts(sid), where);
669
676
  return {
670
677
  ...where,
671
678
  workspace_folders: status.workspace_folders.map((folder) => folder.path),
@@ -41,6 +41,11 @@ export interface InstancePaths {
41
41
  /** Where a file is put before it is overwritten by the checked copy. */
42
42
  readonly rejectedDir: string;
43
43
  readonly stateDir: string;
44
+ /** Where what was derived from a file is kept so it need not be derived
45
+ * again. Everything here can be rebuilt from the file it came from, so the
46
+ * directory may be emptied at any moment and the only cost is the work of
47
+ * deriving it once more. */
48
+ readonly cacheDir: string;
44
49
  /** The address clients connect to. A symlink to whichever `socketReal` is
45
50
  * currently serving, so a client's path outlives the process behind it. */
46
51
  readonly socket: string;
@@ -148,6 +153,7 @@ export function resolvePathsFor(configHome: string, env: Env = process.env): Ins
148
153
  satisfiedFile: join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE),
149
154
  rejectedDir: join(stateRoot, REJECTED_DIR),
150
155
  stateDir,
156
+ cacheDir: appDir(env, "CCMSG_CACHE_DIR", "XDG_CACHE_HOME", [".cache"], key),
151
157
  socketDir,
152
158
  socket: join(socketDir, SOCKET_NAME),
153
159
  socketReal: join(socketDir, realSocketName(process.pid)),
@@ -4,10 +4,12 @@ import { dirname, join } from "node:path";
4
4
  import {
5
5
  INBOX_MAX_PER_SID,
6
6
  INBOX_RETENTION_MS,
7
- type InboxMessage,
7
+ InboxMessage,
8
8
  type InboxRemovedReason,
9
- type Sid,
9
+ isValid,
10
+ Sid,
10
11
  type Timestamp,
12
+ validationErrors,
11
13
  } from "@ccmsg/protocol";
12
14
 
13
15
  export const INBOX_FILE = "inbox.jsonl";
@@ -50,7 +52,12 @@ export class Inbox {
50
52
  * subscribed for them to be news to. */
51
53
  #onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
52
54
 
53
- constructor(private readonly file: string) {}
55
+ constructor(
56
+ private readonly file: string,
57
+ /** Where a line the replay could not keep is named. Absent in the tests
58
+ * that are about the holding rather than about what is said of it. */
59
+ private readonly log: (message: string, fields: Record<string, unknown>) => void = () => {},
60
+ ) {}
54
61
 
55
62
  /** Hear about messages leaving. */
56
63
  onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
@@ -79,6 +86,7 @@ export class Inbox {
79
86
  // The last line of a file the daemon was killed while writing.
80
87
  continue;
81
88
  }
89
+ if (record.v === "add" && !this.#stateable(record.sid, record.message)) continue;
82
90
  this.#replay(record);
83
91
  }
84
92
  this.#expire(now);
@@ -168,6 +176,30 @@ export class Inbox {
168
176
  return highest;
169
177
  }
170
178
 
179
+ /** Whether a line read back is a message the contract can state.
180
+ *
181
+ * The file outlives the contract that wrote it, and what is held is answered
182
+ * to a person as rows of the `inbox` topic — so a single line the contract
183
+ * has since outgrown, replayed as if it were current, is a frame the reader
184
+ * refuses and a whole view lost for it. An instance states only what the
185
+ * contract can say, about its own file as much as about anything else.
186
+ *
187
+ * Dropped rather than mended: the message is the sender's words and the
188
+ * contract is what says how they are spelled, so there is nothing here that
189
+ * could write a spelling the contract would accept without inventing it. It
190
+ * leaves through the compaction that follows the replay, which writes back
191
+ * only what is held; nothing is appended and no removal is stated, since a
192
+ * replay has nobody subscribed to hear one and no `mid` the contract would
193
+ * take to name it by. */
194
+ #stateable(sid: unknown, message: unknown): boolean {
195
+ const why = isValid(Sid, sid)
196
+ ? validationErrors(InboxMessage, message)
197
+ : [`sid: ${JSON.stringify(sid)} is no session id`];
198
+ if (why.length === 0) return true;
199
+ this.log("dropped an inbox record the contract cannot state", { sid, why });
200
+ return false;
201
+ }
202
+
171
203
  #replay(record: Record_): void {
172
204
  if (record.v === "add") {
173
205
  const held = this.#held.get(record.sid) ?? [];
@@ -41,7 +41,7 @@ export async function forkOrigin(
41
41
  sid: Sid,
42
42
  files: TranscriptFiles,
43
43
  ): Promise<ForkOrigin | undefined> {
44
- const file = files.session(sid);
44
+ const file = await files.session(sid);
45
45
  const ours = await recordIds(file);
46
46
  const head = ours?.[0];
47
47
  if (ours === undefined || head === undefined) return undefined;
@@ -171,8 +171,9 @@ export class HarnessSessions implements OwnSessions {
171
171
  * here too: a pid from a poll that has not run is a number belonging to
172
172
  * nobody.
173
173
  *
174
- * Read in place because the directory is a handful of small files of this
175
- * uid's own config home (M6) — a syscall or two per session, not a wait. */
174
+ * Read in place because the answer may not depend on anybody waiting: what
175
+ * this states is that a session exists, and a reading that could be waited
176
+ * for would make it something the callers above cannot ask (DESIGN §4.2). */
176
177
  scan(): ReadonlyMap<Sid, AgentInfo> {
177
178
  const rows = new Map<Sid, AgentInfo>();
178
179
  const names = this.#watch.names().filter((name) => STATE_FILE.test(name));
@@ -248,9 +249,8 @@ class DirectoryWatch {
248
249
  this.#timer = undefined;
249
250
  }
250
251
 
251
- /** What is in the directory now. Read in place because it is a handful of
252
- * small entries of this uid's own config home (M6) a syscall or two, not a
253
- * wait. */
252
+ /** What is in the directory now, read in place for the reason `scan()` is:
253
+ * it is the same answer, and the callers above it cannot wait for one. */
254
254
  names(): string[] {
255
255
  try {
256
256
  return readdirSync(this.dir);
@@ -5,7 +5,7 @@ import type {
5
5
  SessionStatusSnapshot,
6
6
  Sid,
7
7
  } from "@ccmsg/protocol";
8
- import { canonicalSync, within } from "../files/containment.ts";
8
+ import { canonical, within } from "../files/containment.ts";
9
9
  import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
10
  import { topicParam } from "../topics/index.ts";
11
11
  import type { TranscriptFacts } from "../transcript/index.ts";
@@ -35,15 +35,26 @@ export function stoppedOn(facts: TranscriptFacts): SessionApiError | undefined {
35
35
  * root is known. A session that stated no root contributes none of them rather
36
36
  * than all of them: the list is the allowlist an `external` read is checked
37
37
  * against, so not knowing where the session works has to admit nothing. */
38
- export function sessionStatusOf(
38
+ export async function sessionStatusOf(
39
39
  sid: Sid,
40
40
  facts: TranscriptFacts,
41
41
  where: SessionWhere = {},
42
- ): SessionStatusSnapshot & {
43
- sid: Sid;
44
- } {
42
+ ): Promise<
43
+ SessionStatusSnapshot & {
44
+ sid: Sid;
45
+ }
46
+ > {
45
47
  const stopped = stoppedOn(facts);
46
- const root = where.root === undefined ? undefined : canonicalSync(where.root);
48
+ const root = where.root === undefined ? undefined : await canonical(where.root);
49
+ const named =
50
+ root === undefined
51
+ ? []
52
+ : await Promise.all(
53
+ facts.named_files.map(async (file) => ({
54
+ file,
55
+ real: await canonical(file.path),
56
+ })),
57
+ );
47
58
  return {
48
59
  sid,
49
60
  todos: [...facts.todos],
@@ -54,8 +65,8 @@ export function sessionStatusOf(
54
65
  external_files:
55
66
  root === undefined
56
67
  ? []
57
- : facts.named_files.filter((file) => !within(canonicalSync(file.path), root)),
58
- workspace_folders: workspaceFolders(where.cwd),
68
+ : named.filter((each) => !within(each.real, root)).map((each) => each.file),
69
+ workspace_folders: await workspaceFolders(where.cwd),
59
70
  ...(stopped === undefined ? {} : { api_error: stopped }),
60
71
  };
61
72
  }
@@ -81,6 +92,10 @@ export interface SessionStatusDeps {
81
92
  /** The tail behind a session's fold, asked for and let go by name. */
82
93
  readonly hold: (sid: Sid) => void;
83
94
  readonly release: (sid: Sid) => void;
95
+ /** When the fold of a held session has read the whole transcript. What this
96
+ * owner states rests on the fold, so it waits on this before stating it
97
+ * (CT-Q8). */
98
+ readonly ready: (sid: Sid) => Promise<void>;
84
99
  /** The one way a value reaches subscribers (DESIGN §6.1). */
85
100
  readonly publish: (topic: string, data: unknown) => void;
86
101
  }
@@ -105,6 +120,15 @@ export class SessionStatus implements UpstreamResource {
105
120
  * convergence happens once rather than in the middle of itself. */
106
121
  #converging = false;
107
122
  #pending = false;
123
+ /** Which pass is the one entitled to state a value.
124
+ *
125
+ * Stating one waits on a reading, and two readings started in either order
126
+ * can finish in either order — so the later pass read the later facts, and
127
+ * the earlier one must not publish behind it. Every pass takes a number and
128
+ * gives up the moment a later one has taken a higher one: what a subscriber
129
+ * is told is what the most recent reading said, and never what an older one
130
+ * caught up to saying (DR-0015 §2.5). */
131
+ #stating = 0;
108
132
 
109
133
  constructor(private readonly deps: SessionStatusDeps) {}
110
134
 
@@ -112,16 +136,16 @@ export class SessionStatus implements UpstreamResource {
112
136
 
113
137
  start(topic: string): void {
114
138
  this.#wanted.add(topic);
115
- this.refresh();
139
+ void this.refresh();
116
140
  }
117
141
 
118
142
  stop(topic: string): void {
119
143
  this.#wanted.delete(topic);
120
- this.refresh();
144
+ void this.refresh();
121
145
  }
122
146
 
123
- snapshot(topic: string): readonly TopicValue[] {
124
- const data = this.value(topic);
147
+ async snapshot(topic: string): Promise<readonly TopicValue[]> {
148
+ const data = await this.value(topic);
125
149
  return data === undefined ? [] : [{ instance: this.deps.self, data }];
126
150
  }
127
151
 
@@ -129,7 +153,7 @@ export class SessionStatus implements UpstreamResource {
129
153
  * says, and which sessions exist, are the two things that move either.
130
154
  *
131
155
  * Called by whoever changes one of them, rather than on a timer (M3). */
132
- refresh(): void {
156
+ async refresh(): Promise<void> {
133
157
  if (this.#converging) {
134
158
  this.#pending = true;
135
159
  return;
@@ -143,8 +167,15 @@ export class SessionStatus implements UpstreamResource {
143
167
  } finally {
144
168
  this.#converging = false;
145
169
  }
146
- for (const topic of this.#wanted) {
147
- const data = this.value(topic);
170
+ // The topics as they stand now: stating one waits on a reading, and a
171
+ // subscription arriving during that wait must not be iterated into.
172
+ const stating = [...this.#wanted];
173
+ this.#stating += 1;
174
+ const mine = this.#stating;
175
+ for (const topic of stating) {
176
+ const data = await this.value(topic);
177
+ // A later pass has read later facts; this one has nothing to add.
178
+ if (mine !== this.#stating) return;
148
179
  if (data !== undefined) this.deps.publish(topic, data);
149
180
  }
150
181
  }
@@ -169,12 +200,17 @@ export class SessionStatus implements UpstreamResource {
169
200
 
170
201
  /** What a topic of this owner currently says, for a snapshot and for a
171
202
  * change alike — built here and nowhere else, so the two cannot drift. */
172
- private value(topic: string): unknown {
173
- if (topic === "session.errors") return this.errors();
203
+ private async value(topic: string): Promise<unknown> {
204
+ if (topic === "session.errors") {
205
+ // Every session's fold, so the list is of the transcripts as they read
206
+ // rather than of the ones that happen to have been read by now.
207
+ await Promise.all(this.deps.sessions().map((sid) => this.deps.ready(sid)));
208
+ return this.errors();
209
+ }
174
210
  const sid = topicParam(topic);
175
- return sid === undefined
176
- ? undefined
177
- : sessionStatusOf(sid, this.deps.facts(sid), this.deps.where(sid));
211
+ if (sid === undefined) return undefined;
212
+ await this.deps.ready(sid);
213
+ return await sessionStatusOf(sid, this.deps.facts(sid), this.deps.where(sid));
178
214
  }
179
215
 
180
216
  /** Bring the held tails in line with what the subscriptions need. */
@@ -1,4 +1,4 @@
1
- import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
4
4
  import type { WorkspaceFolder } from "@ccmsg/protocol";
@@ -14,15 +14,15 @@ import type { WorkspaceFolder } from "@ccmsg/protocol";
14
14
  *
15
15
  * A session that has no workspace file names no folders, which is what an empty
16
16
  * list means to the contract and admits no `workspace` path at all. */
17
- export function workspaceFolders(cwd: string | undefined): WorkspaceFolder[] {
17
+ export async function workspaceFolders(cwd: string | undefined): Promise<WorkspaceFolder[]> {
18
18
  if (cwd === undefined || !isAbsolute(cwd)) return [];
19
19
  const folders: WorkspaceFolder[] = [];
20
20
  const seen = new Set<string>();
21
- for (const file of workspaceFiles(cwd)) {
22
- for (const spec of specs(file)) {
21
+ for (const file of await workspaceFiles(cwd)) {
22
+ for (const spec of await specs(file)) {
23
23
  // Relative to the workspace file, which is how an editor reads them.
24
- const real = directory(resolve(dirname(file), spec.path));
25
- if (real === undefined || overbroad(real) || seen.has(real)) continue;
24
+ const real = await directory(resolve(dirname(file), spec.path));
25
+ if (real === undefined || (await overbroad(real)) || seen.has(real)) continue;
26
26
  seen.add(real);
27
27
  folders.push({ name: spec.name ?? basename(real), path: real });
28
28
  }
@@ -32,10 +32,10 @@ export function workspaceFolders(cwd: string | undefined): WorkspaceFolder[] {
32
32
 
33
33
  /** The workspace files directly beside the session's working directory, in a
34
34
  * fixed order so the same directory always states its folders the same way. */
35
- function workspaceFiles(cwd: string): string[] {
35
+ async function workspaceFiles(cwd: string): Promise<string[]> {
36
36
  let entries: string[];
37
37
  try {
38
- entries = readdirSync(cwd);
38
+ entries = await readdir(cwd);
39
39
  } catch {
40
40
  return [];
41
41
  }
@@ -47,10 +47,10 @@ function workspaceFiles(cwd: string): string[] {
47
47
 
48
48
  /** What one workspace file declares: the `folders` array, and of each entry the
49
49
  * path it names and the name it may give that path. */
50
- function specs(file: string): { path: string; name?: string }[] {
50
+ async function specs(file: string): Promise<{ path: string; name?: string }[]> {
51
51
  let parsed: unknown;
52
52
  try {
53
- parsed = JSON.parse(uncommented(readFileSync(file, "utf8")));
53
+ parsed = JSON.parse(uncommented(await readFile(file, "utf8")));
54
54
  } catch {
55
55
  // Written by hand and half-saved, or not a workspace file after all.
56
56
  return [];
@@ -113,10 +113,10 @@ function uncommented(text: string): string {
113
113
 
114
114
  /** What the filesystem calls a folder that is one. A path naming a file, or
115
115
  * nothing at all, names no folder and is dropped. */
116
- function directory(path: string): string | undefined {
116
+ async function directory(path: string): Promise<string | undefined> {
117
117
  try {
118
- const real = realpathSync(path);
119
- return statSync(real).isDirectory() ? real : undefined;
118
+ const real = await realpath(path);
119
+ return (await stat(real)).isDirectory() ? real : undefined;
120
120
  } catch {
121
121
  return undefined;
122
122
  }
@@ -127,9 +127,9 @@ function directory(path: string): string | undefined {
127
127
  * The root and the home directory and anything above them are refused: a
128
128
  * workspace file naming one of those turns the `workspace` surface into the
129
129
  * whole filesystem, and the folders are an allowlist rather than a hint. */
130
- function overbroad(real: string): boolean {
130
+ async function overbroad(real: string): Promise<boolean> {
131
131
  if (real === sep || dirname(real) === real) return true;
132
- const home = directory(homedir());
132
+ const home = await directory(homedir());
133
133
  return home !== undefined && (real === home || home.startsWith(withSep(real)));
134
134
  }
135
135
 
@@ -7,9 +7,9 @@ import type { SubscribeOutcome, Topics } from "./topics.ts";
7
7
  * it to `Topics`, and turns the outcome into the contract's own answer. */
8
8
  export function topicHandlers(topics: Topics) {
9
9
  return {
10
- "topic.subscribe": (input: HandlerInput): TopicSubscribeResult => {
10
+ "topic.subscribe": async (input: HandlerInput): Promise<TopicSubscribeResult> => {
11
11
  const topic = topicOf(input);
12
- answer(topics.subscribe(input.conn, topic), topic);
12
+ answer(await topics.subscribe(input.conn, topic), topic);
13
13
  return { topic };
14
14
  },
15
15
  "topic.unsubscribe": (input: HandlerInput): TopicSubscribeResult => {
Binary file