@lambdacurry/arbor 0.24.2 → 0.24.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/dist/arbor.js +299 -79
  3. package/package.json +2 -3
package/README.md CHANGED
@@ -101,6 +101,14 @@ and `attachment list` (`attachment_list`, `list_attachments`) recover receipts;
101
101
  pages with `--cursor`. Finalized receipts replay with the same authentic token even after expiry.
102
102
  Duplicate SHA-256 warnings on the same Thread are best-effort; concurrent uploads may not warn.
103
103
 
104
+ Several files work in one command: `arbor attachment upload a.png b.png --thread-id thr_… --description "A" --description "B"`.
105
+ Repeated `--description` flags pair with files by order (none = filename defaults, otherwise exactly one per
106
+ file; write a value that starts with `--` as `--description=<value>`; values are literal, so a leading `[` is text). A blank description or a path listed twice is an error. The whole batch is checked before the
107
+ first request and uploads one file at a time; a mid-batch failure prints what uploaded, what failed and what
108
+ was not attempted, and exits non-zero. `--dry-run` prints the plan and makes no network call. Every receipt
109
+ carries a `nextStep` (unbound until bound to `contribute`/`respond`, max 10 per contribution; descriptions are
110
+ permanent) and may warn when a description is reused for a different file on the same Thread.
111
+
104
112
  MCP exposes **one** tool, `attachment`, with `upload_url` / `attach` / `get` / `list` verbs.
105
113
  Refresh a stale client tool catalog if it still shows the retired individual tools, or use
106
114
  `arbor attachment upload`. `thread get --view full` retains body/Attachment read-back.
package/dist/arbor.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@lambdacurry/arbor",
5
- version: "0.24.2",
5
+ version: "0.24.4",
6
6
  description: "The Arbor CLI — a shared workspace for people and agents. The human + headless-agent write path over Arbor's guarded operation surface.",
7
7
  keywords: [
8
8
  "agents",
@@ -29,7 +29,7 @@ var package_default = {
29
29
  access: "public"
30
30
  },
31
31
  scripts: {
32
- arbor: "tsx src/bin.ts",
32
+ arbor: "vpx src/bin.ts",
33
33
  build: "bun build ./src/bin.ts --target=node --outfile=dist/arbor.js",
34
34
  test: "vp test run --passWithNoTests",
35
35
  typecheck: "tsc -b",
@@ -40,7 +40,6 @@ var package_default = {
40
40
  "@arbor/core": "workspace:*",
41
41
  "@types/node": "^26.2.0",
42
42
  "just-bash": "3.4.2",
43
- tsx: "^4.20.6",
44
43
  typescript: "^5.8.0",
45
44
  "vite-plus": "catalog:",
46
45
  zod: "^4.4.3"
@@ -5162,6 +5161,7 @@ var ARTIFACT_TYPES = [
5162
5161
  ];
5163
5162
  // ../core/src/blob/attachment-upload.ts
5164
5163
  var MAX_ATTACHMENT_BYTES = 1e8;
5164
+ var ATTACHMENT_ATTACH_NEXT_STEP = "Unbound until you pass attachmentIds (max 10 per contribution) to contribute or respond. The description is permanent (it becomes the Artifact summary): to fix it before binding, re-upload and bind only the new id. Paste attachments[].markdown verbatim; don't rebuild it from the description.";
5165
5165
  var AGENT_ATTACHMENT_MIME_TYPES = [
5166
5166
  "image/png",
5167
5167
  "image/jpeg",
@@ -6579,10 +6579,20 @@ var spaces = sqliteTable("spaces", {
6579
6579
  defaultReviewDimensions: text("default_review_dimensions", { mode: "json" }).$type().notNull().default([]),
6580
6580
  stage: text("stage").$type().notNull().default("exploration"),
6581
6581
  systemKind: text("system_kind"),
6582
+ key: text("key"),
6582
6583
  createdAt: ts("created_at").notNull()
6583
6584
  }, (t) => ({
6584
6585
  orgIdx: index("spaces_org_idx").on(t.orgId),
6585
- systemKindUq: uniqueIndex("spaces_system_kind_uq").on(t.systemKind)
6586
+ systemKindUq: uniqueIndex("spaces_system_kind_uq").on(t.systemKind),
6587
+ orgKeyUq: uniqueIndex("spaces_org_key_uq").on(t.orgId, t.key)
6588
+ }));
6589
+ var spaceRetiredKeys = sqliteTable("space_retired_keys", {
6590
+ orgId: text("org_id").notNull().references(() => orgs.id),
6591
+ key: text("key").notNull(),
6592
+ spaceId: text("space_id").notNull().references(() => spaces.id),
6593
+ retiredAt: ts("retired_at").notNull()
6594
+ }, (t) => ({
6595
+ orgKeyUq: uniqueIndex("space_retired_keys_org_key_uq").on(t.orgId, t.key)
6586
6596
  }));
6587
6597
  var spaceMemberships = sqliteTable("space_memberships", {
6588
6598
  id: text("id").primaryKey(),
@@ -6712,8 +6722,10 @@ var threads = sqliteTable("threads", {
6712
6722
  waysToHelp: text("ways_to_help", { mode: "json" }).$type().notNull().default([]),
6713
6723
  openQuestions: text("open_questions", { mode: "json" }).$type().notNull().default([]),
6714
6724
  expectedOutput: text("expected_output").notNull().default(""),
6725
+ number: integer2("number"),
6715
6726
  createdAt: ts("created_at").notNull()
6716
6727
  }, (t) => ({
6728
+ spaceNumberUq: uniqueIndex("threads_space_number_uq").on(t.spaceId, t.number),
6717
6729
  topicIdx: index("threads_topic_idx").on(t.topicId),
6718
6730
  spaceIdx: index("threads_space_idx").on(t.spaceId),
6719
6731
  lifecycleTypeIdx: index("threads_lifecycle_type_idx").on(t.lifecycleType),
@@ -8217,6 +8229,36 @@ var ORG_ADMIN_CONTAINMENT_PERMISSIONS = new Set([
8217
8229
  "revoke"
8218
8230
  ]);
8219
8231
 
8232
+ // ../core/src/queries/artifact-base.ts
8233
+ var eventChronology = sql`_rowid_`;
8234
+ var SPACE_ARTIFACT_STATUSES = [
8235
+ "draft",
8236
+ "proposed",
8237
+ "active",
8238
+ "promoted",
8239
+ "needs-review",
8240
+ "superseded",
8241
+ "deprecated",
8242
+ "archived"
8243
+ ];
8244
+ var artifactUpdatedAt = sql`coalesce((select max(${events.createdAt}) from ${events} where ${events.objectType} = 'artifact' and ${events.objectId} = ${artifacts.id}), ${artifacts.createdAt})`;
8245
+ // ../core/src/queries/thread-assignment.ts
8246
+ var assigneeColumns = {
8247
+ threadId: threadAssignments.threadId,
8248
+ assignedAt: threadAssignments.createdAt,
8249
+ assignedByProfileId: threadAssignments.assignedByProfileId,
8250
+ id: profiles.id,
8251
+ kind: profiles.kind,
8252
+ displayName: profiles.displayName,
8253
+ color: profiles.color,
8254
+ emoji: profiles.emoji
8255
+ };
8256
+ var lastActivityExpr = sql`coalesce(
8257
+ (select max(${contributions.createdAt}) from ${contributions}
8258
+ where ${contributions.threadId} = ${threads.id}),
8259
+ ${threads.createdAt}
8260
+ )`;
8261
+
8220
8262
  // ../core/src/ops/space.ts
8221
8263
  var GUIDANCE_LIMITS = {
8222
8264
  maxItems: 12,
@@ -8235,6 +8277,11 @@ var MAX_WAY_LEN = WAYS_TO_HELP_LIMITS.maxItemLength;
8235
8277
 
8236
8278
  // ../core/src/ops/computer-output.ts
8237
8279
  var COMPUTER_OUTPUT_DEFAULT_STALE_MS = 24 * 60 * 60 * 1000;
8280
+ var OPERATION_LOSS_REASONS = new Set([
8281
+ "process_provider_lost",
8282
+ "workspace_expired",
8283
+ "run_runtime_lost"
8284
+ ]);
8238
8285
 
8239
8286
  // ../core/src/ops/computer-run-retention.ts
8240
8287
  var COMPUTER_RUN_RETENTION_WARNING_MS = 24 * 60 * 60 * 1000;
@@ -8284,20 +8331,6 @@ var COMPUTER_WORKSPACE_RECOVERY_ATTACH_DEADLINE_MS = 31 * 60 * 1000;
8284
8331
  var COMPUTER_WORKSPACE_RECOVERY_HEALTH_DEADLINE_MS = 2 * 60 * 1000;
8285
8332
  var COMPUTER_WORKSPACE_RECOVERY_HEADROOM_MS = 2 * 60 * 1000;
8286
8333
  var COMPUTER_WORKSPACE_RECOVERY_LEASE_MS = COMPUTER_WORKSPACE_RECOVERY_CHECKPOINT_DEADLINE_MS + COMPUTER_WORKSPACE_RECOVERY_ATTACH_DEADLINE_MS + COMPUTER_WORKSPACE_RECOVERY_HEALTH_DEADLINE_MS + COMPUTER_WORKSPACE_RECOVERY_HEADROOM_MS;
8287
-
8288
- // ../core/src/queries/artifact-base.ts
8289
- var eventChronology = sql`_rowid_`;
8290
- var SPACE_ARTIFACT_STATUSES = [
8291
- "draft",
8292
- "proposed",
8293
- "active",
8294
- "promoted",
8295
- "needs-review",
8296
- "superseded",
8297
- "deprecated",
8298
- "archived"
8299
- ];
8300
- var artifactUpdatedAt = sql`coalesce((select max(${events.createdAt}) from ${events} where ${events.objectType} = 'artifact' and ${events.objectId} = ${artifacts.id}), ${artifacts.createdAt})`;
8301
8334
  // ../core/src/ops/artifact-git-intent.ts
8302
8335
  var RETRYABLE_REPAIR = new Set([
8303
8336
  "pending_push_unconfirmed",
@@ -8315,22 +8348,6 @@ var EMOJI_RE = new RegExp("^\\p{RGI_Emoji}$", "v");
8315
8348
  var BASE_MS = Date.parse("2026-07-20T15:00:00.000Z");
8316
8349
  // ../core/src/ops/agent-pairing.ts
8317
8350
  var PAIRING_TTL_MS = 15 * 60 * 1000;
8318
- // ../core/src/queries/thread-assignment.ts
8319
- var assigneeColumns = {
8320
- threadId: threadAssignments.threadId,
8321
- assignedAt: threadAssignments.createdAt,
8322
- assignedByProfileId: threadAssignments.assignedByProfileId,
8323
- id: profiles.id,
8324
- kind: profiles.kind,
8325
- displayName: profiles.displayName,
8326
- color: profiles.color,
8327
- emoji: profiles.emoji
8328
- };
8329
- var lastActivityExpr = sql`coalesce(
8330
- (select max(${contributions.createdAt}) from ${contributions}
8331
- where ${contributions.threadId} = ${threads.id}),
8332
- ${threads.createdAt}
8333
- )`;
8334
8351
  // ../core/src/ops/package-cache.ts
8335
8352
  var PACKAGE_CACHE_PENDING_TTL_MS = 15 * 60 * 1000;
8336
8353
  var PACKAGE_CACHE_FINAL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -8504,6 +8521,7 @@ var recallHit = looseObject({
8504
8521
  excerpt: string2(),
8505
8522
  space: spaceRef.nullable(),
8506
8523
  threadId: nullableString,
8524
+ shortId: nullableString.optional(),
8507
8525
  url: nullableString,
8508
8526
  createdAt: timestamp
8509
8527
  });
@@ -8989,6 +9007,7 @@ var orgMember = looseObject({
8989
9007
  });
8990
9008
  var thread3 = looseObject({
8991
9009
  id,
9010
+ shortId: nullableString.optional(),
8992
9011
  title: string2(),
8993
9012
  objective: string2(),
8994
9013
  status: string2().optional(),
@@ -9027,6 +9046,7 @@ var topic2 = looseObject({
9027
9046
  var space3 = looseObject({
9028
9047
  id,
9029
9048
  title: string2(),
9049
+ key: nullableString.optional(),
9030
9050
  purpose: nullableString.optional(),
9031
9051
  stage: string2().optional(),
9032
9052
  url: string2().optional()
@@ -10328,8 +10348,19 @@ var MCP_OUTPUT_SCHEMAS = {
10328
10348
  uploadedByProfileId: id,
10329
10349
  createdAt: timestamp,
10330
10350
  attachments: array(attachment2),
10351
+ replayed: boolean2().optional(),
10352
+ nextStep: string2().optional(),
10331
10353
  warnings: array(string2()).optional()
10332
10354
  }),
10355
+ attachment_upload: looseObject({
10356
+ attachmentId: id.optional(),
10357
+ attachmentIds: array(id),
10358
+ attachments: array(attachment2),
10359
+ nextStep: string2().optional(),
10360
+ warnings: array(string2()).optional(),
10361
+ failed: array(looseObject({ file: string2(), error: string2() })).optional(),
10362
+ notAttempted: array(string2()).optional()
10363
+ }),
10333
10364
  computer_export: looseObject({
10334
10365
  computerSessionId: id,
10335
10366
  replayed: boolean2(),
@@ -10421,8 +10452,6 @@ var MCP_OUTPUT_SCHEMAS = {
10421
10452
  })
10422
10453
  };
10423
10454
  function outputSchemaFor(actionName) {
10424
- if (actionName === "attachment_upload")
10425
- actionName = "attachment_attach";
10426
10455
  return MCP_OUTPUT_SCHEMAS[actionName];
10427
10456
  }
10428
10457
 
@@ -11146,6 +11175,7 @@ var TLDRAW_EXEC_OPERATIONS_SCHEMA = array(TLDRAW_EXEC_OPERATION_SCHEMA).min(1).m
11146
11175
  // ../actions/src/index.ts
11147
11176
  var ARTIFACT_TYPE_DESCRIPTION = `semantic type (one of: ${ARTIFACT_TYPES.join(", ")})`;
11148
11177
  var ARBOR_FEEDBACK_INPUT_TYPES = [...ARBOR_FEEDBACK_TYPES, "friction"];
11178
+ var SPACE_KEY_INPUT = string2().min(1).refine((key) => key.trim().length > 0, "space key cannot be blank");
11149
11179
  var CONTRIBUTION_ADD_LINK_RELS = [
11150
11180
  "cites",
11151
11181
  "contests",
@@ -11287,6 +11317,17 @@ function proseList(description) {
11287
11317
  function isProseList(schema) {
11288
11318
  return PROSE_LISTS.has(schema);
11289
11319
  }
11320
+ var VERBATIM_LISTS = registry();
11321
+ function verbatimList(description) {
11322
+ const schema = array(string2()).describe(description);
11323
+ PROSE_LISTS.add(schema, { literal: true });
11324
+ VERBATIM_LISTS.add(schema, { verbatim: true });
11325
+ return schema;
11326
+ }
11327
+ function isVerbatimList(schema) {
11328
+ return VERBATIM_LISTS.has(schema);
11329
+ }
11330
+ var ATTACHMENT_DESCRIPTION_HINT = "durable summary and default alt text; defaults to the filename when omitted; permanent once bound; 1-500 chars; give each file its own";
11290
11331
  function forward(operation) {
11291
11332
  const run = (ex, input) => ex.call(operation, input);
11292
11333
  run.operation = operation;
@@ -11721,7 +11762,7 @@ var ACTION_DEFINITIONS = [
11721
11762
  title: "Contribute to a thread",
11722
11763
  description: "Add one additive point to a Thread; use stamp for agreement, respond/mark_as_response for requests, and links for deliberate reply/contest edges. Pass idempotencyKey (CLI: --idempotency-key) for safe retries.",
11723
11764
  inputSchema: {
11724
- threadId: string2().describe("the thread id, e.g. thr_…"),
11765
+ threadId: string2().describe("the thread id, e.g. thr_…, or its short id, e.g. BUD-34"),
11725
11766
  type: _enum(CONTRIBUTION_TYPES).describe("the contribution type (AD-066 taxonomy)"),
11726
11767
  body: string2().min(1).describe(`the contribution markdown. ${CONTRIBUTION_BODY_GUIDANCE}`),
11727
11768
  idempotencyKey: string2().min(1).max(200).optional().describe("stable key for this ONE logical contribution (≤200 chars), e.g. cron-run/work-item + output slot; safe retries return the original contribution. Reusing it with different content is a conflict."),
@@ -11880,7 +11921,7 @@ var ACTION_DEFINITIONS = [
11880
11921
  title: "Edit a thread's title, objective, or topic",
11881
11922
  description: "Correct a Thread's title or objective, or move it under another Topic of the same Space, patching only supplied fields; use it to make done clear or refile misfiled work instead of archiving and reopening. Status moves stay with `transition_thread`, column order with `--rank`, and the temporary help list with `charter` ways_to_help.",
11882
11923
  inputSchema: {
11883
- threadId: string2().describe("the thread to edit, thr_…"),
11924
+ threadId: string2().describe("the thread to edit, thr_… or a short id like BUD-34"),
11884
11925
  title: string2().min(1).optional().describe("a new title — the question, outcome, investigation, instance, or standing concern"),
11885
11926
  objective: string2().min(1).optional().describe("a new objective — what this Thread should accomplish; for finite work, what done means"),
11886
11927
  topicId: string2().optional().describe("move the thread under this topic of the same space, top_…"),
@@ -11896,7 +11937,7 @@ var ACTION_DEFINITIONS = [
11896
11937
  description: "Make a Space member (yourself when profileId is omitted) responsible for moving a Thread, or remove that responsibility; assignment is immediate, idempotent, grants no permissions, and creates no Inbox item — a specific owed action is still a `request`. Assignees stay attached through every lifecycle move, so transfer responsibility by assigning the new owner and unassigning the old.",
11897
11938
  inputSchema: {
11898
11939
  verb: _enum(["assign", "unassign"]).describe("add or remove responsibility"),
11899
- threadId: string2().describe("the thread, thr_…"),
11940
+ threadId: string2().describe("the thread, thr_… or a short id like BUD-34"),
11900
11941
  profileId: string2().optional().describe("the member to (un)assign, prof_…; omit for yourself")
11901
11942
  },
11902
11943
  surfaces: ["mcp", "cli"],
@@ -11997,7 +12038,7 @@ var ACTION_DEFINITIONS = [
11997
12038
  title: "Replace a Thread computer override",
11998
12039
  description: "DESTRUCTIVE REPLACE: Replace this Thread's entire sparse Computer override; omitted fields are cleared from the Thread layer and inherit Topic/Space/organization policy. This is config only and allocates no runtime; an existing Computer cannot switch provider, and repeating the same full layer is idempotent.",
11999
12040
  inputSchema: {
12000
- threadId: string2().describe("the Thread, thr_…"),
12041
+ threadId: string2().describe("the Thread, thr_… or a short id like BUD-34"),
12001
12042
  backend: _enum(["worker-shell", "container"]).nullable().optional().describe("override execution tier, or null to inherit"),
12002
12043
  containerProfile: string2().nullable().optional().describe("logical container profile; bun-typescript-v1 guarantees Bash, Git, GitHub CLI (gh), Bun 1.4.2, bunx, Node 26.5.1, pnpm 10.33.0, Python 3.13.15 (python/python3), jq, and ripgrep (rg), or null to clear inherited profile"),
12003
12044
  setupScript: string2().nullable().optional().describe("optional strict Bash setup for a fresh base after repositories materialize; install scoped tools in /workspace/.local/bin so exec finds them and snapshots retain them"),
@@ -12013,7 +12054,7 @@ var ACTION_DEFINITIONS = [
12013
12054
  title: "Attach a Thread computer",
12014
12055
  description: "Deprecated by AD-306: computer_run_start(threadId) attaches durable identity itself, so use this only to mint protocol identity with no Run at all. Creates or reuses the Thread's stable Computer identity and an immutable collaborator receipt without starting provider runtime.",
12015
12056
  inputSchema: {
12016
- threadId: string2().describe("the Thread whose computer you are attaching, thr_…")
12057
+ threadId: string2().describe("the Thread whose computer you are attaching, thr_… or a short id like BUD-34")
12017
12058
  },
12018
12059
  surfaces: ["cli"],
12019
12060
  toolset: "loop",
@@ -12024,7 +12065,7 @@ var ACTION_DEFINITIONS = [
12024
12065
  title: "Open a Thread computer",
12025
12066
  description: "Deprecated by AD-306: start work with computer_run_start(threadId, mode) instead; this alias remains one release and boots the parent Thread Computer, returning a computerSessionId. An optional owned active runId records a successful session attach as Run retention activity. Use it only for a legacy client that still addresses the parent by cms_ id; unsafe recovery is refused and unresolved newer workspace state stays fail-closed.",
12026
12067
  inputSchema: {
12027
- threadId: string2().describe("the Arbor Thread whose stable computer to open, thr_…"),
12068
+ threadId: string2().describe("the Arbor Thread whose stable computer to open, thr_… or a short id like BUD-34"),
12028
12069
  runId: string2().optional().describe("optional owned active Run; successful open renews its retention deadline"),
12029
12070
  idempotencyKey: string2().trim().min(1).max(200).optional().describe("optional explicit key for ONE distinct logical open; omission reuses the durable profile/Thread/recipe identity; changed options require a distinct intent; retry unchanged after response loss and never mint a new key to bypass unresolved work"),
12030
12071
  vcpus: number2().int().min(1).max(4).optional().describe("optional container vCPU request"),
@@ -12039,7 +12080,7 @@ var ACTION_DEFINITIONS = [
12039
12080
  title: "Replace a Thread computer",
12040
12081
  description: "DESTRUCTIVE RECOVERY: Replace an incompatible active Computer generation from the Thread's effective recipe when Arbor reports structural recipe incompatibility. Preserves the old generation unless the successor restores supported state and passes health.",
12041
12082
  inputSchema: {
12042
- threadId: string2().describe("the Thread whose active Computer generation is replaced, thr_…"),
12083
+ threadId: string2().describe("the Thread whose active Computer generation is replaced, thr_… or a short id like BUD-34"),
12043
12084
  idempotencyKey: string2().min(1).max(200).describe("stable key for this ONE replacement request; reuse it unchanged for retries"),
12044
12085
  reason: string2().min(1).max(500).describe("concise auditable reason for replacing the active generation")
12045
12086
  },
@@ -12052,7 +12093,7 @@ var ACTION_DEFINITIONS = [
12052
12093
  title: "Start a run",
12053
12094
  description: `Start an isolated write Run or shared/read Run with one idempotencyKey; pending admission returns runStartId for receipt observation while the lifecycle owner continues independently. Save work outside the temporary Computer and finish the Run when done; first use: read the Computer guide at ${AGENT_GUIDE_URLS.computer}.`,
12054
12095
  inputSchema: {
12055
- threadId: string2().optional().describe("the Thread whose Computer this Run works in, thr_…; the canonical Run-first front door"),
12096
+ threadId: string2().optional().describe("the Thread whose Computer this Run works in, thr_… or a short id like BUD-34; the canonical Run-first front door"),
12056
12097
  computerSessionId: string2().optional().describe("legacy compatibility alternative to threadId: an existing parent computerSessionId, cms_…"),
12057
12098
  mode: _enum(["read", "write"]).describe("write for mutable work; read for shared inspection"),
12058
12099
  execution: _enum(["shared", "isolated"]).optional().describe("write Run placement; defaults to isolated. Use shared only for intentional live collaboration on the Thread Computer"),
@@ -12234,7 +12275,7 @@ var ACTION_DEFINITIONS = [
12234
12275
  title: "Capture public visual evidence",
12235
12276
  description: "Capture a 1280×720 screenshot of one public HTTPS URL as Thread evidence without opening the project runtime. Returns capture metadata plus ready-to-place attachments[].markdown; put that Markdown exactly where the image belongs and bind the matching attachmentIds, while localhost and authenticated pages remain unavailable and the capture does not judge correctness.",
12236
12277
  inputSchema: {
12237
- threadId: string2().describe("the Arbor Thread that will own the evidence, thr_…"),
12278
+ threadId: string2().describe("the Arbor Thread that will own the evidence, thr_… or a short id like BUD-34"),
12238
12279
  targetUrl: url().refine((value) => value.startsWith("https://"), "targetUrl must use HTTPS").describe("one public HTTPS URL to capture")
12239
12280
  },
12240
12281
  surfaces: ["computer-mcp", "computer-cli"],
@@ -12321,7 +12362,7 @@ var ACTION_DEFINITIONS = [
12321
12362
  filename: string2().min(1).max(1000).optional().describe("upload_url: original filename"),
12322
12363
  mimeType: _enum(AGENT_ATTACHMENT_MIME_TYPES).optional().describe("upload_url: supported MIME matching the bytes"),
12323
12364
  size: number2().optional().describe("upload_url: exact file size, at most 100000000 bytes; compress, split, or link larger files"),
12324
- description: string2().min(1).max(500).optional(),
12365
+ description: string2().min(1).max(500).optional().describe(`upload_url: ${ATTACHMENT_DESCRIPTION_HINT}`),
12325
12366
  sha256: string2().regex(/^[a-f0-9]{64}$/).optional().describe("upload_url: optional SHA-256 hex; also signed into the PUT checksum header"),
12326
12367
  token: string2().optional().describe("attach: token from upload_url; first attach before attachExpiresAt, replay finalized receipts even after expiry"),
12327
12368
  attachmentId: string2().optional().describe("get: attachment id, att_…"),
@@ -12345,7 +12386,7 @@ var ACTION_DEFINITIONS = [
12345
12386
  filename: string2().min(1).max(1000),
12346
12387
  mimeType: _enum(AGENT_ATTACHMENT_MIME_TYPES),
12347
12388
  size: number2(),
12348
- description: string2().min(1).max(500).optional(),
12389
+ description: string2().min(1).max(500).optional().describe(ATTACHMENT_DESCRIPTION_HINT),
12349
12390
  sha256: string2().regex(/^[a-f0-9]{64}$/).optional()
12350
12391
  },
12351
12392
  surfaces: ["cli"],
@@ -12381,11 +12422,13 @@ var ACTION_DEFINITIONS = [
12381
12422
  },
12382
12423
  {
12383
12424
  name: "attachment_upload",
12384
- title: "Upload a local Thread file",
12385
- description: "Upload a local file with Node or Bun (SHA-256 → presigned PUT → attach, 100 MB cap)",
12425
+ title: "Upload local Thread files",
12426
+ description: "Upload local files with Node or Bun (SHA-256 → presigned PUT → attach, 100 MB cap)",
12386
12427
  cliHelpLead: "Requires Node or Bun.",
12387
12428
  cliHelpNotes: [
12388
12429
  "Upload with SHA-256 via upload_url → presigned PUT → attach (100 MB cap); compress, split, or link larger files.",
12430
+ 'Several files: `arbor attachment upload a.png b.png --thread-id thr_… --description "A" --description "B"`. Repeated --description flags pair with the files by ORDER: pass none (each defaults to its filename) or exactly one per file; a value that starts with `--` must be written --description=<value>. The whole batch is checked before the first request, then uploaded one by one; if one fails the output lists what uploaded and what failed (exit non-zero). --dry-run prints the plan and makes no network call.',
12431
+ `Description: ${ATTACHMENT_DESCRIPTION_HINT}. An upload is unbound until you pass its attachmentIds to contribute or respond (max 10 per contribution); to fix a description before binding, re-upload and bind only the new id. Paste attachments[].markdown verbatim; don't rebuild it from the description.`,
12389
12432
  "PUT links expire after five minutes; first attach is allowed until one hour after PUT expiry. Finalized receipts replay after expiry.",
12390
12433
  "JSON syntax checks stop at 10 MB; larger JSON gets UTF-8/control checks only, so validate syntax locally.",
12391
12434
  "js-exec lacks file descriptors, node:crypto, and binary-safe fetch; run this command with Node or Bun.",
@@ -12395,8 +12438,9 @@ var ACTION_DEFINITIONS = [
12395
12438
  `),
12396
12439
  inputSchema: {
12397
12440
  threadId: string2().min(1),
12398
- file: string2().min(1).describe("local file path (or positional file); requires Node or Bun"),
12399
- description: string2().min(1).max(500).optional()
12441
+ file: verbatimList("local file path(s), or positional files; uploaded in order; requires Node or Bun"),
12442
+ description: verbatimList(`${ATTACHMENT_DESCRIPTION_HINT}; repeat --description once per file, paired by order (none = filenames)`).optional(),
12443
+ dryRun: boolean2().optional().describe("print the upload plan (file, mime, size, sha256, description) with no network call")
12400
12444
  },
12401
12445
  surfaces: ["cli"],
12402
12446
  toolset: "loop",
@@ -12515,7 +12559,7 @@ var ACTION_DEFINITIONS = [
12515
12559
  title: "Read a Thread computer",
12516
12560
  description: "Inspect a Thread's provider-free durable Computer recipe/materialization state, compatibility, recoverySafety, lineage, recent receipts, Runs, and reprovision outcomes; setup content and credentials stay redacted. This read never contacts execution; use computer_status for live readiness.",
12517
12561
  inputSchema: {
12518
- threadId: string2().describe("the Thread, thr_…"),
12562
+ threadId: string2().describe("the Thread, thr_… or a short id like BUD-34"),
12519
12563
  lineageLimit: number2().int().min(1).max(100).optional().describe("recent receipts/lineage rows (default 20)")
12520
12564
  },
12521
12565
  surfaces: ["computer-mcp", "cli"],
@@ -12552,7 +12596,7 @@ var ACTION_DEFINITIONS = [
12552
12596
  title: "Set a thread's ways to help",
12553
12597
  description: "Set a thread's READ-ONLY 'ways to help' (AD-038) — a short, curated list of perspectives, skills, or concrete tasks orienting someone on how they could pitch in with THIS thread as it stands (e.g. 'a benchmark on cold-start latency'). It is thread-local and temporary: it is read inside `thread_get`, and it never rolls up into a space/topic orientation surface — a standing statement about what a room wants is the room's standing ways to help (`contributionLanes`, via `set_space_lanes`/`set_topic_lanes`, AD-222). Guidance you READ, NOT an obligation — that's `request`. Replaces the whole list; pass [] to clear.",
12554
12598
  inputSchema: {
12555
- threadId: string2().describe("the thread, thr_…"),
12599
+ threadId: string2().describe("the thread, thr_… or a short id like BUD-34"),
12556
12600
  waysToHelp: proseList("the full ways-to-help list — replaces the existing one; [] clears it")
12557
12601
  },
12558
12602
  surfaces: ["cli"],
@@ -12564,7 +12608,7 @@ var ACTION_DEFINITIONS = [
12564
12608
  title: "Move a thread's lifecycle",
12565
12609
  description: transitionThreadActionDescription(),
12566
12610
  inputSchema: {
12567
- threadId: string2().describe("the thread to move, thr_…"),
12611
+ threadId: string2().describe("the thread to move, thr_… or a short id like BUD-34"),
12568
12612
  to: string2().min(1).describe("label key, case-insensitive label name, or bare type (`resolved` still works as a type name)"),
12569
12613
  reason: string2().optional().describe("optional one-line why, recorded on the event"),
12570
12614
  rank: string2().max(THREAD_RANK_MAX).regex(THREAD_RANK_PATTERN).nullable().optional().describe("LexoRank in the destination column; omit to leave unchanged, null to restore recency. Combined with `--to` this is one atomic write — a bad rank moves nothing")
@@ -12650,7 +12694,7 @@ var ACTION_DEFINITIONS = [
12650
12694
  description: "Bind one Artifact to an existing same-Space Thread Live Preview, making the Artifact the durable entry point for that executable surface. Omit --thread-id to clear the binding; this changes no Artifact source/version and does not create or reconfigure a Computer.",
12651
12695
  inputSchema: {
12652
12696
  artifactId: string2().describe("the Artifact, art_…"),
12653
- threadId: string2().optional().describe("the configured Thread Live Preview to bind, thr_…; omit to clear")
12697
+ threadId: string2().optional().describe("the configured Thread Live Preview to bind, thr_… or a short id like BUD-34; omit to clear")
12654
12698
  },
12655
12699
  surfaces: ["cli"],
12656
12700
  toolset: "artifacts",
@@ -12730,7 +12774,7 @@ var ACTION_DEFINITIONS = [
12730
12774
  title: "Request a contribution or review",
12731
12775
  description: "Request a contribution or review on a Thread: omit aboutContributionId for a contribution answered via respond, or pass it for a review answered only by stamp; explicitly target yourself for a normal Inbox obligation, target other people, or leave it open. Choose completion/deadline and optionally opensAt/opensIn to keep it dormant until scheduled delivery; fulfill a self-target through respond or use cancel_request to retire it.",
12732
12776
  inputSchema: {
12733
- threadId: string2().describe("the thread the request is in, thr_…"),
12777
+ threadId: string2().describe("the thread the request is in, thr_… or a short id like BUD-34"),
12734
12778
  requestedAction: string2().optional().describe("a one-line 'what you're requesting'"),
12735
12779
  requestedType: _enum(CONTRIBUTION_TYPES).optional().describe("the contribution type you're HOPING for (e.g. evidence, decision) — a soft default on the responder's picker, never a constraint; omit for 'any contribution welcome'. Contribution asks only"),
12736
12780
  aboutContributionId: string2().optional().describe("PASS to request a REVIEW of this contribution (con_…, in the thread; met by a stamp, badges that card). OMIT to request a CONTRIBUTION on the thread (met by a respond)."),
@@ -12891,7 +12935,7 @@ var ACTION_DEFINITIONS = [
12891
12935
  title: "Read a thread",
12892
12936
  description: "Read one Thread as compact current context (distillate, requests, artifacts, recent tail, cursored unchanged checks) — not the durable transcript; pass view=full to read contribution bodies and attachments (including Markdown and authenticated URLs) and page history (contribLimit default 50) or all=true for the complete transcript. Read it before contributing so you can answer open requests correctly.",
12893
12937
  inputSchema: {
12894
- threadId: string2().describe("the thread id, e.g. thr_…"),
12938
+ threadId: string2().describe("the thread id, e.g. thr_…, or its short id, e.g. BUD-34"),
12895
12939
  view: _enum(["full", "compact"]).optional().describe("MCP/CLI/API default compact = bounded decision-ready context; full = transcript page"),
12896
12940
  cursor: string2().optional().describe("compact reads only: cursor from the previous compact response; if context is unchanged the response collapses to a tiny unchanged envelope"),
12897
12941
  recentLimit: number2().int().min(1).max(20).optional().describe("compact reads only: latest contributions to include (default 6, max 20)"),
@@ -13062,7 +13106,7 @@ var ACTION_DEFINITIONS = [
13062
13106
  title: "Link a thread to a goal",
13063
13107
  description: "Record that a thread contributes to a goal — the many-to-many link that surfaces the goal on the thread's space and lists the thread under the goal. Idempotent. Reach for this once a thread's work clearly serves a goal; use `list_goals` to find the goal id.",
13064
13108
  inputSchema: {
13065
- threadId: string2().describe("the contributing thread, thr_…"),
13109
+ threadId: string2().describe("the contributing thread, thr_… or a short id like BUD-34"),
13066
13110
  initiativeId: string2().describe("the goal it serves, ini_…")
13067
13111
  },
13068
13112
  surfaces: ["cli"],
@@ -13074,7 +13118,7 @@ var ACTION_DEFINITIONS = [
13074
13118
  title: "Unlink a thread from a goal",
13075
13119
  description: "Remove the link between a thread and a goal (the deliberate inverse of `link_thread_to_goal`). Idempotent. Reach for this when a thread no longer serves the goal it was attached to — the thread and the goal both stay; only the link is dropped.",
13076
13120
  inputSchema: {
13077
- threadId: string2().describe("the thread to unlink, thr_…"),
13121
+ threadId: string2().describe("the thread to unlink, thr_… or a short id like BUD-34"),
13078
13122
  initiativeId: string2().describe("the goal to unlink it from, ini_…")
13079
13123
  },
13080
13124
  surfaces: ["cli"],
@@ -13099,7 +13143,7 @@ var ACTION_DEFINITIONS = [
13099
13143
  description: "List compact goals attached to one Space, or a Thread's linked goals plus its available pick list. Space lists are compact cards; Thread lists page only the available `all` list and keep `linked` complete; view=summary returns 3 active goals; status pages named lifecycles before the page.",
13100
13144
  inputSchema: {
13101
13145
  spaceId: string2().optional().describe("a Space whose attached goals to list, spc_…"),
13102
- threadId: string2().optional().describe("a Thread whose linked and available goals to list, thr_…"),
13146
+ threadId: string2().optional().describe("a Thread whose linked and available goals to list, thr_… or a short id like BUD-34"),
13103
13147
  view: literal("summary").optional().describe("summary: Topics home — 3 most recently active goals (title + in-space linked Thread count). Ignores status. Omit for the attached list."),
13104
13148
  status: union([
13105
13149
  _enum(["active", "paused", "completed", "archived"]),
@@ -13153,6 +13197,7 @@ var ACTION_DEFINITIONS = [
13153
13197
  description: "Create a Space when work needs its own durable collaboration/governance boundary — its own membership, visibility, shared norms, and multiple durable areas; you become its first admin (org owner/admins always can, other members only when enabled), with open/private visibility and optional seeded members. If the work can live under an existing Space with the same people and governance, use a Topic instead.",
13154
13198
  inputSchema: {
13155
13199
  title: string2().min(1).describe("the space title"),
13200
+ key: SPACE_KEY_INPUT.optional().describe("thread short-id prefix (BUD in BUD-34): 1-10 letters/digits, starting with a letter, unique in the org; default: the title's first three letters"),
13156
13201
  purpose: string2().optional().describe("why this boundary exists and what it is around — commonly a Product/System, Program/Function, Portfolio/Network, or Practice/Community — plus its dominant work modes (Build, Operate, Coordinate, Discuss, Share) when that helps; plain prose, not metadata syntax"),
13157
13202
  visibility: _enum(["open", "private"]).optional().describe("open (default) or private (invite-only)"),
13158
13203
  memberProfileIds: array(string2()).optional().describe("org profile ids to seed as members — agents added directly, humans get a pending invite")
@@ -13169,6 +13214,7 @@ var ACTION_DEFINITIONS = [
13169
13214
  spaceId: string2().describe("the space, spc_…"),
13170
13215
  purpose: string2().optional().describe("why this boundary exists and what it is around — commonly a Product/System, Program/Function, Portfolio/Network, or Practice/Community — plus its dominant work modes (Build, Operate, Coordinate, Discuss, Share) when that helps; plain prose, not metadata syntax"),
13171
13216
  title: string2().optional().describe("a new title for the space"),
13217
+ key: SPACE_KEY_INPUT.optional().describe("a new thread short-id prefix (BUD in BUD-34), unique in the org; every thread's short id changes, the old ones stop resolving, and the old key stays reserved to this Space"),
13172
13218
  visibility: _enum(["open", "private"]).optional().describe("open or private (invite-only)"),
13173
13219
  stage: _enum(["exploration", "design", "implementation", "maintenance", "retired"]).optional().describe("set the lifecycle stage; retired archives the Space reversibly and restores with an active stage, but only when no Run is active/suspended and no App is serving"),
13174
13220
  attachWorkflow: string2().optional().describe("add a workflow to this Space's list: a built-in id (`standard`, `queue`, `content`, `experiment`, `funnel`) or a catalog `wfl_…` from `workflow_list`"),
@@ -13379,7 +13425,7 @@ var ACTION_DEFINITIONS = [
13379
13425
  title: "Inspect a thread (superadmin)",
13380
13426
  description: "Platform-operator only: read any thread by id across orgs (its contributions + authors), bypassing the per-org membership boundary for oversight.",
13381
13427
  inputSchema: {
13382
- threadId: string2().min(1).describe("the thread id, e.g. thr_…")
13428
+ threadId: string2().min(1).describe("the canonical thread id, e.g. thr_… (admin reads are cross-org)")
13383
13429
  },
13384
13430
  surfaces: ["cli"],
13385
13431
  toolset: "admin",
@@ -13400,7 +13446,7 @@ var ACTION_DEFINITIONS = [
13400
13446
  description: "List compact goals attached to one Space, or the linked and available goals for one Thread. Space pages omit relatedThreads; Thread pages keep linked complete and page only the available `all` list; view=summary returns 3 active goals; status pages named lifecycles before the page; it cannot mutate a goal.",
13401
13447
  inputSchema: {
13402
13448
  spaceId: string2().optional().describe("a Space whose attached goals to list, spc_…"),
13403
- threadId: string2().optional().describe("a Thread whose linked and available goals to list, thr_…"),
13449
+ threadId: string2().optional().describe("a Thread whose linked and available goals to list, thr_… or a short id like BUD-34"),
13404
13450
  view: literal("summary").optional().describe("summary: Topics home — 3 most recently active goals (title + in-space linked Thread count). Ignores status. Omit for the attached list."),
13405
13451
  status: union([
13406
13452
  _enum(["active", "paused", "completed", "archived"]),
@@ -13432,7 +13478,7 @@ var ACTION_DEFINITIONS = [
13432
13478
  threadIds: array(string2()).optional().describe("create: threads to link at birth"),
13433
13479
  id: string2().optional().describe("update/set_status: the goal, ini_…"),
13434
13480
  status: _enum(["active", "paused", "completed", "archived"]).optional().describe("set_status: the new lifecycle state"),
13435
- threadId: string2().optional().describe("link_thread/unlink_thread: the thread, thr_…"),
13481
+ threadId: string2().optional().describe("link_thread/unlink_thread: the thread, thr_… or a short id like BUD-34"),
13436
13482
  initiativeId: string2().optional().describe("link/unlink/attach/detach: the goal, ini_…")
13437
13483
  },
13438
13484
  surfaces: ["mcp"],
@@ -13616,7 +13662,7 @@ var ACTION_DEFINITIONS = [
13616
13662
  computerSessionId: string2().optional().describe("create: the attached computer session that produced it, cms_…"),
13617
13663
  sourceContributionIds: array(string2()).optional().describe("create: contributions it distills"),
13618
13664
  artifactId: string2().optional().describe("edit/set_live_preview/set_snapshot: the Artifact, art_…"),
13619
- threadId: string2().optional().describe("set_live_preview: the configured Thread Live Preview, thr_…; omit to clear the binding"),
13665
+ threadId: string2().optional().describe("set_live_preview: the configured Thread Live Preview, thr_… or a short id like BUD-34; omit to clear the binding"),
13620
13666
  baseVersion: number2().optional().describe("edit: the version you READ (from get/create) — stale base is rejected with the current source"),
13621
13667
  patches: array(object({ old: string2(), new: string2() })).optional().describe("edit: exact-match hunks applied in order, atomically; each old must match exactly once"),
13622
13668
  note: string2().optional().describe("edit: one-line summary of what changed"),
@@ -14291,7 +14337,7 @@ var ACTION_DEFINITIONS = [
14291
14337
  topicId: string2().optional().describe("topic_guidance/topic_lanes: the topic, top_…"),
14292
14338
  guidance: proseList("space_guidance/topic_guidance: full list, replaces; [] clears").optional(),
14293
14339
  contributionLanes: proseList("space_lanes/topic_lanes: full list of ways to help, replaces; [] clears").optional(),
14294
- threadId: string2().optional().describe("ways_to_help: the thread, thr_…"),
14340
+ threadId: string2().optional().describe("ways_to_help: the thread, thr_… or a short id like BUD-34"),
14295
14341
  waysToHelp: proseList("ways_to_help: full list, replaces; [] clears").optional()
14296
14342
  },
14297
14343
  surfaces: ["mcp"],
@@ -14677,6 +14723,19 @@ class UsageError extends Error {
14677
14723
  }
14678
14724
  }
14679
14725
 
14726
+ class PartialUploadError extends Error {
14727
+ partialResult;
14728
+ code;
14729
+ details;
14730
+ constructor(message, cause, partialResult) {
14731
+ super(message);
14732
+ this.partialResult = partialResult;
14733
+ this.name = "PartialUploadError";
14734
+ this.code = codeForError(cause);
14735
+ this.details = detailsForError(cause);
14736
+ }
14737
+ }
14738
+
14680
14739
  // src/config.ts
14681
14740
  var PROD_API_URL = "https://arborthreads.com";
14682
14741
  var PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -15051,7 +15110,7 @@ var httpExecutor = {
15051
15110
  };
15052
15111
 
15053
15112
  // src/attachment-upload.ts
15054
- import { basename } from "node:path";
15113
+ import { basename, resolve } from "node:path";
15055
15114
  function checkFile(stat) {
15056
15115
  const isFile = typeof stat.isFile === "function" ? stat.isFile() : stat.isFile;
15057
15116
  if (!isFile)
@@ -15061,8 +15120,7 @@ function checkFile(stat) {
15061
15120
  if (!stat.size)
15062
15121
  throw new UsageError("Attachment file is empty (0 bytes); choose a non-empty file");
15063
15122
  }
15064
- async function uploadLocalAttachment(executor, input, fetcher = fetch) {
15065
- const file = String(input.file);
15123
+ async function preflightFile(file) {
15066
15124
  const filename = basename(file);
15067
15125
  const fs = await import("node:fs");
15068
15126
  const initialStat = fs.statSync(file);
@@ -15072,6 +15130,9 @@ async function uploadLocalAttachment(executor, input, fetcher = fetch) {
15072
15130
  throw new UsageError(`Unsupported file extension for ${initialStat.size} bytes (limit ${MAX_ATTACHMENT_BYTES} bytes). Use PNG/JPEG/GIF/WebP/PDF/TXT/JSON/CSV, or link the file.`);
15073
15131
  if ([fs.openSync, fs.fstatSync, fs.readSync, fs.closeSync].some((api) => typeof api !== "function"))
15074
15132
  throw new UsageError("Attachment upload requires Node or Bun: js-exec lacks file descriptors, node:crypto, and binary-safe fetch. Run `arbor attachment upload <file> --thread-id <thread>` with Node or Bun, or use MCP attachment upload_url → curl PUT → attach.");
15133
+ return { fs, local: { file, filename, mimeType, size: initialStat.size } };
15134
+ }
15135
+ async function putLocalAttachment(executor, fs, { file, filename, mimeType }, input, fetcher) {
15075
15136
  const descriptor = fs.openSync(file, "r");
15076
15137
  let bytes;
15077
15138
  try {
@@ -15117,6 +15178,135 @@ async function uploadLocalAttachment(executor, input, fetcher = fetch) {
15117
15178
  throw new ApiError(`Attachment PUT failed (${response.status}) for ${bytes.length} bytes (limit ${MAX_ATTACHMENT_BYTES} bytes). Request a fresh upload_url if expired, then retry; compress, split, or link files above the limit.`, response.status);
15118
15179
  return executor.call("attachment.attach", { token: link.token });
15119
15180
  }
15181
+ function normalizeDescription(value) {
15182
+ return value.trim().replace(/\s+/g, " ");
15183
+ }
15184
+ function asList(value) {
15185
+ if (value === undefined)
15186
+ return [];
15187
+ return (Array.isArray(value) ? value : [value]).map(String);
15188
+ }
15189
+ async function planBatch(input) {
15190
+ const files = asList(input.file);
15191
+ const descriptions = asList(input.description);
15192
+ if (files.length === 0)
15193
+ throw new UsageError("missing required flag: --file");
15194
+ if (descriptions.length > 0 && descriptions.length !== files.length) {
15195
+ throw new UsageError(files.length > 1 && descriptions.length === 1 ? `One --description for ${files.length} files would give every file the same permanent description. Pass one --description per file in file order, or none to default each to its filename.` : `Got ${descriptions.length} --description flag${descriptions.length === 1 ? "" : "s"} for ${files.length} file${files.length === 1 ? "" : "s"}; descriptions pair with files by order, so pass none or exactly one per file.`, { reason: "validation.invalid_value", field: "description" });
15196
+ }
15197
+ for (const description of descriptions) {
15198
+ const length = normalizeDescription(description).length;
15199
+ if (length < 1)
15200
+ throw new UsageError("--description is blank; give each file a real description, or omit --description to default it to the filename", { reason: "validation.invalid_value", field: "description" });
15201
+ if (length > 500)
15202
+ throw new UsageError("--description must be 1–500 characters", {
15203
+ reason: "validation.invalid_value",
15204
+ field: "description"
15205
+ });
15206
+ }
15207
+ const paths = new Map;
15208
+ for (const file of files) {
15209
+ if (!file.trim())
15210
+ throw new UsageError("--file is blank; pass a path to a local file");
15211
+ const key = resolve(file);
15212
+ const first = paths.get(key);
15213
+ if (first !== undefined)
15214
+ throw new UsageError(`${file} is listed twice${first === file ? "" : ` (as ${first})`}; each file uploads once, so list it once.`, { reason: "validation.invalid_value", field: "file" });
15215
+ paths.set(key, file);
15216
+ }
15217
+ let fs;
15218
+ const plan = [];
15219
+ for (const [index, file] of files.entries()) {
15220
+ const prepared = await preflightFile(file);
15221
+ fs = prepared.fs;
15222
+ plan.push({ local: prepared.local, description: descriptions[index] });
15223
+ }
15224
+ const seen = new Map;
15225
+ for (const { local, description } of plan) {
15226
+ if (description === undefined)
15227
+ continue;
15228
+ const key = normalizeDescription(description);
15229
+ const first = seen.get(key);
15230
+ if (first !== undefined)
15231
+ throw new UsageError(`Duplicate --description "${key}" for ${first} and ${local.file}; descriptions are permanent, so give each file its own.`, { reason: "validation.invalid_value", field: "description" });
15232
+ seen.set(key, local.file);
15233
+ }
15234
+ return { fs, plan };
15235
+ }
15236
+ function sha256File(fs, file, createHash) {
15237
+ const hash = createHash("sha256");
15238
+ const chunk = Buffer.alloc(1048576);
15239
+ const descriptor = fs.openSync(file, "r");
15240
+ try {
15241
+ for (;; ) {
15242
+ const count = fs.readSync(descriptor, chunk, 0, chunk.length, null);
15243
+ if (!count)
15244
+ break;
15245
+ hash.update(chunk.subarray(0, count));
15246
+ }
15247
+ } finally {
15248
+ fs.closeSync(descriptor);
15249
+ }
15250
+ return hash.digest("hex");
15251
+ }
15252
+ async function dryRunPlan(fs, threadId, plan) {
15253
+ const { createHash } = await import("node:crypto");
15254
+ return {
15255
+ dryRun: true,
15256
+ threadId,
15257
+ files: plan.map(({ local, description }) => ({
15258
+ file: local.file,
15259
+ filename: local.filename,
15260
+ mimeType: local.mimeType,
15261
+ size: local.size,
15262
+ sha256: sha256File(fs, local.file, createHash),
15263
+ description: description ?? local.filename,
15264
+ descriptionSource: description === undefined ? "filename default" : "explicit",
15265
+ status: "unbound until attached"
15266
+ }))
15267
+ };
15268
+ }
15269
+ function record3(value) {
15270
+ return value !== null && typeof value === "object" ? value : {};
15271
+ }
15272
+ function aggregateReceipt(uploaded, extra = {}) {
15273
+ const warnings = uploaded.flatMap(({ local, receipt }) => asList(record3(receipt).warnings).map((warning) => `${local.filename}: ${warning}`));
15274
+ return {
15275
+ attachmentIds: uploaded.flatMap(({ receipt }) => {
15276
+ const r = record3(receipt);
15277
+ return Array.isArray(r.attachmentIds) ? r.attachmentIds : typeof r.attachmentId === "string" ? [r.attachmentId] : [];
15278
+ }),
15279
+ attachments: uploaded.flatMap(({ receipt }) => {
15280
+ const attachments = record3(receipt).attachments;
15281
+ return Array.isArray(attachments) ? attachments : [];
15282
+ }),
15283
+ nextStep: ATTACHMENT_ATTACH_NEXT_STEP,
15284
+ ...warnings.length > 0 ? { warnings } : {},
15285
+ ...extra
15286
+ };
15287
+ }
15288
+ async function uploadLocalAttachments(executor, input, fetcher = fetch) {
15289
+ const { fs, plan } = await planBatch(input);
15290
+ if (input.dryRun === true)
15291
+ return dryRunPlan(fs, String(input.threadId), plan);
15292
+ const upload = ({ local, description }) => putLocalAttachment(executor, fs, local, { threadId: input.threadId, ...description !== undefined ? { description } : {} }, fetcher);
15293
+ if (plan.length === 1)
15294
+ return upload(plan[0]);
15295
+ const uploaded = [];
15296
+ for (const [index, item] of plan.entries()) {
15297
+ try {
15298
+ uploaded.push({ local: item.local, receipt: await upload(item) });
15299
+ } catch (cause) {
15300
+ const reason = cause instanceof Error ? cause.message : String(cause);
15301
+ const notAttempted = plan.slice(index + 1).map(({ local }) => local.file);
15302
+ throw new PartialUploadError(`Uploaded ${uploaded.length} of ${plan.length} files; ${item.local.file} failed: ${reason.replace(/\.$/, "")}. Uploaded files stay valid but unbound; re-run only the failed and not-attempted files.`, cause, aggregateReceipt(uploaded, {
15303
+ failed: [{ file: item.local.file, error: reason }],
15304
+ notAttempted
15305
+ }));
15306
+ }
15307
+ }
15308
+ return aggregateReceipt(uploaded);
15309
+ }
15120
15310
 
15121
15311
  // src/output.ts
15122
15312
  function writeOutput(value, stream, fallback) {
@@ -15208,15 +15398,20 @@ function emitData(data, _action, ctx) {
15208
15398
  function emitError(err, _action, ctx) {
15209
15399
  const code = codeForError(err);
15210
15400
  const message = err instanceof Error ? err.message : String(err);
15401
+ const partial = err instanceof PartialUploadError ? err.partialResult : undefined;
15211
15402
  if (ctx.json) {
15212
15403
  const envelope = {
15213
15404
  ok: false,
15214
- error: { code, message, ...detailsForError(err) }
15405
+ error: { code, message, ...detailsForError(err) },
15406
+ ...partial !== undefined ? { data: partial } : {}
15215
15407
  };
15216
15408
  writeStdout(`${JSON.stringify(envelope)}
15217
15409
  `);
15218
15410
  process.exitCode = exitCodeForError(code);
15219
15411
  } else {
15412
+ if (partial !== undefined)
15413
+ writeStdout(`${JSON.stringify(partial, null, 2)}
15414
+ `);
15220
15415
  writeStderr(`✗ ${message}
15221
15416
  `);
15222
15417
  process.exitCode = 1;
@@ -15238,7 +15433,7 @@ var DEFAULT_PROCESS_DEADLINE_MS = 1800000;
15238
15433
  var PROCESS_OUTPUT_PAGE_BYTES = 4096;
15239
15434
  var POLL_MS = 400;
15240
15435
  var processRead = ACTIONS3.find((action) => action.name === "computer_process_read");
15241
- function record3(value) {
15436
+ function record4(value) {
15242
15437
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
15243
15438
  }
15244
15439
  function isRunning(value) {
@@ -15266,7 +15461,7 @@ function sleep(ms) {
15266
15461
  return new Promise((resolve) => setTimeout(resolve, ms));
15267
15462
  }
15268
15463
  function isComputerExecHandle(value) {
15269
- const rec = record3(value);
15464
+ const rec = record4(value);
15270
15465
  return Boolean(rec && typeof rec.processId === "string" && (isRunning(rec) || rec.truncated === true));
15271
15466
  }
15272
15467
  async function waitForComputerExec(executor, input, value, ctx) {
@@ -15281,7 +15476,7 @@ async function waitForComputerExec(executor, input, value, ctx) {
15281
15476
  `, ctx);
15282
15477
  while (isRunning(latest) && Date.now() < deadline) {
15283
15478
  await sleep(POLL_MS);
15284
- const read = record3(await processRead.run(executor, {
15479
+ const read = record4(await processRead.run(executor, {
15285
15480
  processId: latest.processId,
15286
15481
  maxBytes: PROCESS_OUTPUT_PAGE_BYTES,
15287
15482
  ...cursor ? { cursor } : {},
@@ -15355,6 +15550,7 @@ function kindOf(node) {
15355
15550
  return "string";
15356
15551
  }
15357
15552
  }
15553
+ var BARE_VALUE_HINT = (flag) => ` (a value that starts with "--" must be written --${flag}=<value>)`;
15358
15554
  function acceptedFlags(inputSchema) {
15359
15555
  const out = new Set;
15360
15556
  for (const spec of flagsForSchema(inputSchema)) {
@@ -15387,7 +15583,8 @@ function flagsForSchema(inputSchema) {
15387
15583
  required: !optional,
15388
15584
  nullable,
15389
15585
  description: description(inner) ?? description(raw),
15390
- literal: isProseList(inner)
15586
+ literal: isProseList(inner),
15587
+ verbatim: isVerbatimList(inner)
15391
15588
  };
15392
15589
  });
15393
15590
  }
@@ -15428,7 +15625,7 @@ function buildInput(inputSchema, flags, command) {
15428
15625
  continue;
15429
15626
  }
15430
15627
  if (spec.kind !== "boolean" && raw === true) {
15431
- throw new UsageError(`--${spec.flag} expects a value`, {
15628
+ throw new UsageError(`--${spec.flag} expects a value${BARE_VALUE_HINT(spec.flag)}`, {
15432
15629
  reason: "validation.missing_field",
15433
15630
  field: spec.field
15434
15631
  });
@@ -15451,6 +15648,10 @@ function buildInput(inputSchema, flags, command) {
15451
15648
  case "array": {
15452
15649
  const repeated = Array.isArray(raw);
15453
15650
  const tokens = repeated ? raw : [String(raw)];
15651
+ if (spec.verbatim) {
15652
+ input[spec.field] = tokens.map(String);
15653
+ break;
15654
+ }
15454
15655
  const out = [];
15455
15656
  for (const token of tokens) {
15456
15657
  const text = String(token).trim();
@@ -15706,6 +15907,7 @@ var POSITIONAL_FIELDS = {
15706
15907
  code_implementations: ["namePath", "path"],
15707
15908
  code_diagnostics: ["path"]
15708
15909
  };
15910
+ var VARIADIC_LAST_POSITIONAL = new Set(["attachment_upload"]);
15709
15911
  function positionalSpecs(action) {
15710
15912
  const fields = POSITIONAL_FIELDS[action.name];
15711
15913
  if (!fields)
@@ -15793,7 +15995,11 @@ function toolNameAliases(action) {
15793
15995
  function helpLine(action) {
15794
15996
  const positionals = positionalSpecs(action);
15795
15997
  const positionalFlags = new Set(positionals.map((spec) => spec.flag));
15796
- const args = positionals.map((spec) => spec.required ? ` <${spec.field}>` : ` [<${spec.field}>]`).join("");
15998
+ const args = positionals.map((spec, index) => {
15999
+ const variadic = VARIADIC_LAST_POSITIONAL.has(action.name) && index === positionals.length - 1;
16000
+ const name = `${spec.field}${variadic ? ">..." : ">"}`;
16001
+ return spec.required ? ` <${name}` : ` [<${name}]`;
16002
+ }).join("");
15797
16003
  const flags = flagsForSchema(action.inputSchema).filter((spec) => !positionalFlags.has(spec.flag)).map((f) => {
15798
16004
  const token = f.kind === "boolean" ? `--${f.flag}` : `--${f.flag} <>`;
15799
16005
  return f.required ? token : `[${token}]`;
@@ -15819,7 +16025,11 @@ function commandHelp(positionals) {
15819
16025
  const token = f.kind === "boolean" ? `--${f.flag}` : `--${f.flag} <${f.kind}>`;
15820
16026
  return f.required ? token : `[${token}]`;
15821
16027
  }).join(" ");
15822
- const usageArgs = helpPositionals.map((spec) => spec.required ? ` <${spec.field}>` : ` [<${spec.field}>]`).join("");
16028
+ const usageArgs = helpPositionals.map((spec, index) => {
16029
+ const variadic = VARIADIC_LAST_POSITIONAL.has(action.name) && index === helpPositionals.length - 1;
16030
+ const name = `${spec.field}${variadic ? ">..." : ">"}`;
16031
+ return spec.required ? ` <${name}` : ` [<${name}]`;
16032
+ }).join("");
15823
16033
  const usage = `Usage: arbor ${commandWords(action)}${usageArgs}${usageFlags ? ` ${usageFlags}` : ""}`;
15824
16034
  const lines = specs.map((f) => {
15825
16035
  const positional = helpPositionals.find((spec) => spec.flag === f.flag);
@@ -15901,10 +16111,12 @@ function applyPositionals(action, extras, flags) {
15901
16111
  if (positionalTargets.length === 0) {
15902
16112
  throw new UsageError(`unexpected argument: ${extras[0]} (\`${commandWords(action)}\` takes no positional — use flags)`);
15903
16113
  }
15904
- if (extras.length > positionalTargets.length) {
16114
+ const fixed = positionalTargets.length - 1;
16115
+ const values = VARIADIC_LAST_POSITIONAL.has(action.name) && extras.length > fixed ? [...extras.slice(0, fixed), extras.slice(fixed)] : extras;
16116
+ if (values.length > positionalTargets.length) {
15905
16117
  throw new UsageError(`too many arguments: \`${commandWords(action)}\` takes ${positionalTargets.length === 1 ? "one positional" : `${positionalTargets.length} positionals`}; pass the rest as flags`);
15906
16118
  }
15907
- for (const [index, value] of extras.entries()) {
16119
+ for (const [index, value] of values.entries()) {
15908
16120
  const target = positionalTargets[index];
15909
16121
  if (flags[target.flag] !== undefined || flags[`${target.flag}-file`] !== undefined) {
15910
16122
  throw new UsageError(`pass --${target.flag} either as a flag or as the positional argument, not both`);
@@ -15942,8 +16154,16 @@ async function runObjectVerb(positionals, flags, ctx) {
15942
16154
  const unknown = Object.keys(flags).filter((f) => !accepted.has(f) && !RESERVED_FLAGS.has(f));
15943
16155
  if (unknown.length > 0) {
15944
16156
  const hints = unknown.map((f) => listFileHint(action, f)).filter(Boolean);
16157
+ const bare = flagsForSchema(action.inputSchema).find((spec) => spec.kind !== "boolean" && flags[spec.flag] === true);
16158
+ if (bare)
16159
+ hints.push(`--${bare.flag} has no value${BARE_VALUE_HINT(bare.flag)}`);
15945
16160
  throw new UsageError(`unknown flag${unknown.length > 1 ? "s" : ""}: ${unknown.map((f) => `--${f}`).join(", ")}${hints.length > 0 ? ` — ${hints.join("; ")}` : ""}`);
15946
16161
  }
16162
+ if (action.name === "attachment_upload") {
16163
+ const dryRun = lastValue(flags["dry-run"]);
16164
+ if (typeof dryRun === "string" && dryRun !== "true" && dryRun !== "false")
16165
+ throw new UsageError(`--dry-run takes no value but swallowed "${dryRun}"; put --dry-run after the files or write --dry-run=true`, { reason: "validation.invalid_value", field: "dryRun" });
16166
+ }
15947
16167
  const inputFlags = { ...flags };
15948
16168
  if (action.name === "app_request" && lastValue(inputFlags.json) === true) {
15949
16169
  delete inputFlags.json;
@@ -15980,7 +16200,7 @@ async function runObjectVerb(positionals, flags, ctx) {
15980
16200
  advise(`Agent registered. Save its apiKey now — it is shown only once:
15981
16201
  `, ctx);
15982
16202
  }
15983
- let value = action.name === "attachment_upload" ? await uploadLocalAttachment(httpExecutor, input) : await action.run(httpExecutor, input);
16203
+ let value = action.name === "attachment_upload" ? await uploadLocalAttachments(httpExecutor, input) : await action.run(httpExecutor, input);
15984
16204
  if (action.name === "computer_exec" && !truthyFlag(flags["no-wait"]) && input.waitForTerminalMs === undefined && input.background !== true) {
15985
16205
  value = await waitForComputerExec(httpExecutor, input, value, ctx);
15986
16206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lambdacurry/arbor",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "The Arbor CLI — a shared workspace for people and agents. The human + headless-agent write path over Arbor's guarded operation surface.",
5
5
  "keywords": [
6
6
  "agents",
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "scripts": {
30
- "arbor": "tsx src/bin.ts",
30
+ "arbor": "vpx src/bin.ts",
31
31
  "build": "bun build ./src/bin.ts --target=node --outfile=dist/arbor.js",
32
32
  "test": "vp test run --passWithNoTests",
33
33
  "typecheck": "tsc -b",
@@ -38,7 +38,6 @@
38
38
  "@arbor/core": "workspace:*",
39
39
  "@types/node": "^26.2.0",
40
40
  "just-bash": "3.4.2",
41
- "tsx": "^4.20.6",
42
41
  "typescript": "^5.8.0",
43
42
  "vite-plus": "catalog:",
44
43
  "zod": "^4.4.3"