@nyte-ai/host 0.0.24 → 0.0.25-beta.1

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  bindConnection2
3
- } from "./canvas-preview-gm0frf4b.js";
3
+ } from "./canvas-preview-2yzyt077.js";
4
4
  import {
5
5
  createStreamChannels2
6
6
  } from "./canvas-preview-rp4x6y1h.js";
@@ -138,12 +138,22 @@ function bindConnection2(connection, options) {
138
138
  await session(id).info();
139
139
  return viewFrames(session(id).head(head).watch(options));
140
140
  },
141
+ "head.see": async ({ session: id, head, commit }) => {
142
+ await session(id).head(head).see(commit);
143
+ return null;
144
+ },
141
145
  "head.history": ({ session: id, head, before, limit }) => session(id).head(head).history({ before, limit }),
142
146
  "head.tree": ({ session: id, head }) => session(id).head(head).tree(),
143
147
  "head.move": async ({ session: id, head, position, options, id: receipt }) => ({
144
148
  receipt: (await wire.move(id, head, position, { ...options, id: receipt })).id
145
149
  }),
146
150
  "head.compact": ({ session: id, head, instructions }) => session(id).head(head).compact({ instructions }),
151
+ "head.revise": ({ session: id, head, message, content, options }) => session(id).head(head).revise(message, content, options),
152
+ "head.hold": ({ session: id, head, message }) => wire.hold(id, head, message),
153
+ "head.release": async ({ session: id, head, message }) => {
154
+ await wire.release(id, head, message);
155
+ return null;
156
+ },
147
157
  "heads.create": ({ session: id, name, input }) => session(id).heads.create(name, input),
148
158
  "heads.merge": ({ session: id, name, options }) => session(id).heads.merge(name, options),
149
159
  "heads.remove": ({ session: id, name, options }) => session(id).heads.remove(name, options),
@@ -175,6 +185,10 @@ function bindConnection2(connection, options) {
175
185
  const answer = await session(id).part(part).answer({ signal });
176
186
  return answer === null ? null : { action: String(answer.action), input: toJson(answer.input) };
177
187
  },
188
+ "part.watch": async ({ session: id, part }) => {
189
+ await session(id).info();
190
+ return session(id).part(part).watch();
191
+ },
178
192
  "part.close": async ({ session: id, part }) => {
179
193
  await session(id).part(part).close();
180
194
  return null;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  listenFetch2
3
- } from "./canvas-preview-9e15hp6q.js";
3
+ } from "./canvas-preview-da6dwy05.js";
4
4
 
5
5
  // src/listen.ts
6
6
  import { createServer } from "node:http";
@@ -21,7 +21,7 @@ import {
21
21
  } from "./canvas-preview-crmzxxt8.js";
22
22
  import {
23
23
  localWorktrees2
24
- } from "./canvas-preview-2jwbe14k.js";
24
+ } from "./canvas-preview-bg63k8n7.js";
25
25
 
26
26
  // src/local/provider.ts
27
27
  import { constants, realpathSync } from "node:fs";
@@ -87,6 +87,7 @@ function localProvider2(options) {
87
87
  return nests(env.cwd, state) || nests(state, env.cwd) && !nests(join(state, "worktrees"), env.cwd) ? undefined : localSnapshots2(env, { store, git: options?.git });
88
88
  },
89
89
  worktrees: localWorktrees2({ home, git: options?.git }),
90
+ realpath: (env, path) => realpath(isAbsolute(path) ? path : join(env.cwd, path)).catch(() => null),
90
91
  async destroy() {
91
92
  throw new SdkError("unavailable", "A local folder is never torn down.");
92
93
  }
@@ -1,6 +1,3 @@
1
- import {
2
- sha2562
3
- } from "./canvas-preview-0nq75h9k.js";
4
1
  import {
5
2
  run2
6
3
  } from "./canvas-preview-bxnqt8cd.js";
@@ -9,9 +6,54 @@ import {
9
6
  import { randomUUID } from "node:crypto";
10
7
  import { copyFile, mkdir, realpath, rm, stat } from "node:fs/promises";
11
8
  import { homedir, tmpdir } from "node:os";
12
- import { basename, dirname, join, resolve } from "node:path";
9
+ import { basename, join, resolve } from "node:path";
13
10
  import { SdkError } from "@nyte-ai/schema";
14
11
  var ENV = { GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" };
12
+ var SESSION_KEY = "nyte-session";
13
+ var STOPWORDS = new Set([
14
+ "a",
15
+ "an",
16
+ "the",
17
+ "and",
18
+ "or",
19
+ "of",
20
+ "to",
21
+ "in",
22
+ "on",
23
+ "for",
24
+ "with",
25
+ "from",
26
+ "by",
27
+ "at",
28
+ "into",
29
+ "as",
30
+ "is",
31
+ "are",
32
+ "be",
33
+ "it",
34
+ "its",
35
+ "this",
36
+ "that",
37
+ "my",
38
+ "our",
39
+ "your"
40
+ ]);
41
+ var TYPE_WORDS = new Set([
42
+ "feat",
43
+ "feature",
44
+ "fix",
45
+ "add",
46
+ "chore",
47
+ "refactor",
48
+ "docs",
49
+ "test",
50
+ "perf",
51
+ "build",
52
+ "ci",
53
+ "revert",
54
+ "update"
55
+ ]);
56
+ var MAX_ATTEMPTS = 100;
15
57
  function gitCommand(binary = "git") {
16
58
  return async (cwd, args, env) => {
17
59
  const ran = await run2(binary, ["-c", "core.quotepath=off", ...args], {
@@ -55,6 +97,51 @@ function localWorktrees2(options) {
55
97
  await rm(index, { force: true });
56
98
  }
57
99
  };
100
+ const findClaimedBranch = async (root, name) => {
101
+ const out = await git(root, [
102
+ "config",
103
+ "-z",
104
+ "--get-regexp",
105
+ `^branch\\..*\\.${SESSION_KEY}$`
106
+ ]).catch(() => "");
107
+ for (const entry of out.split("\x00")) {
108
+ const newline = entry.indexOf(`
109
+ `);
110
+ const branch = entry.slice("branch.".length, newline - SESSION_KEY.length - 1);
111
+ if (newline > 0 && entry.slice(newline + 1) === name && await hasBranch2(root, branch, options))
112
+ return branch;
113
+ }
114
+ return null;
115
+ };
116
+ const claimBranch = async (root, parent, listed, input, head) => {
117
+ for (let attempt = 1;attempt <= MAX_ATTEMPTS; attempt++) {
118
+ const branch = branchName(input.title, attempt);
119
+ const folder = join(parent, branch);
120
+ if (listed.some((worktree) => worktree.path === folder) || await stat(folder).catch(() => null) !== null)
121
+ continue;
122
+ const [created] = await Promise.allSettled([
123
+ git(root, ["branch", "--no-track", branch, head])
124
+ ]);
125
+ if (created.status === "rejected") {
126
+ if (await hasBranch2(root, branch, options))
127
+ continue;
128
+ throw created.reason;
129
+ }
130
+ await writeConfig(root, `branch.${branch}.${SESSION_KEY}`, input.name);
131
+ return branch;
132
+ }
133
+ throw new SdkError("unavailable", `Every worktree name for "${input.title ?? input.name}" is taken in ${root}.`);
134
+ };
135
+ const writeConfig = async (root, key, value) => {
136
+ for (let attempt = 1;; attempt++) {
137
+ const [written] = await Promise.allSettled([git(root, ["config", key, value])]);
138
+ if (written.status === "fulfilled")
139
+ return;
140
+ if (attempt >= 20)
141
+ throw written.reason;
142
+ await new Promise((settle) => setTimeout(settle, 10 * attempt));
143
+ }
144
+ };
58
145
  return {
59
146
  async create(env, input) {
60
147
  const repo = await readRepository(git, env.cwd);
@@ -63,30 +150,32 @@ function localWorktrees2(options) {
63
150
  const head = await commitAt(repo.root, "HEAD");
64
151
  if (head === null)
65
152
  throw new SdkError("unavailable", `${repo.root} has no commits yet, so a worktree has nothing to branch from.`);
66
- const slug = worktreeSlug(input.name, input.title);
67
- const branch = `nyte/${slug}`;
68
153
  const below = (root) => ({
69
154
  kind: "local",
70
155
  path: resolve(root, repo.prefix)
71
156
  });
72
- const listed = (await listWorktrees2(repo.root, options)).find((worktree) => worktree.branch === branch);
73
- if (listed !== undefined)
157
+ const listed = await listWorktrees2(repo.root, options);
158
+ const claimed = await findClaimedBranch(repo.root, input.name);
159
+ const open = listed.find((worktree) => claimed !== null && worktree.branch === claimed);
160
+ if (claimed !== null && open !== undefined)
74
161
  return {
75
- target: below(listed.path),
76
- branch,
77
- base: await commitAt(repo.root, `refs/heads/${branch}`) ?? head
162
+ target: below(open.path),
163
+ branch: claimed,
164
+ base: await commitAt(repo.root, `refs/heads/${claimed}`) ?? head
78
165
  };
79
- const folder = join(folders, basename(repo.root), slug);
80
- const branched = await hasBranch2(repo.root, branch, options);
81
- const base = branched ? await commitAt(repo.root, `refs/heads/${branch}`) ?? head : head;
166
+ const parent = join(folders, basename(repo.root));
167
+ await mkdir(parent, { recursive: true });
168
+ const realParent = await realpath(parent);
169
+ const branch = claimed ?? await claimBranch(repo.root, realParent, listed, input, head);
170
+ const base = claimed === null ? head : await commitAt(repo.root, `refs/heads/${claimed}`) ?? head;
171
+ const folder = join(realParent, branch);
82
172
  const tree = await workingTree(repo);
83
- await mkdir(dirname(folder), { recursive: true });
84
- await addWorktree2(repo.root, branched ? { path: folder, branch } : { path: folder, branch, from: head }, options);
173
+ await addWorktree2(repo.root, { path: folder, branch }, options);
85
174
  if (tree !== (await git(folder, ["rev-parse", "HEAD^{tree}"])).trim()) {
86
175
  await git(folder, ["read-tree", "-m", "-u", "HEAD", tree]);
87
176
  await git(folder, ["reset", "-q"]);
88
177
  }
89
- return { target: below(await realpath(folder)), branch, base };
178
+ return { target: below(folder), branch, base };
90
179
  },
91
180
  async remove(worktree) {
92
181
  const path = "path" in worktree.target ? worktree.target.path : null;
@@ -121,10 +210,13 @@ async function readRepository(git, cwd) {
121
210
  `) ?? [];
122
211
  return root === undefined || gitDir === undefined || root === "" ? null : { root: resolve(root), gitDir: resolve(gitDir), prefix };
123
212
  }
124
- function worktreeSlug(name, title) {
125
- const words = (title ?? "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").slice(0, 40).replace(/^-+|-+$/gu, "");
126
- const key = sha2562(name).slice(0, 8);
127
- return words === "" ? key : `${words}-${key}`;
213
+ function branchName(title, attempt) {
214
+ const words = (title ?? "").normalize("NFKD").toLowerCase().split(/[^a-z0-9]+/u).filter((word) => word !== "");
215
+ const kept = words.filter((word) => !STOPWORDS.has(word));
216
+ const content = kept.filter((word) => !TYPE_WORDS.has(word));
217
+ const chosen = content.length >= 2 ? content : kept.length > 0 ? kept : words;
218
+ const stem = chosen.length === 0 ? "worktree" : chosen.slice(0, attempt === 1 ? 3 : 2).join("-");
219
+ return attempt === 1 ? stem : `${stem}-${attempt}`;
128
220
  }
129
221
 
130
222
  export { listWorktrees2, addWorktree2, hasBranch2, localWorktrees2 };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createHandler2
3
- } from "./canvas-preview-d97wvref.js";
3
+ } from "./canvas-preview-1r9sgbj3.js";
4
4
 
5
5
  // src/listen-fetch.ts
6
6
  import { SdkError } from "@nyte-ai/schema";
@@ -104,6 +104,7 @@ async function listenFetch2(host, options) {
104
104
  };
105
105
  const unwatchCommits = server.onExternalCommit(externalCommit);
106
106
  const isListenerToken = auth.kind === "token" ? (token) => sameSecret(token, auth.token) : async () => false;
107
+ const listenerAuthority = auth.kind === "token" ? auth.authority ?? TOKEN_AUTHORITY : TOKEN_AUTHORITY;
107
108
  const fallback = auth.kind === "custom" ? async (request) => {
108
109
  const verdict = await auth.authorize(request);
109
110
  if ("deny" in verdict)
@@ -129,8 +130,8 @@ async function listenFetch2(host, options) {
129
130
  if (token !== undefined) {
130
131
  if (await isListenerToken(token))
131
132
  return {
132
- connection: connectionFor(flight, TOKEN, TOKEN_AUTHORITY, token),
133
- authority: TOKEN_AUTHORITY
133
+ connection: connectionFor(flight, TOKEN, listenerAuthority, token),
134
+ authority: listenerAuthority
134
135
  };
135
136
  const remembered = await findDevice(token);
136
137
  if (remembered !== null) {
@@ -12,7 +12,7 @@ import {
12
12
  } from "./canvas-preview-8xc0vn7v.js";
13
13
  import {
14
14
  localProvider2
15
- } from "./canvas-preview-e3vnkkc5.js";
15
+ } from "./canvas-preview-9bxhwjrh.js";
16
16
  import {
17
17
  importLegacyTrust2
18
18
  } from "./canvas-preview-9aqczacs.js";
@@ -21,7 +21,7 @@ import {
21
21
  } from "./canvas-preview-4e0pxa32.js";
22
22
  import {
23
23
  listen2
24
- } from "./canvas-preview-whfdhma6.js";
24
+ } from "./canvas-preview-8sv3j0vt.js";
25
25
  import {
26
26
  nodeImages2
27
27
  } from "./canvas-preview-y5y6ghaw.js";
@@ -70,7 +70,7 @@ import webSearch from "@nyte-ai/plugin/core/web-search";
70
70
  // package.json
71
71
  var package_default = {
72
72
  name: "@nyte-ai/host",
73
- version: "0.0.24",
73
+ version: "0.0.25-beta.1",
74
74
  private: false,
75
75
  license: "MIT",
76
76
  repository: {
@@ -175,7 +175,7 @@ var package_default = {
175
175
  },
176
176
  scripts: {
177
177
  clean: "rm -rf node_modules .turbo",
178
- typecheck: "tsrs --noEmit",
178
+ typecheck: "tsc --noEmit",
179
179
  test: "vitest --run",
180
180
  "check:store": "node scripts/check-store.ts",
181
181
  "build:package": "bun ../../scripts/sdk-packages.mjs build host"
@@ -200,7 +200,6 @@ var package_default = {
200
200
  "@types/node": "^26.6.5",
201
201
  "@types/pg": "^8.23.1",
202
202
  "fast-check": "^4.10.2",
203
- typescript: "6.0.3",
204
203
  vitest: "^5.0.5"
205
204
  },
206
205
  engines: {
package/dist/src/index.js CHANGED
@@ -11,15 +11,15 @@ import"../chunks/canvas-preview-wg80rbc2.js";
11
11
  import {
12
12
  identitySigner2
13
13
  } from "../chunks/canvas-preview-5mex725c.js";
14
- import"../chunks/canvas-preview-gm0frf4b.js";
14
+ import"../chunks/canvas-preview-2yzyt077.js";
15
15
  import"../chunks/canvas-preview-rp4x6y1h.js";
16
- import"../chunks/canvas-preview-d97wvref.js";
16
+ import"../chunks/canvas-preview-1r9sgbj3.js";
17
17
  import {
18
18
  listenFetch2
19
- } from "../chunks/canvas-preview-9e15hp6q.js";
19
+ } from "../chunks/canvas-preview-da6dwy05.js";
20
20
  import {
21
21
  listen2
22
- } from "../chunks/canvas-preview-whfdhma6.js";
22
+ } from "../chunks/canvas-preview-8sv3j0vt.js";
23
23
  import"../chunks/canvas-preview-0nq75h9k.js";
24
24
  import"../chunks/canvas-preview-bxnqt8cd.js";
25
25
  import"../chunks/canvas-preview-yjz0f7n4.js";
@@ -51,10 +51,10 @@ import {
51
51
  addWorktree2,
52
52
  hasBranch2,
53
53
  localWorktrees2
54
- } from "../chunks/canvas-preview-2jwbe14k.js";
54
+ } from "../chunks/canvas-preview-bg63k8n7.js";
55
55
  import {
56
56
  localProvider2
57
- } from "../chunks/canvas-preview-e3vnkkc5.js";
57
+ } from "../chunks/canvas-preview-9bxhwjrh.js";
58
58
  import"../chunks/canvas-preview-kdqgrstc.js";
59
59
  import"../chunks/canvas-preview-5z63am63.js";
60
60
  import"../chunks/canvas-preview-pkzqg96v.js";
@@ -84,7 +84,7 @@ import {
84
84
  } from "../chunks/canvas-preview-mstbvwz3.js";
85
85
  import {
86
86
  openHost2
87
- } from "../chunks/canvas-preview-w0mr5bkn.js";
87
+ } from "../chunks/canvas-preview-dt89p9pm.js";
88
88
  import {
89
89
  postgresStore2
90
90
  } from "../chunks/canvas-preview-4b6sayhg.js";
@@ -1,6 +1,6 @@
1
1
  import type { ListenOptions, Listener } from "@nyte-ai/core";
2
2
  import type { ServedHost } from "@nyte-ai/core/server";
3
- /** Serves a composed host as a bare fetch handler with no socket and no `node:*` import, so a Worker can serve it (§41j): `before`, the unauthenticated identity and describe routes, then `createHandler` over one cached connection per principal and authority, every response carrying `X-Nyte-Host` (§43a, §43j). A request authenticates by a pairing code, then the listener token (compared in constant time over SHA-256 digests) or a device token remembered until a commit from another process moves the host's principals, then `authorize` under `custom`; a revoke aborts that principal's open requests and streams. A `token` shorter than 16 characters throws `SdkError("invalid")`; `execution` (default: run covering all) drives covered sessions in process on an `admit-only` host, covers their leases otherwise and takes nothing on a `dispatch` host (§42v); `close()` disconnects the clients and releases the execution once, after which every request answers `unavailable`. */
3
+ /** Serves a composed host as a bare fetch handler with no socket and no `node:*` import, so a Worker can serve it (§41j): `before`, the unauthenticated identity and describe routes, then `createHandler` over one cached connection per principal and authority, every response carrying `X-Nyte-Host` (§43a, §43j). A request authenticates by a pairing code, then the listener token (compared in constant time over SHA-256 digests, granting `auth.authority`) or a device token remembered until a commit from another process moves the host's principals, then `authorize` under `custom`; a revoke aborts that principal's open requests and streams. A `token` shorter than 16 characters throws `SdkError("invalid")`; `execution` (default: run covering all) drives covered sessions in process on an `admit-only` host, covers their leases otherwise and takes nothing on a `dispatch` host (§42v); `close()` disconnects the clients and releases the execution once, after which every request answers `unavailable`. */
4
4
  export declare function listenFetch(host: ServedHost, options: ListenOptions & {
5
5
  readonly transport: {
6
6
  readonly kind: "fetch";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  listenFetch2
3
- } from "../chunks/canvas-preview-9e15hp6q.js";
4
- import"../chunks/canvas-preview-d97wvref.js";
5
- import"../chunks/canvas-preview-gm0frf4b.js";
3
+ } from "../chunks/canvas-preview-da6dwy05.js";
4
+ import"../chunks/canvas-preview-1r9sgbj3.js";
5
+ import"../chunks/canvas-preview-2yzyt077.js";
6
6
  import"../chunks/canvas-preview-rp4x6y1h.js";
7
7
  export {
8
8
  listenFetch2 as listenFetch
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  listen2
3
- } from "../chunks/canvas-preview-whfdhma6.js";
4
- import"../chunks/canvas-preview-9e15hp6q.js";
5
- import"../chunks/canvas-preview-d97wvref.js";
6
- import"../chunks/canvas-preview-gm0frf4b.js";
3
+ } from "../chunks/canvas-preview-8sv3j0vt.js";
4
+ import"../chunks/canvas-preview-da6dwy05.js";
5
+ import"../chunks/canvas-preview-1r9sgbj3.js";
6
+ import"../chunks/canvas-preview-2yzyt077.js";
7
7
  import"../chunks/canvas-preview-rp4x6y1h.js";
8
8
  export {
9
9
  listen2 as listen
@@ -1,5 +1,5 @@
1
1
  import type { EnvironmentProvider } from "@nyte-ai/core";
2
- /** Returns the local environment provider (`kind: "local"`): a folder on this machine. `inspect` answers a `{ kind: "local", path }` target's `folderIdentity` (§41d), `available` when the path is a readable directory (else `unavailable { unreachable }`; a target without a path `unavailable { unsupported }`, identified by the hash of its locator), and the project sources present under it, relative to it, in this order: the plugin files `.nyte/plugins/<name>.{ts,mts,js,mjs}` and `.nyte/plugins/<name>/index.<ext>`, the skills `.nyte/skills/<name>/SKILL.md` and `.agents/skills/<name>/SKILL.md`, the prompts `.nyte/prompts/<name>.md`, then `.nyte/SYSTEM.md`, `.nyte/APPEND_SYSTEM.md` and `.nyte/mcp.json`: the files project trust gates. Context files such as `AGENTS.md` load without trust and are not listed. `attach` resolves the real path (a target that is not a directory throws `SdkError("unavailable")`) and returns an `ExecutionEnv` with that identity, `cwd` the real path, `files` from `localFiles` (with `ripgrep` and `formatter`), `exec` from `localExec`, and a `close` that ends its file watches and the jobs' readers, leaving running jobs alone. `git` is `localGit` (with `git`), `github` is `localGitHub` (with `gh` and `git`; the login is the machine's, `localGitHubAccount`), `snapshots` is `localSnapshots` storing tree walks under `<home>/.nyte/snapshots` (`home` default `os.homedir()`), and absent for a folder that contains `<home>/.nyte` or lies inside it outside `<home>/.nyte/worktrees` (the home folder itself, its ancestors, Nyte's own state): there a snapshot would walk or stage everything under home, the store and secrets included, to report changes in the store it just wrote; `worktrees` is `localWorktrees` (with `home` and `git`); no `browser` (the desktop wraps one in); `destroy` rejects with `SdkError("unavailable")`: a local folder is never torn down. */
2
+ /** Returns the local environment provider (`kind: "local"`): a folder on this machine. `inspect` answers a `{ kind: "local", path }` target's `folderIdentity` (§41d), `available` when the path is a readable directory (else `unavailable { unreachable }`; a target without a path `unavailable { unsupported }`, identified by the hash of its locator), and the project sources present under it, relative to it, in this order: the plugin files `.nyte/plugins/<name>.{ts,mts,js,mjs}` and `.nyte/plugins/<name>/index.<ext>`, the skills `.nyte/skills/<name>/SKILL.md` and `.agents/skills/<name>/SKILL.md`, the prompts `.nyte/prompts/<name>.md`, then `.nyte/SYSTEM.md`, `.nyte/APPEND_SYSTEM.md` and `.nyte/mcp.json`: the files project trust gates. Context files such as `AGENTS.md` load without trust and are not listed. `attach` resolves the real path (a target that is not a directory throws `SdkError("unavailable")`) and returns an `ExecutionEnv` with that identity, `cwd` the real path, `files` from `localFiles` (with `ripgrep` and `formatter`), `exec` from `localExec`, and a `close` that ends its file watches and the jobs' readers, leaving running jobs alone. `git` is `localGit` (with `git`), `github` is `localGitHub` (with `gh` and `git`; the login is the machine's, `localGitHubAccount`), `snapshots` is `localSnapshots` storing tree walks under `<home>/.nyte/snapshots` (`home` default `os.homedir()`), and absent for a folder that contains `<home>/.nyte` or lies inside it outside `<home>/.nyte/worktrees` (the home folder itself, its ancestors, Nyte's own state): there a snapshot would walk or stage everything under home, the store and secrets included, to report changes in the store it just wrote; `worktrees` is `localWorktrees` (with `home` and `git`); `realpath` is `fs.promises.realpath` (the native one, which spells each name as the disk stores it), a relative path resolved against `env.cwd`, null where it fails; no `browser` (the desktop wraps one in); `destroy` rejects with `SdkError("unavailable")`: a local folder is never torn down. */
3
3
  export declare function localProvider(options?: {
4
4
  readonly home?: string;
5
5
  readonly git?: string;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  localProvider2
3
- } from "../../chunks/canvas-preview-e3vnkkc5.js";
4
- import"../../chunks/canvas-preview-2jwbe14k.js";
3
+ } from "../../chunks/canvas-preview-9bxhwjrh.js";
4
+ import"../../chunks/canvas-preview-bg63k8n7.js";
5
5
  import"../../chunks/canvas-preview-5ekjx6sw.js";
6
6
  import"../../chunks/canvas-preview-8xc0vn7v.js";
7
7
  import"../../chunks/canvas-preview-1sq7z9jq.js";
@@ -22,11 +22,15 @@ export declare function hasBranch(cwd: string, name: string, options?: {
22
22
  }): Promise<boolean>;
23
23
  /**
24
24
  * Returns git worktrees for local workspaces (`git`, default `git` on the
25
- * PATH), each under `<home>/.nyte/worktrees/<repository>/<slug>` on the branch
26
- * `nyte/<slug>`, the slug the title's words and a hash of the name.
25
+ * PATH), each under `<home>/.nyte/worktrees/<repository>/<branch>` on a branch
26
+ * named by `branchName`: at most three hyphenated words from the title.
27
27
  *
28
28
  * `create` refuses a folder outside a repository or one without a commit, then
29
- * answers the worktree already on that branch, or adds one from HEAD and
29
+ * answers the worktree on the branch whose `branch.<branch>.nyte-session`
30
+ * config is `name`, or claims the first name with no folder or listed worktree
31
+ * at its path by creating its branch at HEAD (git refuses an existing branch,
32
+ * which also settles a race with a concurrent create: the loser tries the next
33
+ * name), records `name` in that config, and adds a worktree for it; then it
30
34
  * copies the workspace's uncommitted changes in: the worktree's tracked and
31
35
  * untracked files, ignored ones aside, are written to a tree through a
32
36
  * temporary index seeded from the real one (which is never touched), and the
@@ -3,9 +3,8 @@ import {
3
3
  addWorktree2,
4
4
  hasBranch2,
5
5
  localWorktrees2
6
- } from "../../chunks/canvas-preview-2jwbe14k.js";
6
+ } from "../../chunks/canvas-preview-bg63k8n7.js";
7
7
  import"../../chunks/canvas-preview-bxnqt8cd.js";
8
- import"../../chunks/canvas-preview-0nq75h9k.js";
9
8
  export {
10
9
  addWorktree2 as addWorktree,
11
10
  hasBranch2 as hasBranch,
package/dist/src/open.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  openHost2
3
- } from "../chunks/canvas-preview-w0mr5bkn.js";
3
+ } from "../chunks/canvas-preview-dt89p9pm.js";
4
4
  import"../chunks/canvas-preview-4e0pxa32.js";
5
5
  import"../chunks/canvas-preview-y5y6ghaw.js";
6
6
  import"../chunks/canvas-preview-ec2mt7es.js";
7
7
  import"../chunks/canvas-preview-befkjea2.js";
8
8
  import"../chunks/canvas-preview-9aqczacs.js";
9
- import"../chunks/canvas-preview-e3vnkkc5.js";
10
- import"../chunks/canvas-preview-2jwbe14k.js";
9
+ import"../chunks/canvas-preview-9bxhwjrh.js";
10
+ import"../chunks/canvas-preview-bg63k8n7.js";
11
11
  import"../chunks/canvas-preview-5ekjx6sw.js";
12
12
  import"../chunks/canvas-preview-8xc0vn7v.js";
13
13
  import"../chunks/canvas-preview-1sq7z9jq.js";
@@ -26,10 +26,10 @@ import"../chunks/canvas-preview-s94ebwf5.js";
26
26
  import"../chunks/canvas-preview-kdqgrstc.js";
27
27
  import"../chunks/canvas-preview-mstbvwz3.js";
28
28
  import"../chunks/canvas-preview-zaaj99wd.js";
29
- import"../chunks/canvas-preview-whfdhma6.js";
30
- import"../chunks/canvas-preview-9e15hp6q.js";
31
- import"../chunks/canvas-preview-d97wvref.js";
32
- import"../chunks/canvas-preview-gm0frf4b.js";
29
+ import"../chunks/canvas-preview-8sv3j0vt.js";
30
+ import"../chunks/canvas-preview-da6dwy05.js";
31
+ import"../chunks/canvas-preview-1r9sgbj3.js";
32
+ import"../chunks/canvas-preview-2yzyt077.js";
33
33
  import"../chunks/canvas-preview-rp4x6y1h.js";
34
34
  import"../chunks/canvas-preview-skzwvw4d.js";
35
35
  import"../chunks/canvas-preview-bxnqt8cd.js";
@@ -34,6 +34,5 @@ export type Binding = CallBinding & FrameBinding & ByteBinding & OutputBinding;
34
34
  export type BindingOptions = {
35
35
  readonly server: ServerHost;
36
36
  };
37
- /** Returns each row bound to the call it names: ids bind handles on the connection, handles returned become ids, `Set`s and `Map`s become their wire forms; `head.move`, `files.batch` and `browser.open` go through the server port's wire operations with their wire id (§40o, §40x). `head.watch` sends the head's view from the host's fold whole, then each later view as its changes (`viewFrames`), the core watch marking the principal's seen tip (§40a, §54a); `sessions.watch` sends the list whole, then each later list as its order and changed rows (`sessionsFrames`). Authority is the caller's gate: the server checks each row's before binding. */
38
37
  export declare function bindConnection(connection: Connection, options: BindingOptions): Binding;
39
38
  export {};
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  bindConnection2
3
- } from "../../chunks/canvas-preview-gm0frf4b.js";
3
+ } from "../../chunks/canvas-preview-2yzyt077.js";
4
4
  export {
5
5
  bindConnection2 as bindConnection
6
6
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createHandler2
3
- } from "../../chunks/canvas-preview-d97wvref.js";
4
- import"../../chunks/canvas-preview-gm0frf4b.js";
3
+ } from "../../chunks/canvas-preview-1r9sgbj3.js";
4
+ import"../../chunks/canvas-preview-2yzyt077.js";
5
5
  import"../../chunks/canvas-preview-rp4x6y1h.js";
6
6
  export {
7
7
  createHandler2 as createHandler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyte-ai/host",
3
- "version": "0.0.24",
3
+ "version": "0.0.25-beta.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -88,11 +88,11 @@
88
88
  "dependencies": {
89
89
  "@earendil-works/pi-mcp": "^1.3.0",
90
90
  "@lydell/node-pty": "^1.2.0-beta.15",
91
- "@nyte-ai/ai": "0.0.24",
92
- "@nyte-ai/core": "0.0.24",
93
- "@nyte-ai/plugin": "0.0.24",
94
- "@nyte-ai/protocol": "0.0.24",
95
- "@nyte-ai/schema": "0.0.24",
91
+ "@nyte-ai/ai": "0.0.25-beta.1",
92
+ "@nyte-ai/core": "0.0.25-beta.1",
93
+ "@nyte-ai/plugin": "0.0.25-beta.1",
94
+ "@nyte-ai/protocol": "0.0.25-beta.1",
95
+ "@nyte-ai/schema": "0.0.25-beta.1",
96
96
  "@silvia-odwyer/photon": "^0.3.3",
97
97
  "fflate": "^0.8.3",
98
98
  "ignore": "^7.0.13",
@@ -105,7 +105,6 @@
105
105
  "@types/node": "^26.6.5",
106
106
  "@types/pg": "^8.23.1",
107
107
  "fast-check": "^4.10.2",
108
- "typescript": "6.0.3",
109
108
  "vitest": "^5.0.5"
110
109
  },
111
110
  "engines": {
@@ -113,7 +112,7 @@
113
112
  },
114
113
  "scripts": {
115
114
  "clean": "rm -rf node_modules .turbo",
116
- "typecheck": "tsrs --noEmit",
115
+ "typecheck": "tsc --noEmit",
117
116
  "test": "vitest --run",
118
117
  "check:store": "node scripts/check-store.ts",
119
118
  "build:package": "bun ../../scripts/sdk-packages.mjs build host"
@@ -36,7 +36,7 @@ type Device = {
36
36
 
37
37
  const encoder = new TextEncoder();
38
38
 
39
- /** Serves a composed host as a bare fetch handler with no socket and no `node:*` import, so a Worker can serve it (§41j): `before`, the unauthenticated identity and describe routes, then `createHandler` over one cached connection per principal and authority, every response carrying `X-Nyte-Host` (§43a, §43j). A request authenticates by a pairing code, then the listener token (compared in constant time over SHA-256 digests) or a device token remembered until a commit from another process moves the host's principals, then `authorize` under `custom`; a revoke aborts that principal's open requests and streams. A `token` shorter than 16 characters throws `SdkError("invalid")`; `execution` (default: run covering all) drives covered sessions in process on an `admit-only` host, covers their leases otherwise and takes nothing on a `dispatch` host (§42v); `close()` disconnects the clients and releases the execution once, after which every request answers `unavailable`. */
39
+ /** Serves a composed host as a bare fetch handler with no socket and no `node:*` import, so a Worker can serve it (§41j): `before`, the unauthenticated identity and describe routes, then `createHandler` over one cached connection per principal and authority, every response carrying `X-Nyte-Host` (§43a, §43j). A request authenticates by a pairing code, then the listener token (compared in constant time over SHA-256 digests, granting `auth.authority`) or a device token remembered until a commit from another process moves the host's principals, then `authorize` under `custom`; a revoke aborts that principal's open requests and streams. A `token` shorter than 16 characters throws `SdkError("invalid")`; `execution` (default: run covering all) drives covered sessions in process on an `admit-only` host, covers their leases otherwise and takes nothing on a `dispatch` host (§42v); `close()` disconnects the clients and releases the execution once, after which every request answers `unavailable`. */
40
40
  export async function listenFetch(
41
41
  host: ServedHost,
42
42
  options: ListenOptions & { readonly transport: { readonly kind: "fetch" } },
@@ -161,6 +161,9 @@ export async function listenFetch(
161
161
  const isListenerToken: (token: string) => Promise<boolean> =
162
162
  auth.kind === "token" ? (token) => sameSecret(token, auth.token) : async () => false;
163
163
 
164
+ const listenerAuthority =
165
+ auth.kind === "token" ? (auth.authority ?? TOKEN_AUTHORITY) : TOKEN_AUTHORITY;
166
+
164
167
  const fallback: (request: Request, token: string | undefined) => Promise<Verdict> =
165
168
  auth.kind === "custom"
166
169
  ? async (request) => {
@@ -207,8 +210,8 @@ export async function listenFetch(
207
210
  if (token !== undefined) {
208
211
  if (await isListenerToken(token))
209
212
  return {
210
- connection: connectionFor(flight, TOKEN, TOKEN_AUTHORITY, token),
211
- authority: TOKEN_AUTHORITY,
213
+ connection: connectionFor(flight, TOKEN, listenerAuthority, token),
214
+ authority: listenerAuthority,
212
215
  };
213
216
  const remembered = await findDevice(token);
214
217
 
@@ -18,7 +18,7 @@ const PLUGIN_FILE = /\.(?:ts|mts|js|mjs)$/;
18
18
 
19
19
  const ROOT_SOURCES = [".nyte/SYSTEM.md", ".nyte/APPEND_SYSTEM.md", ".nyte/mcp.json"];
20
20
 
21
- /** Returns the local environment provider (`kind: "local"`): a folder on this machine. `inspect` answers a `{ kind: "local", path }` target's `folderIdentity` (§41d), `available` when the path is a readable directory (else `unavailable { unreachable }`; a target without a path `unavailable { unsupported }`, identified by the hash of its locator), and the project sources present under it, relative to it, in this order: the plugin files `.nyte/plugins/<name>.{ts,mts,js,mjs}` and `.nyte/plugins/<name>/index.<ext>`, the skills `.nyte/skills/<name>/SKILL.md` and `.agents/skills/<name>/SKILL.md`, the prompts `.nyte/prompts/<name>.md`, then `.nyte/SYSTEM.md`, `.nyte/APPEND_SYSTEM.md` and `.nyte/mcp.json`: the files project trust gates. Context files such as `AGENTS.md` load without trust and are not listed. `attach` resolves the real path (a target that is not a directory throws `SdkError("unavailable")`) and returns an `ExecutionEnv` with that identity, `cwd` the real path, `files` from `localFiles` (with `ripgrep` and `formatter`), `exec` from `localExec`, and a `close` that ends its file watches and the jobs' readers, leaving running jobs alone. `git` is `localGit` (with `git`), `github` is `localGitHub` (with `gh` and `git`; the login is the machine's, `localGitHubAccount`), `snapshots` is `localSnapshots` storing tree walks under `<home>/.nyte/snapshots` (`home` default `os.homedir()`), and absent for a folder that contains `<home>/.nyte` or lies inside it outside `<home>/.nyte/worktrees` (the home folder itself, its ancestors, Nyte's own state): there a snapshot would walk or stage everything under home, the store and secrets included, to report changes in the store it just wrote; `worktrees` is `localWorktrees` (with `home` and `git`); no `browser` (the desktop wraps one in); `destroy` rejects with `SdkError("unavailable")`: a local folder is never torn down. */
21
+ /** Returns the local environment provider (`kind: "local"`): a folder on this machine. `inspect` answers a `{ kind: "local", path }` target's `folderIdentity` (§41d), `available` when the path is a readable directory (else `unavailable { unreachable }`; a target without a path `unavailable { unsupported }`, identified by the hash of its locator), and the project sources present under it, relative to it, in this order: the plugin files `.nyte/plugins/<name>.{ts,mts,js,mjs}` and `.nyte/plugins/<name>/index.<ext>`, the skills `.nyte/skills/<name>/SKILL.md` and `.agents/skills/<name>/SKILL.md`, the prompts `.nyte/prompts/<name>.md`, then `.nyte/SYSTEM.md`, `.nyte/APPEND_SYSTEM.md` and `.nyte/mcp.json`: the files project trust gates. Context files such as `AGENTS.md` load without trust and are not listed. `attach` resolves the real path (a target that is not a directory throws `SdkError("unavailable")`) and returns an `ExecutionEnv` with that identity, `cwd` the real path, `files` from `localFiles` (with `ripgrep` and `formatter`), `exec` from `localExec`, and a `close` that ends its file watches and the jobs' readers, leaving running jobs alone. `git` is `localGit` (with `git`), `github` is `localGitHub` (with `gh` and `git`; the login is the machine's, `localGitHubAccount`), `snapshots` is `localSnapshots` storing tree walks under `<home>/.nyte/snapshots` (`home` default `os.homedir()`), and absent for a folder that contains `<home>/.nyte` or lies inside it outside `<home>/.nyte/worktrees` (the home folder itself, its ancestors, Nyte's own state): there a snapshot would walk or stage everything under home, the store and secrets included, to report changes in the store it just wrote; `worktrees` is `localWorktrees` (with `home` and `git`); `realpath` is `fs.promises.realpath` (the native one, which spells each name as the disk stores it), a relative path resolved against `env.cwd`, null where it fails; no `browser` (the desktop wraps one in); `destroy` rejects with `SdkError("unavailable")`: a local folder is never torn down. */
22
22
  export function localProvider(options?: {
23
23
  readonly home?: string;
24
24
  readonly git?: string;
@@ -95,6 +95,8 @@ export function localProvider(options?: {
95
95
  : localSnapshots(env, { store, git: options?.git });
96
96
  },
97
97
  worktrees: localWorktrees({ home, git: options?.git }),
98
+ realpath: (env, path) =>
99
+ realpath(isAbsolute(path) ? path : join(env.cwd, path)).catch(() => null),
98
100
  async destroy() {
99
101
  throw new SdkError("unavailable", "A local folder is never torn down.");
100
102
  },
@@ -1,15 +1,65 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { copyFile, mkdir, realpath, rm, stat } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import { SdkError } from "@nyte-ai/schema";
6
6
  import type { WorkspaceTarget } from "@nyte-ai/schema";
7
7
  import type { WorktreeProvider } from "@nyte-ai/core";
8
- import { sha256 } from "../hash.ts";
9
8
  import { run } from "./command.ts";
10
9
 
11
10
  const ENV: Readonly<Record<string, string>> = { GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" };
12
11
 
12
+ /** The branch config key (`branch.<name>.nyte-session`) naming the session a worktree's branch was made for; `git branch -D` drops it with the branch. */
13
+ const SESSION_KEY = "nyte-session";
14
+
15
+ const STOPWORDS = new Set([
16
+ "a",
17
+ "an",
18
+ "the",
19
+ "and",
20
+ "or",
21
+ "of",
22
+ "to",
23
+ "in",
24
+ "on",
25
+ "for",
26
+ "with",
27
+ "from",
28
+ "by",
29
+ "at",
30
+ "into",
31
+ "as",
32
+ "is",
33
+ "are",
34
+ "be",
35
+ "it",
36
+ "its",
37
+ "this",
38
+ "that",
39
+ "my",
40
+ "our",
41
+ "your",
42
+ ]);
43
+
44
+ const TYPE_WORDS = new Set([
45
+ "feat",
46
+ "feature",
47
+ "fix",
48
+ "add",
49
+ "chore",
50
+ "refactor",
51
+ "docs",
52
+ "test",
53
+ "perf",
54
+ "build",
55
+ "ci",
56
+ "revert",
57
+ "update",
58
+ ]);
59
+
60
+ /** How many names `create` tries before giving up: `<words>`, then `<two words>-2` up to this. */
61
+ const MAX_ATTEMPTS = 100;
62
+
13
63
  /** One checkout of a repository: its folder and the branch it has out, null when HEAD is detached. */
14
64
  export interface ListedWorktree {
15
65
  readonly path: string;
@@ -81,11 +131,15 @@ export async function hasBranch(
81
131
 
82
132
  /**
83
133
  * Returns git worktrees for local workspaces (`git`, default `git` on the
84
- * PATH), each under `<home>/.nyte/worktrees/<repository>/<slug>` on the branch
85
- * `nyte/<slug>`, the slug the title's words and a hash of the name.
134
+ * PATH), each under `<home>/.nyte/worktrees/<repository>/<branch>` on a branch
135
+ * named by `branchName`: at most three hyphenated words from the title.
86
136
  *
87
137
  * `create` refuses a folder outside a repository or one without a commit, then
88
- * answers the worktree already on that branch, or adds one from HEAD and
138
+ * answers the worktree on the branch whose `branch.<branch>.nyte-session`
139
+ * config is `name`, or claims the first name with no folder or listed worktree
140
+ * at its path by creating its branch at HEAD (git refuses an existing branch,
141
+ * which also settles a race with a concurrent create: the loser tries the next
142
+ * name), records `name` in that config, and adds a worktree for it; then it
89
143
  * copies the workspace's uncommitted changes in: the worktree's tracked and
90
144
  * untracked files, ignored ones aside, are written to a tree through a
91
145
  * temporary index seeded from the real one (which is never touched), and the
@@ -126,6 +180,77 @@ export function localWorktrees(options?: {
126
180
  }
127
181
  };
128
182
 
183
+ const findClaimedBranch = async (root: string, name: string): Promise<string | null> => {
184
+ const out = await git(root, [
185
+ "config",
186
+ "-z",
187
+ "--get-regexp",
188
+ `^branch\\..*\\.${SESSION_KEY}$`,
189
+ ]).catch(() => "");
190
+
191
+ for (const entry of out.split("\0")) {
192
+ const newline = entry.indexOf("\n");
193
+ const branch = entry.slice("branch.".length, newline - SESSION_KEY.length - 1);
194
+
195
+ if (
196
+ newline > 0 &&
197
+ entry.slice(newline + 1) === name &&
198
+ (await hasBranch(root, branch, options))
199
+ )
200
+ return branch;
201
+ }
202
+
203
+ return null;
204
+ };
205
+
206
+ const claimBranch = async (
207
+ root: string,
208
+ parent: string,
209
+ listed: readonly ListedWorktree[],
210
+ input: { readonly name: string; readonly title: string | null },
211
+ head: string,
212
+ ): Promise<string> => {
213
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
214
+ const branch = branchName(input.title, attempt);
215
+ const folder = join(parent, branch);
216
+
217
+ if (
218
+ listed.some((worktree) => worktree.path === folder) ||
219
+ (await stat(folder).catch(() => null)) !== null
220
+ )
221
+ continue;
222
+
223
+ const [created] = await Promise.allSettled([
224
+ git(root, ["branch", "--no-track", branch, head]),
225
+ ]);
226
+
227
+ if (created.status === "rejected") {
228
+ if (await hasBranch(root, branch, options)) continue;
229
+ throw created.reason;
230
+ }
231
+
232
+ await writeConfig(root, `branch.${branch}.${SESSION_KEY}`, input.name);
233
+
234
+ return branch;
235
+ }
236
+
237
+ throw new SdkError(
238
+ "unavailable",
239
+ `Every worktree name for "${input.title ?? input.name}" is taken in ${root}.`,
240
+ );
241
+ };
242
+
243
+ const writeConfig = async (root: string, key: string, value: string): Promise<void> => {
244
+ for (let attempt = 1; ; attempt++) {
245
+ const [written] = await Promise.allSettled([git(root, ["config", key, value])]);
246
+
247
+ if (written.status === "fulfilled") return;
248
+
249
+ if (attempt >= 20) throw written.reason;
250
+ await new Promise((settle) => setTimeout(settle, 10 * attempt));
251
+ }
252
+ };
253
+
129
254
  return {
130
255
  async create(env, input) {
131
256
  const repo = await readRepository(git, env.cwd);
@@ -143,42 +268,41 @@ export function localWorktrees(options?: {
143
268
  `${repo.root} has no commits yet, so a worktree has nothing to branch from.`,
144
269
  );
145
270
 
146
- const slug = worktreeSlug(input.name, input.title);
147
- const branch = `nyte/${slug}`;
148
271
  const below = (root: string): WorkspaceTarget => ({
149
272
  kind: "local",
150
273
  path: resolve(root, repo.prefix),
151
274
  });
152
275
 
153
- const listed = (await listWorktrees(repo.root, options)).find(
154
- (worktree) => worktree.branch === branch,
155
- );
276
+ const listed = await listWorktrees(repo.root, options);
277
+ const claimed = await findClaimedBranch(repo.root, input.name);
278
+ const open = listed.find((worktree) => claimed !== null && worktree.branch === claimed);
156
279
 
157
- if (listed !== undefined)
280
+ if (claimed !== null && open !== undefined)
158
281
  return {
159
- target: below(listed.path),
160
- branch,
161
- base: (await commitAt(repo.root, `refs/heads/${branch}`)) ?? head,
282
+ target: below(open.path),
283
+ branch: claimed,
284
+ base: (await commitAt(repo.root, `refs/heads/${claimed}`)) ?? head,
162
285
  };
163
286
 
164
- const folder = join(folders, basename(repo.root), slug);
165
- const branched = await hasBranch(repo.root, branch, options);
166
- const base = branched ? ((await commitAt(repo.root, `refs/heads/${branch}`)) ?? head) : head;
287
+ const parent = join(folders, basename(repo.root));
288
+ await mkdir(parent, { recursive: true });
289
+ const realParent = await realpath(parent);
290
+ const branch = claimed ?? (await claimBranch(repo.root, realParent, listed, input, head));
291
+
292
+ const base =
293
+ claimed === null ? head : ((await commitAt(repo.root, `refs/heads/${claimed}`)) ?? head);
294
+
295
+ const folder = join(realParent, branch);
167
296
  const tree = await workingTree(repo);
168
297
 
169
- await mkdir(dirname(folder), { recursive: true });
170
- await addWorktree(
171
- repo.root,
172
- branched ? { path: folder, branch } : { path: folder, branch, from: head },
173
- options,
174
- );
298
+ await addWorktree(repo.root, { path: folder, branch }, options);
175
299
 
176
300
  if (tree !== (await git(folder, ["rev-parse", "HEAD^{tree}"])).trim()) {
177
301
  await git(folder, ["read-tree", "-m", "-u", "HEAD", tree]);
178
302
  await git(folder, ["reset", "-q"]);
179
303
  }
180
304
 
181
- return { target: below(await realpath(folder)), branch, base };
305
+ return { target: below(folder), branch, base };
182
306
  },
183
307
  async remove(worktree) {
184
308
  const path = "path" in worktree.target ? worktree.target.path : null;
@@ -227,13 +351,24 @@ async function readRepository(git: Git, cwd: string): Promise<Repository | null>
227
351
  : { root: resolve(root), gitDir: resolve(gitDir), prefix };
228
352
  }
229
353
 
230
- function worktreeSlug(name: string, title: string | null): string {
354
+ /**
355
+ * Names a worktree's branch from its title: lowercase ASCII words, stopwords
356
+ * dropped, and type words such as `fix` or `add` too while two words are left
357
+ * without them; three words on the first attempt, then two and `-<attempt>`,
358
+ * so a name never has more than three hyphenated parts. `worktree` stands in
359
+ * for a title with no words.
360
+ */
361
+ function branchName(title: string | null, attempt: number): string {
231
362
  const words = (title ?? "")
363
+ .normalize("NFKD")
232
364
  .toLowerCase()
233
- .replace(/[^a-z0-9]+/gu, "-")
234
- .slice(0, 40)
235
- .replace(/^-+|-+$/gu, "");
236
- const key = sha256(name).slice(0, 8);
365
+ .split(/[^a-z0-9]+/u)
366
+ .filter((word) => word !== "");
367
+
368
+ const kept = words.filter((word) => !STOPWORDS.has(word));
369
+ const content = kept.filter((word) => !TYPE_WORDS.has(word));
370
+ const chosen = content.length >= 2 ? content : kept.length > 0 ? kept : words;
371
+ const stem = chosen.length === 0 ? "worktree" : chosen.slice(0, attempt === 1 ? 3 : 2).join("-");
237
372
 
238
- return words === "" ? key : `${words}-${key}`;
373
+ return attempt === 1 ? stem : `${stem}-${attempt}`;
239
374
  }
@@ -103,7 +103,6 @@ const attempts = new WeakMap<ServerHost, Map<string, Attempt>>();
103
103
 
104
104
  const holds = new WeakMap<ServerHost, Map<string, Page[]>>();
105
105
 
106
- /** Returns each row bound to the call it names: ids bind handles on the connection, handles returned become ids, `Set`s and `Map`s become their wire forms; `head.move`, `files.batch` and `browser.open` go through the server port's wire operations with their wire id (§40o, §40x). `head.watch` sends the head's view from the host's fold whole, then each later view as its changes (`viewFrames`), the core watch marking the principal's seen tip (§40a, §54a); `sessions.watch` sends the list whole, then each later list as its order and changed rows (`sessionsFrames`). Authority is the caller's gate: the server checks each row's before binding. */
107
106
  export function bindConnection(connection: Connection, options: BindingOptions): Binding {
108
107
  const { wire } = options.server;
109
108
  const { principal } = connection;
@@ -285,6 +284,11 @@ export function bindConnection(connection: Connection, options: BindingOptions):
285
284
 
286
285
  return viewFrames(session(id).head(head).watch(options));
287
286
  },
287
+ "head.see": async ({ session: id, head, commit }) => {
288
+ await session(id).head(head).see(commit);
289
+
290
+ return null;
291
+ },
288
292
  "head.history": ({ session: id, head, before, limit }) =>
289
293
  session(id).head(head).history({ before, limit }),
290
294
  "head.tree": ({ session: id, head }) => session(id).head(head).tree(),
@@ -293,6 +297,14 @@ export function bindConnection(connection: Connection, options: BindingOptions):
293
297
  }),
294
298
  "head.compact": ({ session: id, head, instructions }) =>
295
299
  session(id).head(head).compact({ instructions }),
300
+ "head.revise": ({ session: id, head, message, content, options }) =>
301
+ session(id).head(head).revise(message, content, options),
302
+ "head.hold": ({ session: id, head, message }) => wire.hold(id, head, message),
303
+ "head.release": async ({ session: id, head, message }) => {
304
+ await wire.release(id, head, message);
305
+
306
+ return null;
307
+ },
296
308
 
297
309
  "heads.create": ({ session: id, name, input }) => session(id).heads.create(name, input),
298
310
  "heads.merge": ({ session: id, name, options }) => session(id).heads.merge(name, options),
@@ -347,6 +359,11 @@ export function bindConnection(connection: Connection, options: BindingOptions):
347
359
  ? null
348
360
  : { action: String(answer.action), input: toJson(answer.input) };
349
361
  },
362
+ "part.watch": async ({ session: id, part }) => {
363
+ await session(id).info();
364
+
365
+ return session(id).part(part).watch();
366
+ },
350
367
  "part.close": async ({ session: id, part }) => {
351
368
  await session(id).part(part).close();
352
369