codekanban 0.19.0 → 0.21.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.
@@ -0,0 +1,323 @@
1
+ import { buildAgentLaunchSpec } from './command-builder.js';
2
+ import { CodeKanbanConfigError, CodeKanbanHttpError, CodeKanbanValidationError } from './errors.js';
3
+ import { TerminalConnection } from './terminal-connection.js';
4
+ import {
5
+ ensureOptionalString,
6
+ ensureString,
7
+ normalizeBaseUrl,
8
+ normalizeFsPath,
9
+ normalizeTerminalEnter,
10
+ pathBasename,
11
+ sleep,
12
+ toWsUrl,
13
+ } from './utils.js';
14
+
15
+ export class CodeKanbanClient {
16
+ constructor(options = {}) {
17
+ this.baseURL = normalizeBaseUrl(options.baseURL);
18
+ this.headers = { ...(options.headers || {}) };
19
+ this.fetchImpl = options.fetchImpl || globalThis.fetch;
20
+ this.WebSocketImpl = options.WebSocketImpl || globalThis.WebSocket;
21
+ if (!this.fetchImpl) {
22
+ throw new CodeKanbanConfigError('fetch implementation is unavailable');
23
+ }
24
+ }
25
+
26
+ async requestJson(path, options = {}) {
27
+ const method = options.method || 'GET';
28
+ const headers = { Accept: 'application/json', ...this.headers, ...(options.headers || {}) };
29
+ const request = {
30
+ method,
31
+ headers,
32
+ };
33
+ if (options.body !== undefined) {
34
+ request.body = JSON.stringify(options.body);
35
+ request.headers['Content-Type'] = 'application/json';
36
+ }
37
+ const response = await this.fetchImpl(new URL(path, this.baseURL), request);
38
+ const text = await response.text();
39
+ const body = text ? JSON.parse(text) : null;
40
+ if (!response.ok) {
41
+ throw new CodeKanbanHttpError(`request failed with ${response.status}`, {
42
+ status: response.status,
43
+ method,
44
+ path,
45
+ body,
46
+ });
47
+ }
48
+ return body;
49
+ }
50
+
51
+ async listProjects() {
52
+ const response = await this.requestJson('/api/v1/projects');
53
+ return response?.items || [];
54
+ }
55
+
56
+ async getProject(projectId) {
57
+ ensureString(projectId, 'projectId');
58
+ const response = await this.requestJson(`/api/v1/projects/${projectId}`);
59
+ return response?.item;
60
+ }
61
+
62
+ async createProject({ path, name, description = '', worktreeBasePath, hidePath }) {
63
+ ensureString(path, 'path');
64
+ ensureString(name, 'name');
65
+ const response = await this.requestJson('/api/v1/projects/create', {
66
+ method: 'POST',
67
+ body: {
68
+ path,
69
+ name,
70
+ description,
71
+ worktreeBasePath,
72
+ hidePath,
73
+ },
74
+ });
75
+ return response?.item;
76
+ }
77
+
78
+ async listWorktrees(projectId) {
79
+ ensureString(projectId, 'projectId');
80
+ const response = await this.requestJson(`/api/v1/projects/${projectId}/worktrees`);
81
+ return response?.items || [];
82
+ }
83
+
84
+ async listTerminalSessions(projectId) {
85
+ ensureString(projectId, 'projectId');
86
+ const response = await this.requestJson(`/api/v1/projects/${projectId}/terminals`);
87
+ return response?.items || [];
88
+ }
89
+
90
+ async listAISessionsByProject(projectId) {
91
+ ensureString(projectId, 'projectId');
92
+ const response = await this.requestJson(`/api/v1/projects/${projectId}/ai-sessions`);
93
+ return response?.item || null;
94
+ }
95
+
96
+ async listAISessionsByPath(projectPath) {
97
+ ensureString(projectPath, 'path');
98
+ const response = await this.requestJson('/api/v1/ai-sessions/by-path', {
99
+ method: 'POST',
100
+ body: { path: projectPath },
101
+ });
102
+ return response?.item || null;
103
+ }
104
+
105
+ async getAISessionConversation({ id, sessionId, refresh = false }) {
106
+ const dbId = ensureOptionalString(id);
107
+ const rawSessionId = ensureOptionalString(sessionId);
108
+ if (!dbId && !rawSessionId) {
109
+ throw new CodeKanbanValidationError('id or sessionId is required');
110
+ }
111
+ if (refresh && !dbId) {
112
+ throw new CodeKanbanValidationError('refresh currently requires a database id');
113
+ }
114
+
115
+ if (dbId) {
116
+ const path = refresh ? `/api/v1/ai-sessions/${dbId}/refresh` : `/api/v1/ai-sessions/${dbId}/conversation`;
117
+ const response = await this.requestJson(path, { method: refresh ? 'POST' : 'GET' });
118
+ return response?.item || null;
119
+ }
120
+
121
+ const response = await this.requestJson(`/api/v1/ai-sessions/by-session-id/${rawSessionId}/conversation`);
122
+ return response?.item || null;
123
+ }
124
+
125
+ async getAISessionToolResult({ id, sessionId, toolUseId }) {
126
+ const dbId = ensureOptionalString(id);
127
+ const rawSessionId = ensureOptionalString(sessionId);
128
+ const resolvedToolUseId = ensureString(toolUseId, 'toolUseId');
129
+ if (!dbId && !rawSessionId) {
130
+ throw new CodeKanbanValidationError('id or sessionId is required');
131
+ }
132
+ const path = dbId
133
+ ? `/api/v1/ai-sessions/${dbId}/conversation/tool-results/${resolvedToolUseId}`
134
+ : `/api/v1/ai-sessions/by-session-id/${rawSessionId}/conversation/tool-results/${resolvedToolUseId}`;
135
+ const response = await this.requestJson(path);
136
+ return response?.item || null;
137
+ }
138
+
139
+ async createTerminalSession({ projectId, worktreeId, workingDir = '', title = '', rows = 0, cols = 0, taskId = '' }) {
140
+ ensureString(projectId, 'projectId');
141
+ ensureString(worktreeId, 'worktreeId');
142
+ const response = await this.requestJson(`/api/v1/projects/${projectId}/worktrees/${worktreeId}/terminals`, {
143
+ method: 'POST',
144
+ body: {
145
+ workingDir,
146
+ title,
147
+ rows,
148
+ cols,
149
+ taskId,
150
+ },
151
+ });
152
+ return response?.item;
153
+ }
154
+
155
+ async resolveProject({ projectId, path, ensureProject = true }) {
156
+ const resolvedProjectId = ensureOptionalString(projectId);
157
+ const resolvedPath = ensureOptionalString(path);
158
+
159
+ if (resolvedProjectId) {
160
+ const project = await this.getProject(resolvedProjectId);
161
+ return {
162
+ project,
163
+ matchedBy: 'projectId',
164
+ };
165
+ }
166
+
167
+ if (!resolvedPath) {
168
+ throw new CodeKanbanValidationError('projectId or path is required');
169
+ }
170
+
171
+ const target = normalizeFsPath(resolvedPath);
172
+ const projects = await this.listProjects();
173
+ const project = projects.find(item => normalizeFsPath(item.path) === target);
174
+ if (project) {
175
+ return {
176
+ project,
177
+ matchedBy: 'path',
178
+ };
179
+ }
180
+
181
+ if (!ensureProject) {
182
+ throw new CodeKanbanValidationError(`no CodeKanban project is registered for path: ${resolvedPath}`);
183
+ }
184
+
185
+ const created = await this.createProject({
186
+ path: resolvedPath,
187
+ name: pathBasename(resolvedPath),
188
+ description: '',
189
+ });
190
+ return {
191
+ project: created,
192
+ matchedBy: 'created',
193
+ };
194
+ }
195
+
196
+ async resolveWorktree({ projectId, worktreeId }) {
197
+ const project = ensureString(projectId, 'projectId');
198
+ const preferredWorktreeId = ensureOptionalString(worktreeId);
199
+ const worktrees = await this.listWorktrees(project);
200
+ if (worktrees.length === 0) {
201
+ throw new CodeKanbanValidationError(`no worktrees are available for project ${project}`);
202
+ }
203
+ if (preferredWorktreeId) {
204
+ const direct = worktrees.find(item => item.id === preferredWorktreeId);
205
+ if (!direct) {
206
+ throw new CodeKanbanValidationError(`worktree ${preferredWorktreeId} was not found in project ${project}`);
207
+ }
208
+ return direct;
209
+ }
210
+ return worktrees.find(item => item.isMain) || worktrees[0];
211
+ }
212
+
213
+ connectTerminal({ sessionId, wsPath, wsUrl }) {
214
+ const resolvedSessionId = ensureString(sessionId, 'sessionId');
215
+ const resolvedPath =
216
+ ensureOptionalString(wsUrl) || ensureOptionalString(wsPath) || `/api/v1/terminal/ws?sessionId=${resolvedSessionId}`;
217
+ const url = resolvedPath.startsWith('ws://') || resolvedPath.startsWith('wss://') ? resolvedPath : toWsUrl(this.baseURL, resolvedPath);
218
+
219
+ return new TerminalConnection({
220
+ sessionId: resolvedSessionId,
221
+ url,
222
+ WebSocketImpl: this.WebSocketImpl,
223
+ });
224
+ }
225
+
226
+ async listSessions({ projectId, path, includeTerminal = true, includeAI = true, ensureProject = true }) {
227
+ const { project, matchedBy } = await this.resolveProject({ projectId, path, ensureProject });
228
+
229
+ const [terminalSessions, aiSessions] = await Promise.all([
230
+ includeTerminal ? this.listTerminalSessions(project.id) : Promise.resolve([]),
231
+ includeAI
232
+ ? this.listAISessionsByProject(project.id)
233
+ : Promise.resolve({
234
+ hasClaudeCode: false,
235
+ hasCodex: false,
236
+ claudeSessions: [],
237
+ codexSessions: [],
238
+ }),
239
+ ]);
240
+
241
+ return {
242
+ project,
243
+ matchedBy,
244
+ terminalSessions,
245
+ aiSessions,
246
+ };
247
+ }
248
+
249
+ async startWorkflow(input = {}) {
250
+ const launch = buildAgentLaunchSpec(input);
251
+ const { project, matchedBy } = await this.resolveProject({
252
+ projectId: input.projectId,
253
+ path: input.path,
254
+ ensureProject: true,
255
+ });
256
+ const worktree = await this.resolveWorktree({
257
+ projectId: project.id,
258
+ worktreeId: input.worktreeId,
259
+ });
260
+
261
+ const terminal = await this.createTerminalSession({
262
+ projectId: project.id,
263
+ worktreeId: worktree.id,
264
+ workingDir: ensureOptionalString(input.workingDir) || worktree.path,
265
+ title: ensureOptionalString(input.title) || ensureOptionalString(input.prompt) || 'AI workflow',
266
+ taskId: ensureOptionalString(input.taskId),
267
+ rows: Number.isFinite(input.rows) ? input.rows : 0,
268
+ cols: Number.isFinite(input.cols) ? input.cols : 0,
269
+ });
270
+
271
+ const connection = this.connectTerminal({
272
+ sessionId: terminal.id,
273
+ wsPath: terminal.wsPath,
274
+ wsUrl: terminal.wsUrl,
275
+ });
276
+
277
+ await connection.waitForReady();
278
+ connection.sendInput(normalizeTerminalEnter(launch.command));
279
+ await sleep(500);
280
+ connection.sendInput(normalizeTerminalEnter(launch.prompt));
281
+ const metadata = await connection.waitForMetadata();
282
+
283
+ return {
284
+ project,
285
+ matchedBy,
286
+ worktree,
287
+ terminalSession: terminal,
288
+ agent: launch.agent,
289
+ profile: launch.profile,
290
+ command: launch.command,
291
+ prompt: launch.prompt,
292
+ promptAccepted: true,
293
+ aiSessionId: metadata?.aiSessionId,
294
+ connection,
295
+ };
296
+ }
297
+
298
+ async continueTerminalSession({ projectId, path, sessionId, prompt }) {
299
+ const resolvedSessionId = ensureString(sessionId, 'sessionId');
300
+ const resolvedPrompt = ensureString(prompt, 'prompt');
301
+
302
+ let project;
303
+ if (projectId || path) {
304
+ ({ project } = await this.resolveProject({ projectId, path, ensureProject: true }));
305
+ const sessions = await this.listTerminalSessions(project.id);
306
+ const exists = sessions.some(item => item.id === resolvedSessionId);
307
+ if (!exists) {
308
+ throw new CodeKanbanValidationError(`terminal session ${resolvedSessionId} does not belong to project ${project.id}`);
309
+ }
310
+ }
311
+
312
+ const connection = this.connectTerminal({ sessionId: resolvedSessionId });
313
+ await connection.waitForReady();
314
+ connection.sendInput(normalizeTerminalEnter(resolvedPrompt));
315
+ return {
316
+ project,
317
+ sessionId: resolvedSessionId,
318
+ prompt: resolvedPrompt,
319
+ promptAccepted: true,
320
+ connection,
321
+ };
322
+ }
323
+ }
@@ -0,0 +1,129 @@
1
+ import { CodeKanbanValidationError } from './errors.js';
2
+ import { ensureArrayOfStrings, ensureOptionalString, toCommandString } from './utils.js';
3
+
4
+ export const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
5
+ export const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'];
6
+ export const WORKFLOW_PROFILES = ['plan', 'standard', 'yolo'];
7
+ export const AGENTS = ['codex', 'claude'];
8
+
9
+ const KNOWN_STRUCTURED_FLAGS = new Set([
10
+ '-s',
11
+ '--sandbox',
12
+ '-a',
13
+ '--ask-for-approval',
14
+ '--add-dir',
15
+ '--dangerously-bypass-approvals-and-sandbox',
16
+ ]);
17
+
18
+ export const PLAN_PROMPT_PREAMBLE = [
19
+ 'You are starting in planning mode.',
20
+ 'Inspect the project first, summarize the goal, and propose a concrete plan before making changes.',
21
+ 'Do not mutate files until the user confirms execution or explicitly asks you to proceed immediately.',
22
+ 'If additional directories or permissions are needed, call them out explicitly.',
23
+ ].join(' ');
24
+
25
+ function validateEnum(value, allowed, fieldName) {
26
+ if (!value) {
27
+ return undefined;
28
+ }
29
+ if (!allowed.includes(value)) {
30
+ throw new CodeKanbanValidationError(`${fieldName} must be one of: ${allowed.join(', ')}`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function detectStructuredFlagConflicts(extraArgs) {
36
+ const args = ensureArrayOfStrings(extraArgs, 'extraArgs');
37
+ const conflicts = [];
38
+ for (const arg of args) {
39
+ if (KNOWN_STRUCTURED_FLAGS.has(arg)) {
40
+ conflicts.push(arg);
41
+ }
42
+ }
43
+ return conflicts;
44
+ }
45
+
46
+ export function composeWorkflowPrompt({ profile = 'standard', prompt }) {
47
+ const userPrompt = String(prompt || '').trim();
48
+ if (!userPrompt) {
49
+ throw new CodeKanbanValidationError('prompt is required');
50
+ }
51
+ if (profile === 'plan') {
52
+ return `${PLAN_PROMPT_PREAMBLE}\n\nUser request:\n${userPrompt}`;
53
+ }
54
+ return userPrompt;
55
+ }
56
+
57
+ export function buildAgentLaunchSpec(options = {}) {
58
+ const agent = validateEnum(options.agent || 'codex', AGENTS, 'agent') || 'codex';
59
+ const profile = validateEnum(options.profile || 'standard', WORKFLOW_PROFILES, 'profile') || 'standard';
60
+ const extraArgs = ensureArrayOfStrings(options.extraArgs, 'extraArgs');
61
+
62
+ if (agent === 'claude') {
63
+ if (profile !== 'standard') {
64
+ throw new CodeKanbanValidationError('claude only supports the standard profile in v1');
65
+ }
66
+ if (options.permissions) {
67
+ throw new CodeKanbanValidationError('structured permissions are only supported for codex in v1');
68
+ }
69
+ const argv = ['claude', ...extraArgs];
70
+ return {
71
+ agent,
72
+ profile,
73
+ argv,
74
+ command: toCommandString(argv),
75
+ prompt: composeWorkflowPrompt({ profile, prompt: options.prompt }),
76
+ };
77
+ }
78
+
79
+ const permissions = options.permissions || {};
80
+ const conflicts = detectStructuredFlagConflicts(extraArgs);
81
+ if (
82
+ conflicts.length > 0 &&
83
+ (permissions.sandbox ||
84
+ permissions.approvalPolicy ||
85
+ (permissions.addDirs && permissions.addDirs.length > 0) ||
86
+ profile === 'yolo')
87
+ ) {
88
+ throw new CodeKanbanValidationError(`extraArgs conflicts with structured permissions: ${conflicts.join(', ')}`);
89
+ }
90
+
91
+ if (profile === 'yolo') {
92
+ if (permissions.sandbox || permissions.approvalPolicy || (permissions.addDirs && permissions.addDirs.length > 0)) {
93
+ throw new CodeKanbanValidationError('yolo does not accept structured sandbox, approval, or addDirs overrides');
94
+ }
95
+ const argv = ['codex', '--dangerously-bypass-approvals-and-sandbox', ...extraArgs];
96
+ return {
97
+ agent,
98
+ profile,
99
+ argv,
100
+ command: toCommandString(argv),
101
+ prompt: composeWorkflowPrompt({ profile, prompt: options.prompt }),
102
+ };
103
+ }
104
+
105
+ const sandbox =
106
+ validateEnum(ensureOptionalString(permissions.sandbox) || 'workspace-write', SANDBOX_MODES, 'permissions.sandbox') ||
107
+ 'workspace-write';
108
+ const approvalPolicy =
109
+ validateEnum(
110
+ ensureOptionalString(permissions.approvalPolicy) || 'on-request',
111
+ APPROVAL_POLICIES,
112
+ 'permissions.approvalPolicy',
113
+ ) || 'on-request';
114
+ const addDirs = ensureArrayOfStrings(permissions.addDirs, 'permissions.addDirs');
115
+
116
+ const argv = ['codex', '-s', sandbox, '-a', approvalPolicy];
117
+ for (const dir of addDirs) {
118
+ argv.push('--add-dir', dir);
119
+ }
120
+ argv.push(...extraArgs);
121
+
122
+ return {
123
+ agent,
124
+ profile,
125
+ argv,
126
+ command: toCommandString(argv),
127
+ prompt: composeWorkflowPrompt({ profile, prompt: options.prompt }),
128
+ };
129
+ }
@@ -0,0 +1,28 @@
1
+ export class CodeKanbanError extends Error {
2
+ constructor(message, options = {}) {
3
+ super(message);
4
+ this.name = options.name || this.constructor.name;
5
+ if (options.cause) {
6
+ this.cause = options.cause;
7
+ }
8
+ for (const [key, value] of Object.entries(options)) {
9
+ if (key === 'name' || key === 'cause') {
10
+ continue;
11
+ }
12
+ this[key] = value;
13
+ }
14
+ }
15
+ }
16
+
17
+ export class CodeKanbanConfigError extends CodeKanbanError {}
18
+ export class CodeKanbanValidationError extends CodeKanbanError {}
19
+
20
+ export class CodeKanbanHttpError extends CodeKanbanError {
21
+ constructor(message, options = {}) {
22
+ super(message, { ...options, name: 'CodeKanbanHttpError' });
23
+ this.status = options.status ?? 500;
24
+ this.method = options.method ?? 'GET';
25
+ this.path = options.path ?? '';
26
+ this.body = options.body;
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ export { CodeKanbanClient } from './client.js';
2
+ export {
3
+ AGENTS,
4
+ APPROVAL_POLICIES,
5
+ PLAN_PROMPT_PREAMBLE,
6
+ SANDBOX_MODES,
7
+ WORKFLOW_PROFILES,
8
+ buildAgentLaunchSpec,
9
+ composeWorkflowPrompt,
10
+ } from './command-builder.js';
11
+ export {
12
+ CodeKanbanConfigError,
13
+ CodeKanbanError,
14
+ CodeKanbanHttpError,
15
+ CodeKanbanValidationError,
16
+ } from './errors.js';
17
+ export { TerminalConnection } from './terminal-connection.js';
@@ -0,0 +1,133 @@
1
+ import { CodeKanbanConfigError, CodeKanbanError, CodeKanbanValidationError } from './errors.js';
2
+
3
+ function decodeJsonMessage(raw) {
4
+ const text = typeof raw === 'string' ? raw : String(raw ?? '');
5
+ return JSON.parse(text);
6
+ }
7
+
8
+ export class TerminalConnection {
9
+ constructor({ sessionId, url, WebSocketImpl }) {
10
+ if (!sessionId) {
11
+ throw new CodeKanbanValidationError('sessionId is required');
12
+ }
13
+ if (!url) {
14
+ throw new CodeKanbanValidationError('url is required');
15
+ }
16
+ const Socket = WebSocketImpl || globalThis.WebSocket;
17
+ if (!Socket) {
18
+ throw new CodeKanbanConfigError('WebSocket implementation is unavailable');
19
+ }
20
+
21
+ this.sessionId = sessionId;
22
+ this.url = url;
23
+ this.messages = [];
24
+ this.lastMetadata = undefined;
25
+ this.readyMessage = undefined;
26
+ this._listeners = new Map();
27
+
28
+ this.socket = new Socket(url);
29
+ this._readyResolve = null;
30
+ this._readyReject = null;
31
+ this._readyPromise = new Promise((resolve, reject) => {
32
+ this._readyResolve = resolve;
33
+ this._readyReject = reject;
34
+ });
35
+
36
+ this.socket.addEventListener('message', event => {
37
+ const payload = decodeJsonMessage(event.data);
38
+ this.messages.push(payload);
39
+ if (payload.type === 'metadata' && payload.metadata) {
40
+ this.lastMetadata = payload.metadata;
41
+ }
42
+ if (payload.type === 'ready') {
43
+ this.readyMessage = payload;
44
+ this._readyResolve?.(payload);
45
+ }
46
+ this._emit('message', payload);
47
+ this._emit(payload.type, payload);
48
+ });
49
+
50
+ this.socket.addEventListener('error', event => {
51
+ const error = new CodeKanbanError('terminal websocket error', { event });
52
+ this._readyReject?.(error);
53
+ this._emit('error', error);
54
+ });
55
+
56
+ this.socket.addEventListener('close', event => {
57
+ this._emit('close', event);
58
+ });
59
+ }
60
+
61
+ on(type, handler) {
62
+ const existing = this._listeners.get(type) || new Set();
63
+ existing.add(handler);
64
+ this._listeners.set(type, existing);
65
+ return () => this.off(type, handler);
66
+ }
67
+
68
+ off(type, handler) {
69
+ const existing = this._listeners.get(type);
70
+ if (!existing) {
71
+ return;
72
+ }
73
+ existing.delete(handler);
74
+ }
75
+
76
+ _emit(type, payload) {
77
+ const existing = this._listeners.get(type);
78
+ if (!existing) {
79
+ return;
80
+ }
81
+ for (const handler of existing) {
82
+ handler(payload);
83
+ }
84
+ }
85
+
86
+ async waitForReady(timeoutMs = 8000) {
87
+ if (this.readyMessage) {
88
+ return this.readyMessage;
89
+ }
90
+ return await Promise.race([
91
+ this._readyPromise,
92
+ new Promise((_, reject) => {
93
+ setTimeout(
94
+ () => reject(new CodeKanbanValidationError(`terminal session ${this.sessionId} did not become ready in time`)),
95
+ timeoutMs,
96
+ );
97
+ }),
98
+ ]);
99
+ }
100
+
101
+ sendJson(payload) {
102
+ this.socket.send(JSON.stringify(payload));
103
+ }
104
+
105
+ sendInput(data) {
106
+ this.sendJson({ type: 'input', data });
107
+ }
108
+
109
+ resize(cols, rows) {
110
+ this.sendJson({ type: 'resize', cols, rows });
111
+ }
112
+
113
+ close() {
114
+ this.sendJson({ type: 'close' });
115
+ this.socket.close();
116
+ }
117
+
118
+ async waitForMetadata(timeoutMs = 1500) {
119
+ if (this.lastMetadata) {
120
+ return this.lastMetadata;
121
+ }
122
+ return await new Promise(resolve => {
123
+ const cleanup = this.on('metadata', payload => {
124
+ cleanup();
125
+ resolve(payload.metadata);
126
+ });
127
+ setTimeout(() => {
128
+ cleanup();
129
+ resolve(this.lastMetadata);
130
+ }, timeoutMs);
131
+ });
132
+ }
133
+ }