toolroll 0.9.44 → 0.9.45

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.45 — 2026-10-07
4
+
5
+ - **Your projects, your lists.** People with access to some projects can now
6
+ run `status`, `task list` and `ready` remotely, and their agents' list
7
+ tools, and see and count only those projects. A task waiting on one in
8
+ another project says "waits for a task in another project", with no id,
9
+ and queue position counts only your projects. Owners see exactly what they
10
+ did before.
11
+ - Still unavailable remotely for limited access: `brief`, `spend` and
12
+ choosing a task's `--id`.
13
+
3
14
  ## 0.9.44 — 2026-10-07
4
15
 
5
16
  - **Work from your own laptop.** `toolroll connect https://… --token so_…`
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Store } from './store.js';
3
+ import type { Principal } from './operate.js';
3
4
  import { type RemoteCommandLookup } from './remote-command.js';
4
5
  /**
5
6
  * `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
@@ -10,22 +11,10 @@ import { type RemoteCommandLookup } from './remote-command.js';
10
11
  * Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
11
12
  * carrying a browser Origin is refused, so a page can never drive it.
12
13
  */
13
- /** Who a remote command runs as. The shared seam with operate.ts (remote-principal). */
14
- export type Principal = {
15
- kind: 'person';
16
- account: string;
17
- generation: number;
18
- scope: 'read' | 'act';
19
- tokenId: string;
20
- projects: string[] | null;
21
- };
22
- export type RunOperateAs = (argv: string[], opts: {
23
- principal: Principal;
24
- store: Store;
25
- write: (s: string) => void;
26
- files?: Record<string, string>;
27
- source?: 'api' | 'mcp';
28
- }) => Promise<number>;
14
+ /** Who a remote command runs as, and the runner: operate.ts's own types, so a change there breaks this build instead of
15
+ * drifting. Type-only: the value is loaded lazily through `run` (operate.ts imports the server that imports this). */
16
+ export type { Principal } from './operate.js';
17
+ export type RunOperateAs = typeof import('./operate.js').runOperateAs;
29
18
  export type CliRequest = {
30
19
  argv: string[];
31
20
  files: Record<string, string>;
@@ -94,13 +94,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
- error?: never;
98
97
  active: boolean;
99
98
  phase: string;
100
99
  detail: string;
101
100
  running: boolean;
102
101
  canResume: boolean;
103
102
  canCancel: boolean;
103
+ error?: never;
104
104
  automaticRecovery?: never;
105
105
  recoveryAttempts?: never;
106
106
  recoveryRepairs?: never;
@@ -94,8 +94,8 @@ export declare function scopeApprovedForDispatch(store: Store, taskRef: number,
94
94
  export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date): "builder" | "planner" | "scout";
95
95
  /** The first requirement this task fails, in words, or null. */
96
96
  export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
97
- /** The read-side lifecycle answer. It changes nothing and grants nothing. */
98
- export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date): DispatchDiagnosis | null;
97
+ /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
98
+ export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date, readableRepos?: readonly string[] | null): DispatchDiagnosis | null;
99
99
  export declare function diagnosisIsDispatchable(diagnosis: DispatchDiagnosis | null): boolean;
100
100
  /** Attach the same lifecycle answer to the bounded fleet snapshot used by
101
101
  * both direct chat and the long-running mate. */
package/dist/dispatch.js CHANGED
@@ -163,8 +163,8 @@ function approvedProfile(scope) {
163
163
  return null;
164
164
  return scope.approvedProfile ?? scope.profile ?? null;
165
165
  }
166
- /** The read-side lifecycle answer. It changes nothing and grants nothing. */
167
- export function diagnoseTaskDispatch(store, taskId, now) {
166
+ /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
167
+ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
168
168
  const task = store.getTask(taskId);
169
169
  const ref = store.lookupRef(taskId);
170
170
  if (task === null || ref === null)
@@ -251,9 +251,13 @@ export function diagnoseTaskDispatch(store, taskId, now) {
251
251
  }
252
252
  if (local?.code === "dependency") {
253
253
  const terminal = local.blockerState === "failed" || local.blockerState === "cancelled";
254
+ const blockerRepo = readableRepos === null ? null : store.lookupRef(local.blockerId)?.repo;
255
+ const hidden = readableRepos !== null && (blockerRepo == null || !readableRepos.includes(blockerRepo));
256
+ const blocker = hidden ? "A task in another project" : local.blockerId;
257
+ const blockerTaskId = hidden ? null : local.blockerId;
254
258
  return terminal
255
- ? answer("terminal-dependency", "waiting", "A required task did not finish", `${local.blockerId} ${local.blockerState === "cancelled" ? "was cancelled" : "failed"} before it finished.`, { action: "repair-dependency", blockerTaskId: local.blockerId })
256
- : answer("waiting-dependency", "waiting", "Waiting for another task", `${local.blockerId} must finish before this task can start.`, { blockerTaskId: local.blockerId });
259
+ ? answer("terminal-dependency", "waiting", "A required task did not finish", `${blocker} ${local.blockerState === "cancelled" ? "was cancelled" : "failed"} before it finished.`, { action: "repair-dependency", blockerTaskId })
260
+ : answer("waiting-dependency", "waiting", "Waiting for another task", `${blocker} must finish before this task can start.`, { blockerTaskId });
257
261
  }
258
262
  const role = dispatchRoleFor(store, ref.id, now);
259
263
  const scope = store.getScope(taskId);
@@ -101,7 +101,11 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
101
101
  exitsRecorded: number;
102
102
  readyForReview: number;
103
103
  };
104
- /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
105
- export declare function installationStatus(store: Store, now: Date, viewer?: string | null): InstallationStatus;
106
- export declare function renderInstallationStatus(status: InstallationStatus): string[];
104
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
105
+ * `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
106
+ * A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
107
+ * the release check, plan windows and the lead line are empty, never another project's. */
108
+ export declare function installationStatus(store: Store, now: Date, viewer?: string | null, repos?: readonly string[] | null): InstallationStatus;
109
+ /** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
110
+ export declare function renderInstallationStatus(status: InstallationStatus, limited?: boolean): string[];
107
111
  export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
@@ -181,15 +181,20 @@ export function repairStaleStatuses(store, now) {
181
181
  const exitsRecorded = store.recordFinishedRunExits(now);
182
182
  return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
183
183
  }
184
- /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
185
- export function installationStatus(store, now, viewer = currentActor()?.account ?? null) {
186
- const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task
184
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
185
+ * `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
186
+ * A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
187
+ * the release check, plan windows and the lead line are empty, never another project's. */
188
+ export function installationStatus(store, now, viewer = currentActor()?.account ?? null, repos = null) {
189
+ const admitted = (row) => repos === null || typeof row["repo"] === "string" && repos.includes(row["repo"]);
190
+ const access = { principal: "operator", repos, includeUnplaced: repos === null };
191
+ const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task, ref.repo
187
192
  FROM run INDEXED BY work_unfinished
188
193
  JOIN task_ref AS ref ON ref.id = run.task_ref
189
194
  JOIN claim ON claim.lease_id = run.lease_id
190
195
  WHERE run.outcome IS NULL AND claim.released_at IS NULL AND claim.expires_at > ?
191
196
  AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest INDEXED BY claim_by_task WHERE newest.task_ref = run.task_ref)
192
- ORDER BY run.id`).all(now.toISOString());
197
+ ORDER BY run.id`).all(now.toISOString()).filter(admitted);
193
198
  const phases = new Map();
194
199
  const runningTasks = runningRows.map(row => {
195
200
  const phase = phaseWords(phaseOf(row));
@@ -197,7 +202,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
197
202
  return { task: String(row["task"]), run: Number(row["id"]), phase };
198
203
  });
199
204
  const runningRefs = new Set(runningRows.map(row => Number(row["task_ref"])));
200
- const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.plan, ref.strikes, scope.digest AS scope_digest,
205
+ const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.repo, ref.plan, ref.strikes, scope.digest AS scope_digest,
201
206
  scope.profile_state,
202
207
  (scope.approved_at IS NOT NULL AND scope.approved_by IS NOT NULL AND scope.approved_digest = scope.digest) AS approved,
203
208
  EXISTS (SELECT 1 FROM task_edge WHERE task_edge.blocked = task.id
@@ -208,7 +213,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
208
213
  JOIN task_ref AS ref INDEXED BY sqlite_autoindex_task_ref_1 ON ref.backend = ? AND ref.external_id = task.id
209
214
  LEFT JOIN task_scope AS scope ON scope.task_id = task.id
210
215
  WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
211
- .filter(row => !runningRefs.has(Number(row["task_ref"])));
216
+ .filter(row => !runningRefs.has(Number(row["task_ref"])) && admitted(row));
212
217
  const reasons = new Map();
213
218
  const queuedTasks = [];
214
219
  const pauses = openAuthPauses(store);
@@ -227,11 +232,11 @@ export function installationStatus(store, now, viewer = currentActor()?.account
227
232
  }
228
233
  // One per family, from the same projection as the Tasks list: a completed
229
234
  // family (root or any revision) and a superseded version never count.
230
- const ready = workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 5);
235
+ const ready = workReadyForReview(store, now, access, 5);
231
236
  const checkRow = (run) => store.handle.prepare(`SELECT ${CHECK_COLUMNS} FROM run_check AS rc WHERE rc.run = ?`).get(run);
232
237
  // Run ids grow with time, so the newest finished release check is the
233
238
  // first finished row down the partial index.
234
- const release = store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
239
+ const release = repos !== null ? undefined : store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
235
240
  FROM run_check AS rc INDEXED BY run_check_release
236
241
  JOIN run ON run.id = rc.run
237
242
  JOIN task_ref AS ref ON ref.id = run.task_ref
@@ -247,7 +252,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
247
252
  },
248
253
  waitingForReview: { count: ready.count, results: ready.results.map(one => ({ task: one.taskId, run: one.run, check: checkOf(one.run === null ? undefined : checkRow(one.run)) })) },
249
254
  releaseCheck: release === undefined ? null : { task: String(release["task"]), run: Number(release["id"]), finishedAt: release["finished_at"] == null ? null : String(release["finished_at"]), check: checkOf(release) },
250
- planWindows: store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
255
+ planWindows: repos !== null ? [] : store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
251
256
  FROM provider_limit INDEXED BY sqlite_autoindex_provider_limit_1 ORDER BY provider, window`).all().map(row => ({
252
257
  provider: String(row["provider"]),
253
258
  plan: row["plan"] == null ? null : String(row["plan"]),
@@ -259,16 +264,17 @@ export function installationStatus(store, now, viewer = currentActor()?.account
259
264
  observedAt: String(row["observed_at"]),
260
265
  })),
261
266
  signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
262
- tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true, viewer }, { limit: 8 }).items
267
+ tasks: workIndexPage(store, now, { ...access, viewer }, { limit: 8 }).items
263
268
  .map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
264
- lead: (() => {
269
+ lead: repos !== null ? null : (() => {
265
270
  // Only the asker's own lead: another person's lead is never "Your lead".
266
271
  const activity = leadActivity(store, viewer);
267
272
  return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
268
273
  })(),
269
274
  };
270
275
  }
271
- export function renderInstallationStatus(status) {
276
+ /** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
277
+ export function renderInstallationStatus(status, limited = false) {
272
278
  const lines = [];
273
279
  // A paused provider comes first: it is the one thing a person must do.
274
280
  for (const one of status.signIn)
@@ -284,6 +290,9 @@ export function renderInstallationStatus(status) {
284
290
  lines.push(status.running.count === 0 ? "Building: none" : `Building: ${status.running.count} — ${status.running.tasks.map(one => `${one.task} (#${one.run}, ${one.phase})`).join(", ")}${status.running.count > status.running.tasks.length ? ", …" : ""}`);
285
291
  lines.push(status.queued.count === 0 ? "Queued: none" : `Queued: ${status.queued.count} — ${status.queued.tasks.map(one => `${one.task} (${one.reason})`).join(", ")}${status.queued.count > status.queued.tasks.length ? ", …" : ""}`);
286
292
  lines.push(status.waitingForReview.count === 0 ? "Ready for review: none" : `Ready for review: ${status.waitingForReview.count} — ${status.waitingForReview.results.map(one => one.run === null ? one.task : `${one.task} (#${one.run})`).join(", ")}${status.waitingForReview.count > status.waitingForReview.results.length ? ", …" : ""}`);
293
+ // Neither the release check nor plan windows belong to a person's projects: a limited report leaves both out.
294
+ if (limited)
295
+ return lines;
287
296
  if (status.releaseCheck === null) {
288
297
  lines.push("Release check: none recorded");
289
298
  }
@@ -33,6 +33,8 @@ export declare const REMOTE_MESSAGES: {
33
33
  /**
34
34
  * How a "yes" command finds the project it touches, before it runs. Any --repo given is checked too.
35
35
  * installation — reads or acts across every project: only for someone with access to all of them.
36
+ * listing — lists across projects: someone with access to all of them sees everything, as locally; anyone else
37
+ * sees only their own projects (the run's `lens`), with every count taken over those projects alone.
36
38
  * repo — needs --repo, naming a project the server knows and the person may use.
37
39
  * references — the audited arguments resolve every resource to its stored project before dispatch.
38
40
  * self — the command already narrows to the person's own projects (their access is passed in).
@@ -40,6 +42,8 @@ export declare const REMOTE_MESSAGES: {
40
42
  */
41
43
  export type RemoteScope = {
42
44
  kind: "installation";
45
+ } | {
46
+ kind: "listing";
43
47
  } | {
44
48
  kind: "repo";
45
49
  } | {
@@ -51,6 +55,14 @@ export type RemoteScope = {
51
55
  };
52
56
  /** Every "yes" row's scope. The remote test holds this to the guide's "yes" rows exactly. */
53
57
  export declare const REMOTE_SCOPES: ReadonlyMap<string, RemoteScope>;
58
+ /**
59
+ * Why each "installation" row still needs access to every project rather than listing a person's own. The remote
60
+ * test holds this to the table's "installation" rows exactly, so a newly allowed cross-project command has to be
61
+ * scoped as a listing or stated here; it cannot skip project scoping by omission.
62
+ */
63
+ export declare const REMOTE_ALL_PROJECTS_ONLY: ReadonlyMap<string, "installation-wide" | "not yet scoped">;
64
+ /** The projects a listing or task reference may disclose: null for someone with access to all of them. */
65
+ export declare function remoteLensOf(store: Store, scope: RemoteScope, allows: (repo: string | null) => boolean): readonly string[] | null;
54
66
  /** Flags a remote caller never passes: who they are comes from the token, and the server reads and writes no paths for them. */
55
67
  export declare const REMOTE_REFUSED_FLAGS: ReadonlySet<string>;
56
68
  /**
@@ -31,15 +31,15 @@ export const REMOTE_MESSAGES = {
31
31
  files: "Send that file with the request; the server reads no paths of its own for you.",
32
32
  };
33
33
  const installation = { kind: "installation" }, repo = { kind: "repo" }, self = { kind: "self" }, global = { kind: "global" };
34
- const references = { kind: "references" };
34
+ const references = { kind: "references" }, listing = { kind: "listing" };
35
35
  /** Every "yes" row's scope. The remote test holds this to the guide's "yes" rows exactly. */
36
36
  export const REMOTE_SCOPES = new Map([
37
- ["status", installation], ["integrations", installation], ["ready", installation], ["grants", installation], ["sync", installation],
37
+ ["status", listing], ["integrations", installation], ["ready", listing], ["grants", installation], ["sync", installation],
38
38
  ["gaps", repo], ["task add", repo],
39
39
  ...["ask", "checks", "add-tests", "show", "wait", "complete", "revise", "state", "block", "unblock", "next", "steer", "assign", "scope", "plan", "hold",
40
40
  "unhold", "require", "requeue", "review", "repair", "route", "reopen", "stop", "resume"].map(action => [`task ${action}`, references]),
41
41
  ["check-progress", references],
42
- ["task list", installation],
42
+ ["task list", listing],
43
43
  ["runner list", installation], ["coordinator list", installation], ["outbox list", installation], ["incident list", installation], ["incident resolve", installation],
44
44
  ["cap list", repo], ["cap add", repo],
45
45
  ...["list", "show", "add", "refresh", "pause", "resume", "run-now"].map(action => [`routine ${action}`, installation]),
@@ -53,6 +53,25 @@ export const REMOTE_SCOPES = new Map([
53
53
  .map(action => [`flows ${action}`, self]),
54
54
  ["models status", global], ["models list", global],
55
55
  ]);
56
+ /**
57
+ * Why each "installation" row still needs access to every project rather than listing a person's own. The remote
58
+ * test holds this to the table's "installation" rows exactly, so a newly allowed cross-project command has to be
59
+ * scoped as a listing or stated here; it cannot skip project scoping by omission.
60
+ */
61
+ export const REMOTE_ALL_PROJECTS_ONLY = new Map([
62
+ // Settings, connections and acts that belong to the installation as a whole, not to any one project.
63
+ ...["integrations", "grants", "sync", "incident resolve", "routine add", "routine refresh", "routine pause", "routine resume", "routine run-now",
64
+ "config show", "intake run", "contest exclude", "webhook status", "webhook test"].map(row => [row, "installation-wide"]),
65
+ // Cross-project lists and reads a limited person is still refused; scoping them is later work.
66
+ ...["runner list", "coordinator list", "outbox list", "incident list", "routine list", "routine show", "intake show", "intake preview",
67
+ "intake pr-comments", "template list", "template show", "contest show", "review show"].map(row => [row, "not yet scoped"]),
68
+ ]);
69
+ /** The projects a listing or task reference may disclose: null for someone with access to all of them. */
70
+ export function remoteLensOf(store, scope, allows) {
71
+ if ((scope.kind !== "listing" && scope.kind !== "references") || allows(null))
72
+ return null;
73
+ return Object.freeze(store.knownRepos().filter(repo => allows(repo)));
74
+ }
56
75
  /** Flags a remote caller never passes: who they are comes from the token, and the server reads and writes no paths for them. */
57
76
  export const REMOTE_REFUSED_FLAGS = new Set([
58
77
  "db", "as", "token", "token-file", "token-env", "password", "key-file", "credentials", "containment", "out", "project-root", "bin", "port", "host",
@@ -111,6 +130,7 @@ export function remoteProjectOf(store, run, scope, positional, repos, invocation
111
130
  switch (scope.kind) {
112
131
  case "global":
113
132
  case "self":
133
+ case "listing":
114
134
  return checked;
115
135
  case "installation":
116
136
  return run.allows(null) ? { ok: true, repo: named, taskId: null } : { ok: false, reason: "all-projects", message: REMOTE_MESSAGES.installation };
package/dist/operate.js CHANGED
@@ -154,7 +154,7 @@ import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm,
154
154
  import { runMateCli, answerContextLines } from "./mate-cli.js";
155
155
  import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
156
156
  import { verifyApproverByPassword as approverByPassword } from "./principal.js";
157
- import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
157
+ import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
158
158
  import { projectAuthority } from "./project-access.js";
159
159
  import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
160
160
  import { tally, spendLine } from "./summary.js";
@@ -975,17 +975,20 @@ export async function runOperateAs(argv, opts) {
975
975
  const file = store.databaseFile() ?? options.databaseFile;
976
976
  if (file === undefined)
977
977
  return refuse("failed", "The server's database has no file.", project.repo, project.taskId);
978
- const run = Object.freeze({ account: principal.account, scope: proved.scope, tokenName: proved.tokenName, source, secret: remoteSecret(), allows, files: Object.freeze({ ...(opts.files ?? {}) }) });
978
+ // The task a `task add` filed is only known once it runs; the final history line names it.
979
+ let filed = null;
980
+ const run = Object.freeze({ account: principal.account, scope: proved.scope, tokenName: proved.tokenName, source, secret: remoteSecret(), allows,
981
+ lens: remoteLensOf(store, scope, allows), noteTask: (taskId) => { filed = taskId; }, files: Object.freeze({ ...(opts.files ?? {}) }) });
979
982
  record("requested", project.repo, project.taskId, null);
980
983
  let code;
981
984
  try {
982
985
  code = await withRemote(run, () => dispatchOn(root, parsed.positional, parsed.flags, parsed.repoList, { store, write, json, file, now }, options, go => withActor({ account: principal.account, lead: false }, () => projectAuthority.run({ actor: principal.account, repo: project.repo }, go))));
983
986
  }
984
987
  catch (error) {
985
- record("failed", project.repo, project.taskId, null);
988
+ record("failed", project.repo, filed ?? project.taskId, null);
986
989
  throw error;
987
990
  }
988
- record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, project.taskId, null);
991
+ record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, filed ?? project.taskId, null);
989
992
  return code;
990
993
  }
991
994
  /** A file a remote caller sent with the request, by the argument that names it; never a path on this machine. */
@@ -1307,7 +1310,9 @@ async function readyCommand(flags, context) {
1307
1310
  write(` ${task.id} ${task.title}`);
1308
1311
  return EXIT.ok;
1309
1312
  }
1310
- const ready = store.listReady(now);
1313
+ // A remote person limited to some projects (remoteLensOf) sees only theirs, counted over those alone.
1314
+ const lens = context.principal?.lens ?? null;
1315
+ const ready = store.listReady(now).filter(ref => lens === null || ref.repo !== null && lens.includes(ref.repo));
1311
1316
  const described = ready.map(ref => describeRef(store, ref, now));
1312
1317
  const dispatchableCount = described.filter(one => diagnosisIsDispatchable(one.dispatch)).length;
1313
1318
  const pauses = openAuthPauses(store);
@@ -10097,6 +10102,13 @@ async function statusCommand(positional, flags, context) {
10097
10102
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
10098
10103
  // Whose lead the status line follows: the lead's own person, else the remembered login; nobody shows no lead.
10099
10104
  const viewer = currentActor()?.account ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null;
10105
+ // A remote person limited to some projects sees only those (remoteLensOf): their tasks and counts, and nothing
10106
+ // that belongs to the installation or other projects (updates, integrations, builds by project, unsent replies).
10107
+ const lens = context.principal?.lens ?? null;
10108
+ if (lens !== null) {
10109
+ const status = installationStatus(context.store, context.clock(), viewer, lens);
10110
+ return succeed(context.write, context.json, command, { ...status, projects: [] }, () => renderInstallationStatus(status, true));
10111
+ }
10100
10112
  const status = installationStatus(context.store, context.clock(), viewer);
10101
10113
  // One line, only when a newer Toolroll exists; offline or switched off says nothing.
10102
10114
  const current = PACKAGE_VERSION;
@@ -10427,6 +10439,7 @@ async function addTask(positional, flags, context) {
10427
10439
  throw new RemoteRefusal("not-found", "Not found.");
10428
10440
  }
10429
10441
  }
10442
+ remote?.noteTask(filedId);
10430
10443
  store.stampFiledVia(store.refFor(BUILT_IN, filedId).id, "cli");
10431
10444
  // Placement is explicit, never inferred from where the command happened to
10432
10445
  // run: a task filed from the wrong directory would silently bind to it.
@@ -10707,7 +10720,9 @@ function listTasks(flags, context) {
10707
10720
  }
10708
10721
  let page;
10709
10722
  try {
10710
- page = workIndexPage(store, now, { principal: 'operator', repos: null, includeUnplaced: true }, {
10723
+ // A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
10724
+ const lens = context.principal?.lens ?? null;
10725
+ page = workIndexPage(store, now, { principal: 'operator', repos: lens, includeUnplaced: lens === null }, {
10711
10726
  view: parseWorkView(rawView ?? null), limit, cursor: text(flags, 'cursor') ?? null,
10712
10727
  ...(wanted === undefined ? {} : { state: wanted }),
10713
10728
  ...(text(flags, 'repo') === undefined ? {} : { project: text(flags, 'repo') }),
@@ -10754,13 +10769,21 @@ function showTask(positional, context) {
10754
10769
  // whatever readiness this task's runners have reported.
10755
10770
  const routed = routeOfTask(store, id, ref, now);
10756
10771
  const readiness = store.readinessLookupFor(ref.repo, ref.assignedRunner, now);
10772
+ const lens = context.principal?.lens ?? null;
10773
+ const access = { principal: "operator", repos: lens, includeUnplaced: lens === null, dependencyRepos: lens };
10774
+ const blockers = store.blockers(id);
10775
+ const visibleBlockers = lens === null ? blockers : blockers.filter(blocker => {
10776
+ const repo = store.lookupRef(blocker)?.repo;
10777
+ return repo != null && lens.includes(repo);
10778
+ });
10757
10779
  const detail = {
10758
10780
  task,
10759
- work: taskWorkSummaryOf(store, id, now, { principal: "operator", repos: null, includeUnplaced: true }),
10760
- assignment: assignmentBrief(assignmentOf(store, id, now, { principal: "operator", repos: null, includeUnplaced: true }, context.evidenceRoot)),
10781
+ work: taskWorkSummaryOf(store, id, now, access),
10782
+ assignment: assignmentBrief(assignmentOf(store, id, now, access, context.evidenceRoot)),
10761
10783
  ref: ref.id,
10762
- blockedBy: store.blockers(id),
10763
- position: store.queuePosition(id),
10784
+ // One phrase for every inaccessible blocker, so even their number stays private.
10785
+ blockedBy: visibleBlockers.length === blockers.length ? visibleBlockers : [...visibleBlockers, "a task in another project"],
10786
+ position: lens === null ? store.queuePosition(id) : null,
10764
10787
  reservedFor: ref.assignedRunner,
10765
10788
  hold: store.activeHold(ref.id, now),
10766
10789
  claim: currentClaim(store, ref.id, now),
@@ -10785,7 +10808,7 @@ function showTask(positional, context) {
10785
10808
  // The one automatic review of the latest build: HIGH findings, suggested
10786
10809
  // follow-ups (MEDIUM/LOW), or why it was not reviewed.
10787
10810
  automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
10788
- dispatch: diagnoseTaskDispatch(store, id, now),
10811
+ dispatch: diagnoseTaskDispatch(store, id, now, lens),
10789
10812
  // v52: the exact-run control the console shows — Stop, Stopping,
10790
10813
  // Paused (resume), or the review-retry door — and every stop on record.
10791
10814
  control: taskControlOf(store, ref.id, now),
@@ -8,6 +8,10 @@ export type RemoteRun = {
8
8
  readonly secret: string;
9
9
  /** Whether the person may use `repo` now (their token's projects and their account's current access). */
10
10
  readonly allows: (repo: string | null) => boolean;
11
+ /** The projects listings and task references may disclose (remoteLensOf); null shows every project. */
12
+ readonly lens: readonly string[] | null;
13
+ /** Called with the task a remote `task add` filed (or replayed), so its action history names it. */
14
+ readonly noteTask: (taskId: string) => void;
11
15
  /** Files the caller sent, keyed by the argument that names them. Nothing else is read for input. */
12
16
  readonly files: Readonly<Record<string, string>>;
13
17
  };
@@ -41,7 +41,7 @@ export declare function openWorkDecisionOf(store: Store, taskRef: number, now: D
41
41
  export declare function workDecisionAction(taskId: string, decision: NonNullable<WorkFacts["openDecision"]>, principal: WorkPrincipal): WorkAction;
42
42
  /** Pure counterpart for callers already holding the same facts. */
43
43
  export declare function workSummaryOf(facts: WorkFacts, principal: WorkPrincipal, resultDisplay?: DisplayStatus): WorkSummary;
44
- export type WorkSummaryAccess =
44
+ export type WorkSummaryAccess = (
45
45
  /** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "<name> is on it". */
46
46
  {
47
47
  principal: "operator";
@@ -51,6 +51,9 @@ export type WorkSummaryAccess =
51
51
  } | {
52
52
  principal: "coordinator";
53
53
  repos: readonly string[];
54
+ }) & {
55
+ /** Remote task reads also restrict dependency details; omitted preserves existing local output. */
56
+ dependencyRepos?: readonly string[] | null;
54
57
  };
55
58
  /** Admission precedes reading question/proof bodies. Missing and foreign
56
59
  * tasks share the same result. The caller must authenticate/revalidate
@@ -94,7 +94,7 @@ export function taskWorkSummaryOf(store, taskId, now, access) {
94
94
  const publication = result === null ? null : store.publicationForRun(result.id);
95
95
  const facts = {
96
96
  id: task.id, title: task.title, repo: ref.repo, state: task.state, updatedAt: task.updatedAt,
97
- dispatch: diagnoseTaskDispatch(store, taskId, now), liveRunId: live?.id ?? null,
97
+ dispatch: diagnoseTaskDispatch(store, taskId, now, access.dependencyRepos ?? null), liveRunId: live?.id ?? null,
98
98
  unfinishedRunId: runs.find(one => one.outcome === null && one.role !== "reviewer")?.id ?? null,
99
99
  control: taskControlOf(store, ref.id, now), openDecision: openWorkDecisionOf(store, ref.id, now),
100
100
  result: result === null ? null : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.44",
3
+ "version": "0.9.45",
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"