toolroll 0.9.82 → 0.9.83

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.
@@ -422,22 +422,6 @@ export type ProjectPeek = {
422
422
  * GitHub identity. Everything else is null.
423
423
  */
424
424
  export declare function githubIdentityOf(remoteUrl: string): string | null;
425
- /**
426
- * The `origin` remote's url from a repository's OWN .git/config. BOUNDED
427
- * I/O by construction (ghlist review, finding 1): the final component may
428
- * not be a symlink (O_NOFOLLOW + lstat), must be a REGULAR file under a
429
- * size cap, and at most 64 KiB are ever read — a sparse monster or a fifo
430
- * planted as a "config" reads as null, never as a hang. Parsed LINE BY
431
- * LINE with real section tracking (finding 2): a `[remote "origin"]`
432
- * embedded inside some other value never opens the section. null when
433
- * unreadable or origin-less; a worktree-style `.git` FILE (gitdir pointer)
434
- * reads null too, which is honest — its identity lives elsewhere. The
435
- * answer is ADVISORY metadata for offering a button: the /projects/open
436
- * road re-proves path, ceiling, and git-ness before anything mutates, and
437
- * a config that lies about its origin can only mislabel a repository the
438
- * operator was already allowed to open.
439
- */
440
- export declare function originUrlOf(repoPath: string): string | null;
441
425
  /** The GitHub listing page: the account's repositories with ONE honest
442
426
  * action each. Names, descriptions, and paths are gh/filesystem DATA —
443
427
  * escaped at the sink like everything else. */
@@ -450,6 +434,8 @@ export declare function githubReposPage(chrome: Chrome, data: {
450
434
  csrf: string;
451
435
  cloneReady: boolean;
452
436
  openProject: string | null;
437
+ /** A projects folder didn't answer in time: local copies weren't matched this time. */
438
+ localUnanswered?: boolean;
453
439
  }): Screen;
454
440
  export declare function projectsPage(chrome: Chrome, recent: {
455
441
  path: string;
@@ -22,7 +22,6 @@ import { runCostWords, spendLine, tally } from "../summary.js";
22
22
  import {} from "../work-index.js";
23
23
  import { WORK_VIEWS } from "../workspace-ui.js";
24
24
  import { createHash } from "node:crypto";
25
- import { closeSync, constants as fsConstants, lstatSync, openSync, readSync } from "node:fs";
26
25
  import { homedir } from "node:os";
27
26
  import { join } from "node:path";
28
27
  import { buildsViews, CHEVRON_ICON, consoleSpend, FOLDER_PATHS, GRIP_HANDLE, isOverdue, projectChip, relativeAge, safePrUrl, screen, sentenceCase, strokeIcon, TO_FRONT_ICON, when, whenTime } from "./chrome.js";
@@ -865,60 +864,6 @@ export function githubIdentityOf(remoteUrl) {
865
864
  return `${ssh[1]}/${ssh[2]}`;
866
865
  return null;
867
866
  }
868
- /**
869
- * The `origin` remote's url from a repository's OWN .git/config. BOUNDED
870
- * I/O by construction (ghlist review, finding 1): the final component may
871
- * not be a symlink (O_NOFOLLOW + lstat), must be a REGULAR file under a
872
- * size cap, and at most 64 KiB are ever read — a sparse monster or a fifo
873
- * planted as a "config" reads as null, never as a hang. Parsed LINE BY
874
- * LINE with real section tracking (finding 2): a `[remote "origin"]`
875
- * embedded inside some other value never opens the section. null when
876
- * unreadable or origin-less; a worktree-style `.git` FILE (gitdir pointer)
877
- * reads null too, which is honest — its identity lives elsewhere. The
878
- * answer is ADVISORY metadata for offering a button: the /projects/open
879
- * road re-proves path, ceiling, and git-ness before anything mutates, and
880
- * a config that lies about its origin can only mislabel a repository the
881
- * operator was already allowed to open.
882
- */
883
- export function originUrlOf(repoPath) {
884
- const file = join(repoPath, ".git", "config");
885
- let fd = null;
886
- try {
887
- const stat = lstatSync(file);
888
- if (!stat.isFile() || stat.size > 1024 * 1024)
889
- return null;
890
- fd = openSync(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
891
- const buffer = Buffer.alloc(64 * 1024);
892
- const read = readSync(fd, buffer, 0, buffer.length, 0);
893
- const config = buffer.toString("utf8", 0, read);
894
- let inOrigin = false;
895
- for (const line of config.split("\n")) {
896
- if (/^\s*\[/.test(line)) {
897
- inOrigin = /^\s*\[remote "origin"\]\s*$/.test(line);
898
- continue;
899
- }
900
- if (!inOrigin)
901
- continue;
902
- const url = /^\s*url\s*=\s*(.+)$/.exec(line)?.[1];
903
- if (url !== undefined)
904
- return url.trim();
905
- }
906
- return null;
907
- }
908
- catch {
909
- return null;
910
- }
911
- finally {
912
- if (fd !== null) {
913
- try {
914
- closeSync(fd);
915
- }
916
- catch {
917
- // already closed
918
- }
919
- }
920
- }
921
- }
922
867
  /** The GitHub listing page: the account's repositories with ONE honest
923
868
  * action each. Names, descriptions, and paths are gh/filesystem DATA —
924
869
  * escaped at the sink like everything else. */
@@ -955,6 +900,7 @@ export function githubReposPage(chrome, data) {
955
900
  return screen("projects", joinHtml([
956
901
  html `<h1>Your GitHub repositories</h1>`,
957
902
  html `<p class="meta">Repositories available to the GitHub account signed in on this machine — open one you already have, or clone a new one after a quick preview.</p>`,
903
+ data.localUnanswered === true ? html `<p class="meta">Your projects folder didn't answer, so copies already on this machine aren't matched yet. Try again in a moment.</p>` : null,
958
904
  rows,
959
905
  data.listed === null ? null : list("Refresh list"),
960
906
  html `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
@@ -1,3 +1,4 @@
1
+ import { type ProjectIdentities } from "../project-identity.js";
1
2
  import type { FreshFacts } from '../scope.js';
2
3
  import type { createPersonalQueueRuntime } from '../lead-queue-runtime.js';
3
4
  import type { FlowRooms } from '../flow-live.js';
@@ -95,7 +96,10 @@ export interface ServerRuntime {
95
96
  liveCeiling: () => {
96
97
  repos: string[];
97
98
  roots: readonly string[];
99
+ canon?: (path: string) => string | null;
98
100
  };
101
+ /** Project identities without reading folders on the console's thread (project-identity.ts). */
102
+ identities: ProjectIdentities;
99
103
  taskScreen: (response: ServerResponse, who: Who, taskId: string, problem: string | null, status: number, scopeDraft?: URLSearchParams, cancelDraft?: string, editPlan?: boolean, page?: TaskPageExtras) => void;
100
104
  runVisible: (run: Run) => boolean;
101
105
  runIsLive: (run: Pick<Run, "outcome" | "leaseId" | "taskRef">) => boolean;
@@ -1,3 +1,4 @@
1
+ import {} from "../project-identity.js";
1
2
  /** Captured server state and shared guards. Getters preserve live state across requests. */
2
3
  import {} from "node:http";
3
4
  import {} from '../assignment.js';
@@ -9,6 +9,7 @@ import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClic
9
9
  import { learningView } from "../project-learning.js";
10
10
  import { skillTestResult } from "../project-skills.js";
11
11
  import { discoverTools, projectToolsOf } from "../project-tools.js";
12
+ import { lookProjectMcp } from "../folder-look.js";
12
13
  import { publishingOf } from '../pull-request-flow.js';
13
14
  import { skillTestFeedbackHtml } from "../skills-ui.js";
14
15
  import { labelOf, nameOf } from "../subagent-admin.js";
@@ -132,7 +133,7 @@ import { personChip, ROLE_TITLES } from "./chrome.js";
132
133
  import { chatReturnWithSaid, goOutside } from "./http.js";
133
134
  import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
134
135
  export function createSettingsHandlers(runtime) {
135
- const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
136
+ const { identities, store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
136
137
  // v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
137
138
  async function spendPage(ctx) {
138
139
  const { url, who, response, now } = ctx;
@@ -1547,7 +1548,15 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1547
1548
  const wanted = body.getAll("import");
1548
1549
  if (wanted.length === 0)
1549
1550
  return back("problem", "Choose at least one tool to add.");
1550
- const found = discoverTools(repo, await codexServers(repo), toolHome);
1551
+ // The project's own .mcp.json read now, off the console's thread (folder-look.ts).
1552
+ let projectConfig;
1553
+ if (options.identities !== undefined) {
1554
+ const looked = await lookProjectMcp(repo);
1555
+ if (!looked.ok)
1556
+ return back("problem", "This project's folder didn't answer. It may be on a drive that's disconnected, or waiting on macOS. Try again in a moment.");
1557
+ projectConfig = looked.value;
1558
+ }
1559
+ const found = discoverTools(repo, await codexServers(repo), toolHome, process.env, projectConfig);
1551
1560
  const added = [], problems = [];
1552
1561
  for (const one of wanted) {
1553
1562
  const match = found.find(tool => tool.spec.name === one);
@@ -1887,7 +1896,9 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1887
1896
  if (["scope", "full-access", "cap-usd", "nonce", "digest", "token"].some(key => body.getAll(key).length > 1))
1888
1897
  return refuse(response, who, 400, "Submit one value for each field.", "/settings");
1889
1898
  const given = (body.get("scope") ?? "all").trim();
1890
- const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : canonicalProject(given) ?? given;
1899
+ const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : identities.vouched(given, consoleProjects) ?? null;
1900
+ if (scope === null)
1901
+ return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
1891
1902
  if (scope !== CHAT_APPROVAL_ALL && !(visible(scope) && store.accountCanAccess(who.name, scope)))
1892
1903
  return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
1893
1904
  const where = scope === CHAT_APPROVAL_ALL ? "all your projects" : projectName(scope);
@@ -72,7 +72,7 @@ import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReview
72
72
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
73
73
  import {} from "./session.js";
74
74
  export function createTasksHandlers(runtime) {
75
- const { store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
75
+ const { identities, store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
76
76
  /** A row admitted the address but its path parameter does not parse: the console's unknown page. */
77
77
  function noPage(who, response) {
78
78
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
@@ -1022,8 +1022,12 @@ export function createTasksHandlers(runtime) {
1022
1022
  let admitted = unscopedMode ? null : admissionList() ?? [];
1023
1023
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
1024
1024
  if (rootMode && effective !== "") {
1025
- const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
1026
- if (canonical === null || canonical === undefined) {
1025
+ // Resolved off the console's thread (project-identity.ts), then proved against the ceiling as that identity.
1026
+ const resolved = await identities.resolveNow(effective);
1027
+ if (resolved === "no-answer")
1028
+ return newTaskScreen(ctx, 503, `${effective} didn't answer — its folder may be on a drive that's disconnected, or waiting on macOS; try again in a moment`, draft);
1029
+ const canonical = resolved !== null && (await authorizedProject(liveCeiling(), resolved)) ? resolved : null;
1030
+ if (canonical === null) {
1027
1031
  return newTaskScreen(ctx, 403, `${effective} is outside what this server was configured to show`, draft);
1028
1032
  }
1029
1033
  repo = canonical;
@@ -26,6 +26,14 @@ export type UpWorkerConfig = {
26
26
  terminal: boolean;
27
27
  /** How long a start waits for a predecessor's watch lease to expire (a crashed worker's, after a restart). */
28
28
  leaseWaitMs: number;
29
+ /** Saved projects this start couldn't reach (a folder that didn't answer, a drive not connected): looked at again
30
+ * while it runs, and connected once they answer, on the same saved-list authority the start had. */
31
+ deferredRepos?: string[];
32
+ /** Each of those lines' removal count when this start read the saved list: a different count at binding means it was
33
+ * removed since (even if saved again), and the start's authority for it is gone. */
34
+ deferredGenerations?: Record<string, number>;
35
+ /** The saved line each of `repos` was proved from: a project stays while its line is saved, whatever else answers. */
36
+ repoLines?: Record<string, string[]>;
29
37
  };
30
38
  export type UpRoleStart = {
31
39
  type: "start";
@@ -67,6 +75,11 @@ export type RoleToParent = {
67
75
  } | {
68
76
  type: "repos";
69
77
  repos: string[];
78
+ }
79
+ /** The saved projects the worker still holds start authority for (it only ever shrinks: connected or removed). */
80
+ | {
81
+ type: "deferred";
82
+ repos: string[];
70
83
  } | {
71
84
  type: "failed";
72
85
  reason: "port-busy" | "start";
@@ -53,6 +53,7 @@ export function roleMessage(value, role) {
53
53
  return { type: "ready", role, build: value["build"], pid: value["pid"], url: value["url"], port: value["port"] };
54
54
  case "log": return text(value["line"]) ? { type: "log", line: value["line"] } : null;
55
55
  case "repos": return role === "worker" && texts(value["repos"]) ? { type: "repos", repos: [...value["repos"]] } : null;
56
+ case "deferred": return role === "worker" && texts(value["repos"]) ? { type: "deferred", repos: [...value["repos"]] } : null;
56
57
  case "failed": return (value["reason"] === "port-busy" || value["reason"] === "start") && text(value["detail"]) ? { type: "failed", reason: value["reason"], detail: value["detail"] } : null;
57
58
  case "fatal": return text(value["detail"]) ? { type: "fatal", detail: value["detail"] } : null;
58
59
  case "sign-in-link": return role === "console" && whole(value["id"]) && orNull(value["link"], (one) => nonEmpty(one, 2_048)) ? { type: "sign-in-link", id: value["id"], link: value["link"] } : null;
@@ -78,12 +79,15 @@ export function startMessage(value) {
78
79
  return { ...base, role: "console", config: config };
79
80
  }
80
81
  if (value["role"] === "worker") {
81
- const keys = ["runner", "token", "repos", "pinnedRepos", "projectRoots", "registryPath", "pool", "forMs", "publicUrl", "stopGraceMs", "verbose", "terminal", "leaseWaitMs"];
82
+ const keys = ["runner", "token", "repos", "pinnedRepos", "projectRoots", "registryPath", "pool", "forMs", "publicUrl", "stopGraceMs", "verbose", "terminal", "leaseWaitMs", "deferredRepos", "deferredGenerations", "repoLines"];
82
83
  if (Object.keys(config).some(key => !keys.includes(key)))
83
84
  return null;
84
85
  if (!nonEmpty(config["runner"], 256) || !nonEmpty(config["token"], 1_024) || !texts(config["repos"]) || !texts(config["pinnedRepos"]) || !texts(config["projectRoots"]) ||
85
86
  !nonEmpty(config["registryPath"], 4_096) || !nonEmpty(config["pool"], 4_096) || !orNull(config["forMs"], (one) => whole(one, 1)) ||
86
- !orNull(config["publicUrl"], (one) => nonEmpty(one, 2_048)) || !whole(config["stopGraceMs"], 0) || typeof config["verbose"] !== "boolean" || typeof config["terminal"] !== "boolean" || !whole(config["leaseWaitMs"], 0))
87
+ !orNull(config["publicUrl"], (one) => nonEmpty(one, 2_048)) || !whole(config["stopGraceMs"], 0) || typeof config["verbose"] !== "boolean" || typeof config["terminal"] !== "boolean" || !whole(config["leaseWaitMs"], 0) ||
88
+ (config["deferredRepos"] !== undefined && !texts(config["deferredRepos"])) ||
89
+ (config["deferredGenerations"] !== undefined && !(isObject(config["deferredGenerations"]) && Object.values(config["deferredGenerations"]).every(one => whole(one, -1, Number.MAX_SAFE_INTEGER)))) ||
90
+ (config["repoLines"] !== undefined && !(isObject(config["repoLines"]) && Object.values(config["repoLines"]).every(one => texts(one)))))
87
91
  return null;
88
92
  return { ...base, role: "worker", config: config };
89
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.82",
3
+ "version": "0.9.83",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"