@kosuke-ai/kosuke 1.0.50 → 1.0.51

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.
@@ -241,7 +241,8 @@ export const operation = {
241
241
  ],
242
242
  "attachments": {
243
243
  "maxFiles": 20,
244
- "maxBytes": 10485760
244
+ "maxBytes": 10485760,
245
+ "uploadPath": "/api/projects/{projectId}/attachments"
245
246
  },
246
247
  "anonymous": false,
247
248
  "agents": "all"
@@ -75,7 +75,8 @@ export const operation = {
75
75
  ],
76
76
  "attachments": {
77
77
  "maxFiles": 20,
78
- "maxBytes": 10485760
78
+ "maxBytes": 10485760,
79
+ "uploadPath": "/api/projects/{projectId}/attachments"
79
80
  },
80
81
  "anonymous": false,
81
82
  "agents": "all"
@@ -50,7 +50,8 @@ export const operation = {
50
50
  ],
51
51
  "attachments": {
52
52
  "maxFiles": 20,
53
- "maxBytes": 10485760
53
+ "maxBytes": 10485760,
54
+ "uploadPath": "/api/projects/{projectId}/attachments"
54
55
  },
55
56
  "anonymous": false,
56
57
  "agents": "all"
@@ -131,7 +131,8 @@ export const operation = {
131
131
  ],
132
132
  "attachments": {
133
133
  "maxFiles": 20,
134
- "maxBytes": 10485760
134
+ "maxBytes": 10485760,
135
+ "uploadPath": "/api/projects/{projectId}/attachments"
135
136
  },
136
137
  "anonymous": false,
137
138
  "agents": "all"
@@ -285,7 +285,8 @@ export const operation = {
285
285
  ],
286
286
  "attachments": {
287
287
  "maxFiles": 20,
288
- "maxBytes": 10485760
288
+ "maxBytes": 10485760,
289
+ "uploadPath": "/api/projects/{hintProjectId}/attachments"
289
290
  },
290
291
  "anonymous": false,
291
292
  "agents": "all"
@@ -184,8 +184,22 @@ async function run(leaf, runtime, operation, input) {
184
184
  anonymous: operation.anonymous,
185
185
  answerTimeoutMs: operation.answerTimeoutMs,
186
186
  });
187
- const outcome = await execute(client, request);
188
- runtime.output(outcome);
187
+ const controller = new AbortController();
188
+ const cancel = () => controller.abort();
189
+ // Stop in-flight staging and prevent the final send on a CLI interruption.
190
+ // Hard exits and lost responses are covered by the server's staging expiry.
191
+ if (request.deferredAttachments) {
192
+ process.once('SIGINT', cancel);
193
+ process.once('SIGTERM', cancel);
194
+ }
195
+ try {
196
+ const outcome = await execute(client, request, controller.signal);
197
+ runtime.output(outcome);
198
+ }
199
+ finally {
200
+ process.removeListener('SIGINT', cancel);
201
+ process.removeListener('SIGTERM', cancel);
202
+ }
189
203
  }
190
204
  /** `(personal only)`, `(workspace only)`, `(--provider claude_code, codex)`; empty for all. */
191
205
  function whoTakes(leafFlag, forms) {
@@ -1,4 +1,4 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { openAsBlob, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, extname } from 'node:path';
3
3
  /**
4
4
  * Files on a multipart request, the way the platform reads them.
@@ -46,18 +46,47 @@ function formatBytes(bytes) {
46
46
  const mib = bytes / (1024 * 1024);
47
47
  return Number.isInteger(mib) ? `${mib} MiB` : `${bytes}-byte`;
48
48
  }
49
- export function appendAttachments(form, paths, { maxFiles, maxBytes }) {
49
+ /** Leave room for multipart overhead below the app proxy's body limit. */
50
+ export const INLINE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
51
+ /** Validate the entire batch before reading or uploading any file. */
52
+ export function attachmentBytes(paths, { maxFiles, maxBytes }) {
50
53
  if (paths.length > maxFiles) {
51
54
  throw new Error(`At most ${maxFiles} attachments per request; ${paths.length} given.`);
52
55
  }
53
- // Size from the inode before the read: a file past the cap must fail with
54
- // this message, not with the runtime's own error once it has been loaded.
56
+ let total = 0;
55
57
  for (const path of paths) {
56
- if (statSync(path).size > maxBytes) {
57
- throw new Error(`${path} is larger than the ${formatBytes(maxBytes)} attachment limit.`);
58
- }
58
+ const size = statSync(path).size;
59
+ checkSize(path, size, maxBytes);
60
+ total += size;
59
61
  }
60
- const files = paths.map((path) => new File([readFileSync(path)], basename(path), { type: mediaTypeFor(path) }));
62
+ return total;
63
+ }
64
+ function checkSize(path, size, maxBytes) {
65
+ if (size > maxBytes) {
66
+ throw new Error(`${path} is larger than the ${formatBytes(maxBytes)} attachment limit.`);
67
+ }
68
+ }
69
+ export function appendAttachments(form, paths, limits) {
70
+ attachmentBytes(paths, limits);
71
+ const files = paths.map((path) => {
72
+ const bytes = readFileSync(path);
73
+ checkSize(path, bytes.length, limits.maxBytes);
74
+ return new File([bytes], basename(path), { type: mediaTypeFor(path) });
75
+ });
61
76
  form.set('attachmentCount', String(files.length));
62
77
  files.forEach((file, index) => form.set(`attachment_${index}`, file));
63
78
  }
79
+ /** File-backed blobs stream on send and reject if the file changes during upload. */
80
+ export async function appendStreamingAttachments(form, paths, limits, signal) {
81
+ attachmentBytes(paths, limits);
82
+ const files = [];
83
+ for (const path of paths) {
84
+ signal?.throwIfAborted();
85
+ const file = await openAsBlob(path, { type: mediaTypeFor(path) });
86
+ checkSize(path, file.size, limits.maxBytes);
87
+ files.push(file);
88
+ }
89
+ signal?.throwIfAborted();
90
+ form.set('attachmentCount', String(files.length));
91
+ files.forEach((file, index) => form.set(`attachment_${index}`, file, basename(paths[index])));
92
+ }
@@ -77,10 +77,29 @@ function isRetryable(error, repeatable) {
77
77
  return true;
78
78
  return repeatable && AMBIGUOUS_CODES.includes(code);
79
79
  }
80
+ // Retrying a consumed multipart Request needs a fresh stream. Keep the original
81
+ // FormData (whose large files are disk-backed), not a cloned/teed 200 MiB body.
82
+ const multipartReplays = new WeakMap();
83
+ class MultipartRequest extends Request {
84
+ constructor(input, init) {
85
+ super(input, init);
86
+ if (init?.body instanceof FormData) {
87
+ multipartReplays.set(this, () => {
88
+ const headers = new Headers(this.headers);
89
+ headers.delete('content-type'); // The fresh form serializer chooses its boundary.
90
+ return new Request(this.url, { ...init, headers, signal: this.signal });
91
+ });
92
+ }
93
+ }
94
+ }
80
95
  function withTransportRetry(fetchImpl) {
81
96
  return async (...args) => {
97
+ const replay = args[0] instanceof Request ? multipartReplays.get(args[0]) : undefined;
82
98
  let attempt = 0;
83
99
  for (;;) {
100
+ signalOf(args)?.throwIfAborted();
101
+ if (attempt && replay)
102
+ args = [replay(), args[1]];
84
103
  attempt++;
85
104
  try {
86
105
  return await fetchImpl(...args);
@@ -178,6 +197,7 @@ function withIntegrationCap(fetchImpl) {
178
197
  export function createApiClient({ apiUrl, apiKey, fetch, answerTimeoutMs, }) {
179
198
  const base = fetch ?? globalThis.fetch;
180
199
  return createClient({
200
+ Request: MultipartRequest,
181
201
  baseUrl: apiUrl,
182
202
  headers: {
183
203
  ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { appendAttachments } from './attachments.js';
2
+ import { defaultPathSerializer } from 'openapi-fetch';
3
+ import { appendAttachments, appendStreamingAttachments, attachmentBytes, INLINE_ATTACHMENT_BYTES, } from './attachments.js';
3
4
  export class UsageError extends Error {
4
5
  }
5
6
  export function buildRequest(operation, input) {
@@ -16,12 +17,18 @@ export function buildRequest(operation, input) {
16
17
  }
17
18
  }
18
19
  const bodyFields = Object.fromEntries(Object.entries(input.fields).filter(([name, value]) => !queryNames.has(name) && value !== undefined));
20
+ const deferredAttachments = operation.attachments &&
21
+ input.attachments.length &&
22
+ attachmentBytes(input.attachments, operation.attachments) > INLINE_ATTACHMENT_BYTES
23
+ ? { paths: [...input.attachments], limits: operation.attachments }
24
+ : undefined;
19
25
  return {
26
+ ...(deferredAttachments ? { deferredAttachments } : {}),
20
27
  method: operation.method,
21
28
  path: operation.path,
22
29
  pathParams: input.pathParams,
23
30
  query,
24
- body: buildBody(operation, bodyFields, input),
31
+ body: buildBody(operation, bodyFields, input, !!deferredAttachments),
25
32
  };
26
33
  }
27
34
  /**
@@ -30,7 +37,7 @@ export function buildRequest(operation, input) {
30
37
  * JSON otherwise, including an empty `{}` for a body-taking operation given
31
38
  * nothing, which is how the deploy route reads "start a deploy".
32
39
  */
33
- function buildBody(operation, fields, input) {
40
+ function buildBody(operation, fields, input, deferAttachments = false) {
34
41
  const takesJson = operation.body !== null;
35
42
  const takesMultipart = operation.multipart !== null;
36
43
  if (!takesJson && !takesMultipart) {
@@ -75,7 +82,7 @@ function buildBody(operation, fields, input) {
75
82
  throw new UsageError(`--${field.flag} is required.`);
76
83
  }
77
84
  }
78
- if (input.attachments.length && operation.attachments)
85
+ if (input.attachments.length && operation.attachments && !deferAttachments)
79
86
  appendAttachments(form, input.attachments, operation.attachments);
80
87
  return { kind: 'multipart', value: form };
81
88
  }
@@ -139,9 +146,22 @@ export function readWholeBody(options, readStdin = () => readFileSync(0, 'utf8')
139
146
  throw new UsageError('The body is not valid JSON.');
140
147
  }
141
148
  }
142
- export async function execute(client, request) {
149
+ export async function execute(client, request, signal) {
150
+ signal?.throwIfAborted();
151
+ if (request.deferredAttachments && request.body?.kind === 'multipart') {
152
+ // Build a fresh form per execution. Retrying never reuses partially staged slots.
153
+ const form = new FormData();
154
+ for (const [key, value] of request.body.value)
155
+ form.append(key, value);
156
+ const failure = await prepareAttachments(client, request, form, signal);
157
+ if (failure)
158
+ return failure;
159
+ request = { ...request, body: { kind: 'multipart', value: form } };
160
+ }
161
+ signal?.throwIfAborted();
143
162
  const init = {
144
163
  params: { path: request.pathParams, query: request.query },
164
+ ...(signal ? { signal } : {}),
145
165
  };
146
166
  if (request.body)
147
167
  init.body = request.body.value;
@@ -150,3 +170,98 @@ export async function execute(client, request) {
150
170
  const { data, error, response } = await client.request(request.method, request.path, init);
151
171
  return { ok: response.ok, status: response.status, body: response.ok ? data : error };
152
172
  }
173
+ /**
174
+ * Staging uses the same client/auth/retry policy as the message. A first API
175
+ * catch-all 404 or 405 permits the legacy inline form. No fallback on an auth,
176
+ * validation or transport failure, or after any upload has succeeded.
177
+ * Unconsumed staging is reclaimed by the server's bounded expiry sweep; never
178
+ * delete a reference after a message failure, whose commit may have succeeded.
179
+ */
180
+ async function prepareAttachments(client, request, form, signal) {
181
+ const { paths, limits } = request.deferredAttachments;
182
+ attachmentBytes(paths, limits);
183
+ const uploadPath = limits.uploadPath;
184
+ if (!uploadPath) {
185
+ await appendStreamingAttachments(form, paths, limits, signal);
186
+ return;
187
+ }
188
+ const uploadParams = {};
189
+ for (const match of uploadPath.matchAll(/\{([^}]+)\}/g)) {
190
+ const name = match[1];
191
+ const value = request.pathParams[name] ?? form.get(name);
192
+ if (typeof value !== 'string' || !value) {
193
+ throw new UsageError(`Missing attachment upload parameter "${name}".`);
194
+ }
195
+ uploadParams[name] = value;
196
+ }
197
+ const controller = new AbortController();
198
+ const uploadSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
199
+ const references = new Array(paths.length);
200
+ let failure;
201
+ let next = 0;
202
+ const upload = async (index) => {
203
+ uploadSignal.throwIfAborted();
204
+ const body = new FormData();
205
+ await appendStreamingAttachments(body, [paths[index]], limits, uploadSignal);
206
+ const outcome = await execute(client, {
207
+ method: 'post',
208
+ path: uploadPath,
209
+ pathParams: uploadParams,
210
+ query: {},
211
+ body: { kind: 'multipart', value: body },
212
+ }, uploadSignal);
213
+ uploadSignal.throwIfAborted();
214
+ if (outcome.ok) {
215
+ const reference = stagedReference(outcome.body);
216
+ if (!reference)
217
+ throw new Error('Invalid attachment upload response.');
218
+ references[index] = reference;
219
+ }
220
+ return outcome;
221
+ };
222
+ // Discover legacy support before starting the other requests.
223
+ const first = await upload(next++);
224
+ if (!first.ok) {
225
+ const error = asRecordOrEmpty(first.body);
226
+ const missingRoute = first.status === 404 &&
227
+ error.code === 'NOT_FOUND' &&
228
+ error.error === `No API route at ${defaultPathSerializer(uploadPath, uploadParams)}`;
229
+ if (missingRoute || first.status === 405) {
230
+ await appendStreamingAttachments(form, paths, limits, signal);
231
+ return;
232
+ }
233
+ return first;
234
+ }
235
+ const worker = async () => {
236
+ try {
237
+ while (next < paths.length) {
238
+ uploadSignal.throwIfAborted();
239
+ const outcome = await upload(next++);
240
+ if (!outcome.ok) {
241
+ failure ??= outcome;
242
+ controller.abort();
243
+ return;
244
+ }
245
+ }
246
+ }
247
+ catch (error) {
248
+ controller.abort(error);
249
+ throw error;
250
+ }
251
+ };
252
+ // Drain all requests after abort: nothing continues in the background on return.
253
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(3, paths.length - next) }, worker));
254
+ signal?.throwIfAborted();
255
+ if (failure)
256
+ return failure;
257
+ const rejected = workers.find((result) => result.status === 'rejected');
258
+ if (rejected?.status === 'rejected')
259
+ throw controller.signal.reason;
260
+ form.set('attachmentCount', String(references.length));
261
+ references.forEach((reference, index) => form.set(`attachment_${index}`, reference));
262
+ }
263
+ function stagedReference(body) {
264
+ const data = asRecordOrEmpty(body).data;
265
+ const reference = asRecordOrEmpty(data).reference;
266
+ return typeof reference === 'string' && reference.length > 0 ? reference : undefined;
267
+ }
package/openapi.json CHANGED
@@ -8360,6 +8360,7 @@
8360
8360
  "x-kosuke-attachments": true,
8361
8361
  "x-kosuke-attachment-max-files": 20,
8362
8362
  "x-kosuke-attachment-max-bytes": 10485760,
8363
+ "x-kosuke-attachment-upload-path": "/api/projects/{hintProjectId}/attachments",
8363
8364
  "x-kosuke-sandbox-token": true,
8364
8365
  "x-kosuke-cron-token": true,
8365
8366
  "x-kosuke-sensei-token": true,
@@ -10252,6 +10253,75 @@
10252
10253
  }
10253
10254
  }
10254
10255
  },
10256
+ "/api/projects/{projectId}/attachments": {
10257
+ "post": {
10258
+ "operationId": "attachmentsCreate",
10259
+ "summary": "Stage one private message attachment. Returns data.reference for an attachment slot in a subsequent message to this project, using the same credential, within one hour.",
10260
+ "tags": ["attachments"],
10261
+ "x-kosuke-command": ["attachments", "create"],
10262
+ "x-kosuke-attachments": true,
10263
+ "x-kosuke-attachment-max-files": 1,
10264
+ "x-kosuke-attachment-max-bytes": 10485760,
10265
+ "x-kosuke-sandbox-token": true,
10266
+ "x-kosuke-cron-token": true,
10267
+ "x-kosuke-sensei-token": true,
10268
+ "parameters": [
10269
+ {
10270
+ "name": "projectId",
10271
+ "in": "path",
10272
+ "required": true,
10273
+ "schema": {
10274
+ "type": "string",
10275
+ "format": "uuid",
10276
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
10277
+ }
10278
+ }
10279
+ ],
10280
+ "requestBody": {
10281
+ "required": true,
10282
+ "content": {
10283
+ "multipart/form-data": {
10284
+ "schema": {
10285
+ "type": "object",
10286
+ "properties": {}
10287
+ }
10288
+ }
10289
+ }
10290
+ },
10291
+ "responses": {
10292
+ "2XX": {
10293
+ "description": "Success envelope",
10294
+ "content": {
10295
+ "application/json": {
10296
+ "schema": {
10297
+ "$ref": "#/components/schemas/ApiSuccess"
10298
+ }
10299
+ }
10300
+ }
10301
+ },
10302
+ "4XX": {
10303
+ "description": "Error envelope",
10304
+ "content": {
10305
+ "application/json": {
10306
+ "schema": {
10307
+ "$ref": "#/components/schemas/ApiError"
10308
+ }
10309
+ }
10310
+ }
10311
+ },
10312
+ "5XX": {
10313
+ "description": "Error envelope",
10314
+ "content": {
10315
+ "application/json": {
10316
+ "schema": {
10317
+ "$ref": "#/components/schemas/ApiError"
10318
+ }
10319
+ }
10320
+ }
10321
+ }
10322
+ }
10323
+ }
10324
+ },
10255
10325
  "/api/projects/{projectId}/backlog-items": {
10256
10326
  "get": {
10257
10327
  "operationId": "projectsBacklogItemsList",
@@ -11316,6 +11386,7 @@
11316
11386
  "x-kosuke-attachments": true,
11317
11387
  "x-kosuke-attachment-max-files": 20,
11318
11388
  "x-kosuke-attachment-max-bytes": 10485760,
11389
+ "x-kosuke-attachment-upload-path": "/api/projects/{projectId}/attachments",
11319
11390
  "x-kosuke-sandbox-token": true,
11320
11391
  "x-kosuke-cron-token": true,
11321
11392
  "x-kosuke-sensei-token": true,
@@ -12641,6 +12712,7 @@
12641
12712
  "x-kosuke-attachments": true,
12642
12713
  "x-kosuke-attachment-max-files": 20,
12643
12714
  "x-kosuke-attachment-max-bytes": 10485760,
12715
+ "x-kosuke-attachment-upload-path": "/api/projects/{projectId}/attachments",
12644
12716
  "x-kosuke-sandbox-token": true,
12645
12717
  "x-kosuke-cron-token": true,
12646
12718
  "x-kosuke-sensei-token": true,
@@ -14432,6 +14504,7 @@
14432
14504
  "x-kosuke-attachments": true,
14433
14505
  "x-kosuke-attachment-max-files": 20,
14434
14506
  "x-kosuke-attachment-max-bytes": 10485760,
14507
+ "x-kosuke-attachment-upload-path": "/api/projects/{projectId}/attachments",
14435
14508
  "x-kosuke-sandbox-token": true,
14436
14509
  "x-kosuke-cron-token": true,
14437
14510
  "x-kosuke-sensei-token": true,
@@ -15648,6 +15721,7 @@
15648
15721
  "x-kosuke-attachments": true,
15649
15722
  "x-kosuke-attachment-max-files": 20,
15650
15723
  "x-kosuke-attachment-max-bytes": 10485760,
15724
+ "x-kosuke-attachment-upload-path": "/api/projects/{projectId}/attachments",
15651
15725
  "x-kosuke-sandbox-token": true,
15652
15726
  "x-kosuke-cron-token": true,
15653
15727
  "x-kosuke-sensei-token": true,
@@ -18454,6 +18528,7 @@
18454
18528
  "x-kosuke-attachments": true,
18455
18529
  "x-kosuke-attachment-max-files": 20,
18456
18530
  "x-kosuke-attachment-max-bytes": 10485760,
18531
+ "x-kosuke-attachment-upload-path": "/api/projects/{projectId}/attachments",
18457
18532
  "x-kosuke-sandbox-token": true,
18458
18533
  "x-kosuke-cron-token": true,
18459
18534
  "x-kosuke-sensei-token": true,
@@ -18741,6 +18816,7 @@
18741
18816
  "x-kosuke-attachments": true,
18742
18817
  "x-kosuke-attachment-max-files": 20,
18743
18818
  "x-kosuke-attachment-max-bytes": 10485760,
18819
+ "x-kosuke-attachment-upload-path": "/api/projects/{projectId}/attachments",
18744
18820
  "x-kosuke-sandbox-token": true,
18745
18821
  "x-kosuke-cron-token": true,
18746
18822
  "x-kosuke-sensei-token": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kosuke-ai/kosuke",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
4
4
  "description": "Kosuke platform CLI. The client customers install to drive app.kosuke.ai from their own machine.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,11 +40,10 @@
40
40
  "vitest": "^4.1.12"
41
41
  },
42
42
  "kosukeRelease": {
43
- "sourceCommit": "783c56f80b3c75df5f129ad9ef32d5f2cf0e7a92",
44
- "sourceIdentity": "97a8761f0d47f9900bc37d7da2fb71dd14fc32222a97ec5d248c32986cd63bcd",
43
+ "sourceCommit": "11e0393d7adbd35a8589bef145e005e29aa8d1a7",
44
+ "sourceIdentity": "39feb5bff790b571ed2317abfb3663d6bfac98ad68ff123b03e57b51389bddbc",
45
45
  "fragments": [
46
- "cli-internal-marks-3q4y00.json",
47
- "device-approval-per-turn-23f44b.json"
46
+ "cli-staged-attachments.json"
48
47
  ]
49
48
  }
50
49
  }