@join-pantheon/cli 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pins.js ADDED
@@ -0,0 +1,161 @@
1
+ import { accessSync, constants as fsConstants, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readStateFile, withFileLock } from "./file-lock.js";
4
+ import { dirname, join } from "node:path";
5
+ import { CONFIG_PATH } from "./config.js";
6
+ import { canonicalKey, fingerprint } from "./identity.js";
7
+ export const PINS_FILE = "keys.json";
8
+ const home = (h) => h ?? process.env.PANTHEON_HOME ?? dirname(CONFIG_PATH);
9
+ export const pinsPath = (h) => join(home(h), PINS_FILE);
10
+ const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
11
+ export const unsafeKey = (k) => k === "__proto__" || k === "constructor" || k === "prototype";
12
+ const dict = () => Object.create(null);
13
+ export function loadPins(h) {
14
+ let text;
15
+ try {
16
+ const t = readStateFile(pinsPath(h));
17
+ if (t === null)
18
+ return { v: 1, teams: dict() };
19
+ accessSync(pinsPath(h), fsConstants.W_OK);
20
+ text = t;
21
+ }
22
+ catch {
23
+ return { v: 1, teams: {}, corrupt: true };
24
+ }
25
+ try {
26
+ const raw = JSON.parse(text);
27
+ if (!isObj(raw) || raw.v !== 1 || !isObj(raw.teams))
28
+ return { v: 1, teams: {}, corrupt: true };
29
+ const teams = dict();
30
+ for (const [id, t] of Object.entries(raw.teams)) {
31
+ if (!isObj(t) || !isObj(t.members) || unsafeKey(id))
32
+ return { v: 1, teams: {}, corrupt: true };
33
+ const members = dict();
34
+ for (const [m, p] of Object.entries(t.members)) {
35
+ if (!isObj(p) || typeof p.fp !== "string" || unsafeKey(m))
36
+ return { v: 1, teams: {}, corrupt: true };
37
+ members[m] = p;
38
+ }
39
+ teams[id] = { members, ...(typeof t.sigs === "number" ? { sigs: t.sigs } : {}), ...(typeof t.spaceId === "string" ? { spaceId: t.spaceId } : {}) };
40
+ }
41
+ return { v: 1, teams };
42
+ }
43
+ catch {
44
+ return { v: 1, teams: {}, corrupt: true };
45
+ }
46
+ }
47
+ export function savePins(p, h) {
48
+ if (p.corrupt)
49
+ throw new Error(`${pinsPath(h)} could not be read, so it is not written over — fix or remove it (pantheon doctor)`);
50
+ const f = pinsPath(h);
51
+ mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
52
+ writeWhole(f, JSON.stringify(p, null, 2) + "\n");
53
+ }
54
+ export function updatePins(fn, h) {
55
+ return withFileLock(pinsPath(h), () => {
56
+ const p = loadPins(h);
57
+ if (fn(p) && !p.corrupt)
58
+ savePins(p, h);
59
+ return p;
60
+ });
61
+ }
62
+ export { withFileLock };
63
+ export function writeWhole(f, text) {
64
+ const tmp = `${f}.${randomBytes(8).toString("hex")}.tmp`;
65
+ try {
66
+ writeFileSync(tmp, text, { mode: 0o600, flag: "wx" });
67
+ renameSync(tmp, f);
68
+ }
69
+ catch (e) {
70
+ try {
71
+ rmSync(tmp, { force: true });
72
+ }
73
+ catch { }
74
+ throw e;
75
+ }
76
+ }
77
+ export function observeMembers(p, team, members, me, at = Date.now()) {
78
+ const t = (p.teams[team] ??= { members: dict() });
79
+ const out = [];
80
+ const mine = fingerprint(me.publicKey);
81
+ if (t.members[me.member]?.fp !== mine)
82
+ t.members[me.member] = { fp: mine, key: me.publicKey, since: at };
83
+ for (const m of members) {
84
+ if (!m.admitted || m.member === me.member || typeof m.member !== "string" || unsafeKey(m.member))
85
+ continue;
86
+ const fp = m.fingerprint;
87
+ const key = m.pubkey && canonicalKey(m.pubkey) && fingerprint(m.pubkey) === fp ? m.pubkey : undefined;
88
+ const cur = t.members[m.member];
89
+ if (!cur) {
90
+ t.members[m.member] = { fp, ...(key ? { key } : {}), since: at };
91
+ out.push({ member: m.member, kind: "pinned", fp });
92
+ continue;
93
+ }
94
+ if (cur.fp === fp) {
95
+ if (key && !cur.key)
96
+ cur.key = key;
97
+ if (cur.pending)
98
+ delete cur.pending;
99
+ continue;
100
+ }
101
+ if (cur.pending?.fp !== fp) {
102
+ cur.pending = { fp, ...(key ? { key } : {}), since: at };
103
+ out.push({ member: m.member, kind: "changed", fp, was: cur.fp });
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ export const pinFor = (p, team, member) => p.teams[team]?.members[member];
109
+ export const pinScope = (team, me) => `${team} ${me}`;
110
+ export function joinedSigning(since) {
111
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(since ?? "");
112
+ if (!m)
113
+ return false;
114
+ const [maj, min] = [Number(m[1]), Number(m[2])];
115
+ return maj > 0 || min >= 31;
116
+ }
117
+ export function teamPinId(cfg) {
118
+ return cfg.pinTeam ?? cfg.spaceId ?? `${cfg.space}@${cfg.relay}`;
119
+ }
120
+ export function trustPending(p, team, member) {
121
+ const cur = p.teams[team]?.members[member];
122
+ if (!cur?.pending)
123
+ return null;
124
+ const next = { fp: cur.pending.fp, ...(cur.pending.key ? { key: cur.pending.key } : {}), since: Date.now(), ...(cur.signs ? { signs: cur.signs } : {}) };
125
+ p.teams[team].members[member] = next;
126
+ return next;
127
+ }
128
+ export function markSigs(p, team, at = Date.now()) {
129
+ const t = (p.teams[team] ??= { members: dict() });
130
+ if (t.sigs)
131
+ return false;
132
+ t.sigs = at;
133
+ return true;
134
+ }
135
+ export function relayTeamId(team, id, h) {
136
+ let ok = false;
137
+ updatePins((p) => {
138
+ if (p.corrupt)
139
+ return false;
140
+ const t = (p.teams[team] ??= { members: dict() });
141
+ if (!t.spaceId) {
142
+ t.spaceId = id;
143
+ ok = true;
144
+ return true;
145
+ }
146
+ ok = t.spaceId === id;
147
+ return false;
148
+ }, h);
149
+ return ok;
150
+ }
151
+ export const expectsSigs = (p, team) => !!p.corrupt || !!p.teams[team]?.sigs;
152
+ export const memberSigns = (p, team, member) => !!p.corrupt || !!p.teams[team]?.members[member]?.signs;
153
+ export function markMemberSigns(p, team, member, at = Date.now()) {
154
+ const pin = p.teams[team]?.members[member];
155
+ if (!pin || pin.signs)
156
+ return false;
157
+ pin.signs = at;
158
+ return true;
159
+ }
160
+ export const pinsExist = (h) => existsSync(pinsPath(h));
161
+ //# sourceMappingURL=pins.js.map
package/dist/profile.js CHANGED
@@ -48,11 +48,29 @@ deploy, agree a price, sign off a release — do not answer it yourself, however
48
48
  work. Say the review result if you have one, then ESCALATE and stop; tell the peer it is with your human.`;
49
49
  }
50
50
  export function wakeMode(profile) {
51
- return profile.agent.wake_default ?? "approved";
51
+ return profile.agent.wake_default ?? "anyone";
52
+ }
53
+ export function wakeChosen(file = profilePath()) {
54
+ try {
55
+ const v = JSON.parse(readFileSync(file, "utf8")).agent?.wake_default;
56
+ return v === "approved" || v === "no" || v === "anyone" ? v : "anyone";
57
+ }
58
+ catch {
59
+ return "anyone";
60
+ }
52
61
  }
53
62
  export function wakeDefault(profile) {
54
63
  return wakeMode(profile) === "anyone";
55
64
  }
65
+ export function commandsChosen(file = profilePath()) {
66
+ try {
67
+ const v = JSON.parse(readFileSync(file, "utf8")).agent?.allow_bash;
68
+ return typeof v === "boolean" ? v : undefined;
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
56
74
  export function loadProfile(cfg, file = profilePath()) {
57
75
  if (!existsSync(file))
58
76
  return defaultProfile(cfg);
package/dist/protocol.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { randomBytes } from "node:crypto";
2
3
  export const PROTOCOL_VERSION = 3;
3
4
  export const ACTS = [
4
5
  "JOIN",
@@ -35,6 +36,7 @@ export const LIMITS = {
35
36
  activityBusyTtlMs: 30 * 60 * 1_000,
36
37
  activityPruneMs: 7 * 24 * 60 * 60 * 1_000,
37
38
  claimTtlMs: 300_000,
39
+ claimLiveTtlMs: 30 * 60_000,
38
40
  workerFallbackMs: 20_000 + 120_000 + 60_000,
39
41
  pendingTimeoutMs: 1_500,
40
42
  cursorTtlMs: 7 * 24 * 60 * 60 * 1_000,
@@ -80,6 +82,37 @@ function loopback(host) {
80
82
  return "0.0.0.0";
81
83
  return null;
82
84
  }
85
+ export const DelegateCertSchema = z.object({
86
+ pub: z.string().min(32).max(200),
87
+ run: z.string().min(1).max(64),
88
+ expires: z.number().int().positive(),
89
+ sig: z.string().min(32).max(200),
90
+ });
91
+ export const ActSigSchema = z.object({
92
+ v: z.literal(1),
93
+ role: z.enum(["member", "run"]),
94
+ nonce: z.string().min(8).max(64),
95
+ ts: z.number().int().positive(),
96
+ pub: z.string().min(32).max(200),
97
+ s: z.string().min(32).max(200),
98
+ meta: z.record(z.unknown()).optional(),
99
+ repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
100
+ cert: DelegateCertSchema.optional(),
101
+ });
102
+ export const GoalSigSchema = z.object({
103
+ v: z.literal(1),
104
+ role: z.enum(["member", "run"]),
105
+ nonce: z.string().min(8).max(64),
106
+ ts: z.number().int().positive(),
107
+ pub: z.string().min(32).max(200),
108
+ s: z.string().min(32).max(200),
109
+ invite: z.array(z.string().min(1).max(48)).max(16).nullable(),
110
+ budget: z.number().int().nullable().optional(),
111
+ deadline: z.number().int().nullable().optional(),
112
+ kind: z.string().max(32).nullable().optional(),
113
+ cert: DelegateCertSchema.optional(),
114
+ });
115
+ export const RELAY_FEATURES = ["act-sig", "delegate"];
83
116
  export const Hello = z.object({
84
117
  t: z.literal("hello"),
85
118
  v: z.number().int().optional(),
@@ -98,6 +131,7 @@ export const Hello = z.object({
98
131
  from_seq: z.number().int().nonnegative().optional(),
99
132
  repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
100
133
  repo_origin: z.string().max(200).optional(),
134
+ delegate: DelegateCertSchema.optional(),
101
135
  });
102
136
  export const OrganizationProfileSchema = z.object({
103
137
  version: z.literal(1),
@@ -147,12 +181,14 @@ export const Admit = z.object({
147
181
  member: z.string().min(1).max(48),
148
182
  may_wake: z.boolean().default(false),
149
183
  revoke: z.boolean().default(false),
184
+ wake_only: z.boolean().optional(),
150
185
  forget: z.boolean().default(false),
151
186
  });
152
187
  export const SendAct = z.object({
153
188
  t: z.literal("act"),
154
189
  cid: z.string().max(64).optional(),
155
190
  after_seq: z.number().int().nonnegative().optional(),
191
+ id: z.string().regex(/^a_[A-Za-z0-9]{6,48}$/).optional(),
156
192
  act: z.enum(ACTS),
157
193
  body: z.string().max(LIMITS.bodyChars),
158
194
  summary: z.string().max(LIMITS.summaryChars).optional(),
@@ -163,6 +199,7 @@ export const SendAct = z.object({
163
199
  affects: z.array(z.string().min(1).max(48)).max(16).optional(),
164
200
  meta: z.record(z.unknown()).optional(),
165
201
  repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
202
+ sig: ActSigSchema.optional(),
166
203
  });
167
204
  export const BindRepo = z.object({
168
205
  t: z.literal("bind_repo"),
@@ -174,12 +211,14 @@ export const BindRepo = z.object({
174
211
  export const OpenGoal = z.object({
175
212
  t: z.literal("open_goal"),
176
213
  cid: z.string().max(64).optional(),
214
+ id: z.string().regex(/^g_[a-z0-9]{8,40}$/).optional(),
177
215
  title: z.string().min(1).max(200),
178
216
  acceptance: z.string().max(2000).default(""),
179
217
  invite: z.array(z.string().min(1).max(48)).max(16).optional(),
180
218
  budget: z.number().int().min(4).max(1000).optional(),
181
219
  deadline: z.number().int().positive().optional(),
182
220
  kind: z.enum(["worklist"]).optional(),
221
+ sig: GoalSigSchema.optional(),
183
222
  });
184
223
  export const Ping = z.object({ t: z.literal("ping") });
185
224
  export const DeleteSpace = z.object({
@@ -223,6 +262,6 @@ export function isContinuationFor(act, me, ctx) {
223
262
  && ctx.goalMembers.includes(me);
224
263
  }
225
264
  export function newId(prefix) {
226
- return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
265
+ return `${prefix}_${Date.now().toString(36)}${randomBytes(5).readUIntBE(0, 5).toString(36).padStart(8, "0").slice(0, 8)}`;
227
266
  }
228
267
  //# sourceMappingURL=protocol.js.map
@@ -1,24 +1,25 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:net";
4
- import { basename, join as pathJoin } from "node:path";
4
+ import { basename, dirname, join as pathJoin, resolve as resolvePath } from "node:path";
5
5
  import { homedir, hostname, platform, userInfo } from "node:os";
6
6
  import { lookup } from "node:dns/promises";
7
7
  import { describeNetError } from "./network.js";
8
8
  import { PantheonClient } from "./client.js";
9
- import { load, loadAll, identity, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
10
- import { loadProfile, wakeDefault } from "./profile.js";
9
+ import { holdsRepo } from "./repo-find.js";
10
+ import { load, loadAll, identity, save, toInvite, resolveFor, repoInfo, bindRepo, bindFolder, folderBinding, DEFAULT_RELAY } from "./config.js";
11
+ import { commandsChosen, loadProfile, wakeChosen, wakeDefault } from "./profile.js";
11
12
  import { newKeypair } from "./identity.js";
12
13
  import { installForUser } from "./install.js";
13
14
  import { writeIntent, installHooks } from "./notify.js";
14
- import { detectAvailable, agentById } from "./agents.js";
15
+ import { detectAvailable, agentById, agentsNeedingUnsandboxed } from "./agents.js";
15
16
  import { installService, servicesUnavailable } from "./service.js";
16
17
  import { ensureNativeNotifier } from "./notifier.js";
17
18
  import { ensureTrayBinary, writeTrayCommands } from "./tray.js";
18
19
  import { desktopUnavailable, ephemeralWarning, hostEnv } from "./host-env.js";
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
20
21
  import { localAddresses } from "./machine.js";
21
- import { grant as grantCap, loadPolicy, policyPath, savePolicy } from "./worker-policy.js";
22
+ import { grant as grantCap, loadPolicy, policyPath, savePolicy, unsandboxedAllowed } from "./worker-policy.js";
22
23
  import { Relay } from "./relay.js";
23
24
  import { which } from "./which.js";
24
25
  const sh = (cmd) => {
@@ -121,23 +122,68 @@ export function wireUp(opts, deps = {}) {
121
122
  catch {
122
123
  return false;
123
124
  } })();
124
- if (!isRepo && how === "explicit") {
125
+ const boundFolder = !isRepo && (() => { try {
126
+ const f = loadAll();
127
+ return !!f && !!folderBinding(f, project);
128
+ }
129
+ catch {
130
+ return false;
131
+ } })();
132
+ if (boundFolder) {
133
+ try {
134
+ const why = notLoaded(installSvc("worker", project, []));
135
+ if (why) {
136
+ tools.push({ tool: "worker", status: `installed, not running: ${why}` });
137
+ warnings.push(`the worker is installed but not running (${why}) — this machine cannot answer the team while you are away. Try \`pantheon service restart worker\``);
138
+ }
139
+ else {
140
+ tools.push({ tool: "worker", status: `on — answers peers from this folder while you are away (it reads it; anything it writes goes to a drafts folder for you)` });
141
+ granted.push(`${wakeChosen() === "approved" ? "A teammate you approve from this machine (pantheon wake allow <name>)" : "Your teammates"} can start an agent run that READS ${project} and answers them. It never changes that folder: drafts go to a folder of its own for you to review.`);
142
+ const hasClaude = (() => { try {
143
+ return detectAvailable().some((a) => a.backend.id === "claude-code");
144
+ }
145
+ catch {
146
+ return true;
147
+ } })();
148
+ if (!hasClaude)
149
+ warnings.push(`answering from a plain folder while you are away needs Claude Code (claude), and it is not installed here — until it is, teammates' requests for this folder wait for your live session`);
150
+ }
151
+ }
152
+ catch (e) {
153
+ tools.push({ tool: "worker", status: `could not install: ${e.message}` });
154
+ warnings.push(`the worker did not install: ${e.message}`);
155
+ }
156
+ }
157
+ else if (!isRepo && how === "explicit") {
125
158
  tools.push({ tool: "worker", status: `not installed — ${project} is not a git repo` });
126
159
  warnings.push(`--unattended needs a git repo to work in; pass --project <a clone>. Nothing was installed.`);
127
160
  }
128
161
  else if (!isRepo) {
129
- tools.push({ tool: "worker", status: `not on — run this from your project's git repo to enable, or later: pantheon service install worker --project <repo>` });
162
+ tools.push({ tool: "worker", status: `not on yet — nothing here is bound to the team. Bind a repo or docs folder (pantheon repo find, or pantheon use <team> --here), then: pantheon service install worker` });
130
163
  }
131
164
  else {
132
165
  try {
133
- const why = notLoaded(installSvc("worker", project, []));
166
+ const commandsOn = commandsChosen() !== false;
167
+ const why = notLoaded(installSvc("worker", project, commandsOn ? ["--allow-bash"] : []));
134
168
  if (why) {
135
169
  tools.push({ tool: "worker", status: `installed, not running: ${why}` });
136
170
  warnings.push(`the worker is installed but not running (${why}) — this machine cannot answer the team while you are away. Try \`pantheon service restart worker\``);
137
171
  return { tools, granted, warnings };
138
172
  }
139
173
  tools.push({ tool: "worker", status: `on — answers peers while you are away (${project})` });
140
- granted.push(`A teammate you approve from this machine (pantheon admit <name> --may-wake) — or anyone on the team, if you choose wake "anyone" — can start an agent run here, in an isolated worktree under ${project}. Nobody can until then.`);
174
+ granted.push(wakeChosen() === "approved"
175
+ ? `A teammate you approve here can start an agent run on this machine, in an isolated worktree under ${project}: you are asked the first time each one's request needs it (Always / Just this once / No), or approve ahead with pantheon wake allow <name>. Under wake "anyone", every teammate the team lets wake can.`
176
+ : `Your teammates can hand work to this machine while you're away, so nothing waits on you: it runs in its own copy of ${project} and delivers on a branch for your review. To approve each teammate first: pantheon setup --wake approved`);
177
+ if (commandsOn)
178
+ granted.push(`That work runs your tests, commits, and pushes to its own branch, so it arrives ready to review. Edits only instead: pantheon setup --commands no`);
179
+ const away = (() => { try {
180
+ return agentsNeedingUnsandboxed();
181
+ }
182
+ catch {
183
+ return [];
184
+ } })();
185
+ if (away.length && unsandboxedAllowed(loadPolicy()))
186
+ granted.push(`${away.join(", ")} ${away.length === 1 ? "answers" : "answer"} those requests, so teammates get answers even when you're offline. To have them wait for you instead: pantheon setup --unsandboxed no`);
141
187
  if (!existsSync(policyPath())) {
142
188
  const pol = loadPolicy();
143
189
  grantCap(pol, "self_upgrade", "always", "setup-default");
@@ -357,8 +403,7 @@ export async function start(opts) {
357
403
  const reach = chosen
358
404
  ? (place === "loopback"
359
405
  ? { url: chosen, how: "as given", shareable: false,
360
- hint: `That address is this machine only — a teammate cannot reach it.\n` +
361
- ` Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
406
+ hint: `A teammate cannot reach it. Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
362
407
  : { url: chosen, how: place === "this-machine" ? "this machine's address" : place === "tunnel" ? "a tunnel to this machine" : opts.relay ? "as given" : "your signed-in relay", shareable: true })
363
408
  : findReach(opts.port);
364
409
  const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, inviteMayWake: false, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
@@ -430,6 +475,7 @@ export async function start(opts) {
430
475
  }
431
476
  let invite = null;
432
477
  let inviteMayWake = false;
478
+ let folderBound = null;
433
479
  let claimed = false;
434
480
  const claim = async (c) => {
435
481
  const client = new PantheonClient(c);
@@ -441,6 +487,10 @@ export async function start(opts) {
441
487
  if (repo && !client.repos.some((r) => r.fingerprint === repo.fingerprint)) {
442
488
  await client.bindRepo(repo.fingerprint, repo.origin).catch((e) => warnings.push(`could not register this repo with the team: ${e.message}`));
443
489
  }
490
+ if (!repo && c.spaceId && bindableFolder(process.cwd())) {
491
+ bindFolder(process.cwd(), c.spaceId);
492
+ folderBound = process.cwd();
493
+ }
444
494
  if (repo && c.spaceId) {
445
495
  const { replaced } = bindRepo(repo, c.spaceId);
446
496
  const was = replaced ?? (here.binding && here.binding.space !== c.spaceId ? here.binding : null);
@@ -449,7 +499,7 @@ export async function start(opts) {
449
499
  warnings.push(`this repo was bound to "${name}"; it now belongs to "${c.space}" — agents opened here speak in "${c.space}" from now on (pantheon use ${name} --here moves it back)`);
450
500
  }
451
501
  }
452
- opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : ""}`);
502
+ opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : folderBound ? ` this folder → bound to it` : ""}`);
453
503
  const mayWake = opts.wake ? opts.wake === "anyone" : wakeDefault(loadProfile(c));
454
504
  const inv = await client.makeInvite(mayWake, opts.ttlMinutes);
455
505
  invite = toInvite({ ...c, token: inv.url });
@@ -524,6 +574,10 @@ export async function join(opts) {
524
574
  cfg.spaceId = client.spaceId;
525
575
  save(cfg);
526
576
  const warnings = [];
577
+ if (!repo && cfg.spaceId && bindableFolder(process.cwd())) {
578
+ bindFolder(process.cwd(), cfg.spaceId);
579
+ opts.log(` folder ${process.cwd()} → bound to "${cfg.space}" (agents opened here speak in it; no git needed)`);
580
+ }
527
581
  if (repo && cfg.spaceId) {
528
582
  const { replaced } = bindRepo(repo, cfg.spaceId);
529
583
  if (replaced)
@@ -544,6 +598,33 @@ export async function join(opts) {
544
598
  });
545
599
  return { cfg, tools: wired.tools, granted: wired.granted, warnings: [...warnings, ...wired.warnings] };
546
600
  }
601
+ export function bindableFolder(dir) {
602
+ const real = (d) => { try {
603
+ return realpathSync.native(d);
604
+ }
605
+ catch {
606
+ return resolvePath(d);
607
+ } };
608
+ const here = real(dir), home = real(homedir());
609
+ if (dirname(here) === here || here === home || home.startsWith(here.endsWith("/") ? here : `${here}/`))
610
+ return false;
611
+ if (["Downloads", "Desktop", "Documents", "Library", "tmp"].some((n) => here === pathJoin(home, n)))
612
+ return false;
613
+ try {
614
+ if (holdsRepo(here))
615
+ return false;
616
+ }
617
+ catch {
618
+ return false;
619
+ }
620
+ try {
621
+ const f = loadAll();
622
+ if (Object.values(f?.bindings ?? {}).some((b) => { const p = real(b.path); return p !== here && p.startsWith(`${here}/`); }))
623
+ return false;
624
+ }
625
+ catch { }
626
+ return true;
627
+ }
547
628
  function detectAgent() {
548
629
  const found = detectAvailable();
549
630
  return found[0]?.backend.id ?? "unknown";
package/dist/reads.js CHANGED
@@ -16,7 +16,14 @@ export function signedReadQuery(cfg, path, params, at) {
16
16
  full[k] = String(v);
17
17
  full.reader = cfg.member;
18
18
  full.ts = String(at);
19
- const sig = signRead(cfg.privateKey, "GET", path, full);
19
+ const d = cfg.delegate;
20
+ if (d) {
21
+ full.dpub = d.cert.pub;
22
+ full.drun = d.cert.run;
23
+ full.dexp = String(d.cert.expires);
24
+ full.dcert = d.cert.sig;
25
+ }
26
+ const sig = signRead(d ? d.privateKey : cfg.privateKey, "GET", path, full);
20
27
  return new URLSearchParams({ ...full, sig }).toString();
21
28
  }
22
29
  export async function signedRead(cfg, path, params = {}, opts = {}) {