talos-code 0.2.0 → 0.2.2

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
@@ -5,6 +5,59 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Versions
5
5
  `src/version.ts`, and in the `talos-cli-vX.Y.Z` tag. The package is published on npm as
6
6
  [`talos-code`](https://www.npmjs.com/package/talos-code); the command is `talos`.
7
7
 
8
+ ## talos-cli-v0.2.2 — 2026-09-27
9
+
10
+ `npm i -g talos-code@0.2.2`, or `talos update` to see it.
11
+
12
+ ### Changed
13
+ - Faster turns again. The `/undo` snapshot looks at the files of a folder together instead of one by one: a turn in a
14
+ folder of 6,000 files went from 1.8 s to 1.25 s (1.4 s to 1.28 s in a git repository), on par with Pi.
15
+ - Long sessions no longer slow down at every tool result: the context archive is written once per tool exchange instead
16
+ of after every result, and is no longer read back whole at every write. At 1,350 messages, a turn with six tools
17
+ spends 24 ms on it instead of 160 ms.
18
+
19
+ ### Fixed
20
+ - A search asked in the same answer AFTER a file write could miss what that write had just changed (0.2.1: the searches
21
+ of one answer started together, before the write). Only the searches before any other tool start together now.
22
+ - Stopping a turn also stops a search that is still walking the project when ripgrep is not available.
23
+
24
+ ### Known limits
25
+ - Tested on Windows 11 x64 only; Node.js 24 (24.18 or later in the 24 line).
26
+ - Shell commands run one at a time, each in its sandbox: the safety check of each command depends on what the
27
+ previous ones did.
28
+
29
+ ### Verification
30
+ - Hermetic bench (`scripts/bench-harness.ts`, a git repository of 6,000 files, local fake provider): a turn with no
31
+ tool 1.13 s (Pi 1.23, OpenCode 2.69, Codex 0.50); six reads 1.10 s (Pi 1.23, Codex 1.98, OpenCode 3.34); four
32
+ searches 1.32 s (Pi 1.59, Codex 1.71, OpenCode 3.61).
33
+ - CLI unit suite: 1879 tests, 1875 pass, 4 skip, 0 fail. Acceptance on the built CLI in a synthetic terminal: 263/263.
34
+ The package proof (`scripts/prove-standalone.ts`): 16/16 on `talos-code-0.2.2.tgz`.
35
+
36
+ ## talos-cli-v0.2.1 — 2026-09-26
37
+
38
+ `npm i -g talos-code@0.2.1`, or `talos update` to see it.
39
+
40
+ ### Changed
41
+ - Faster turns in large folders. The workspace snapshot that makes `/undo` possible no longer reads, rewrites and syncs
42
+ every file at every turn: a file that has not changed since the last turn reuses what was stored. A turn in a plain
43
+ folder of 6,000 files went from 39 s to 1.8 s, and from 2.6 s to 1.4 s in a git repository of the same size. What
44
+ `/undo` covers is unchanged.
45
+ - Faster and complete project search. `cerca` runs ripgrep, shipped inside the package for your platform (no download),
46
+ and each match now comes with its line: `path:line:text`. Four searches over 6,000 files took 9.3 s; now about 0.2 s.
47
+ Before, a search stopped after reading 5,000 files and could miss matches in a bigger project. Several searches asked
48
+ in the same answer run together. Without ripgrep, the previous search still works.
49
+
50
+ ### Known limits
51
+ - Tested on Windows 11 x64 only; Node.js 24 (24.18 or later in the 24 line).
52
+ - Shell commands still run one at a time, each in its sandbox; a start of about one second remains.
53
+
54
+ ### Verification
55
+ - Measured on a hermetic bench (`scripts/bench-harness.ts`): a local fake provider, no network, TALOS against Pi, Codex
56
+ and OpenCode on the same turns.
57
+ - CLI unit suite: 1876 tests, 1872 pass, 4 skip, 0 fail. Acceptance on the built CLI in a synthetic terminal: 263/263.
58
+ - The package proof (`scripts/prove-standalone.ts`), 16/16 on `talos-code-0.2.1.tgz` (1.76 MB); the installed package
59
+ resolves its own ripgrep.
60
+
8
61
  ## talos-cli-v0.2.0 — 2026-09-26
9
62
 
10
63
  `npm i -g talos-code@0.2.0`, or `talos update` to see it from 0.1.0.
@@ -61,9 +61,28 @@ export function withArchivableCapture(hooks, { stalePatienceMs = STALE_PATIENCE_
61
61
  if (!hooks || typeof hooks.capture !== 'function')
62
62
  return hooks;
63
63
  const capture = hooks.capture, prepare = hooks.prepare;
64
+ /*
65
+ * P18 phase 1 (owner 2026-09-26). The kernel captures at 'start', 'before-request', 'response', after EVERY tool result
66
+ * and at 'finished'; with the rule above most of those carry the prefix already archived (a tool exchange is archived
67
+ * once, when its last result is in), and each still cost the service a full reread of the archive and a sha256 of
68
+ * every message (context-desktop-service.mjs syncOriginals): ~25 ms a capture at 1,351 messages, measured with
69
+ * scripts/bench-tools.ts. A prefix with the same length, first and last message as the last one archived is not sent
70
+ * again. Any other change (longer, shorter, a message replaced) still goes to the service, which decides.
71
+ */
72
+ let archived = null;
73
+ const sameAsArchived = (rows) => archived !== null && rows.length === archived.length && rows[0] === archived.first && rows[rows.length - 1] === archived.last;
64
74
  return {
65
75
  ...hooks,
66
- capture: (input) => retryStale(() => capture({ ...input, messages: Array.isArray(input?.messages) ? archivableHistory(input.messages) : input?.messages }), stalePatienceMs),
76
+ capture: async (input) => {
77
+ if (!Array.isArray(input?.messages))
78
+ return retryStale(() => capture(input), stalePatienceMs);
79
+ const rows = archivableHistory(input.messages);
80
+ if (sameAsArchived(rows))
81
+ return undefined;
82
+ const result = await retryStale(() => capture({ ...input, messages: rows }), stalePatienceMs);
83
+ archived = { length: rows.length, first: rows[0], last: rows[rows.length - 1] };
84
+ return result;
85
+ },
67
86
  ...(typeof prepare === 'function' ? { prepare: (input) => retryStale(() => prepare(input), stalePatienceMs) } : {}),
68
87
  };
69
88
  }
@@ -89,7 +89,18 @@ export function findTalosRepoRoot(start = process.cwd(), deps = {}) {
89
89
  return cwd;
90
90
  throw new Error('TALOS_RUNTIME_NOT_FOUND');
91
91
  }
92
+ /* P18 phase 3 (owner 2026-09-26: ripgrep inside the npm package, no network): the kernel's `cerca` runs ripgrep from
93
+ TALOS_RG_PATH. `@vscode/ripgrep` ships the binary for this platform as an optional dependency of the package (no
94
+ install script, no download); a path already set, or a package without it, is left alone and `cerca` keeps its JS walk. */
95
+ let bundledRipgrep = null;
96
+ export function provideBundledRipgrep(env = process.env) {
97
+ if (env.TALOS_RG_PATH)
98
+ return Promise.resolve();
99
+ return bundledRipgrep ??= import('@vscode/ripgrep').then((m) => { if (typeof m?.rgPath === 'string' && existsSync(m.rgPath))
100
+ env.TALOS_RG_PATH = m.rgPath; }, () => { });
101
+ }
92
102
  export async function importTalosModule(repoRoot, relativeFromHarnessSrc) {
103
+ await provideBundledRipgrep();
93
104
  const file = join(repoRoot, 'harness-ui', 'src', relativeFromHarnessSrc);
94
105
  return import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
95
106
  }
@@ -12,6 +12,7 @@ import { secretValuesFromEnvironment } from "../diagnostics/redact.js";
12
12
  import { isTestProfile } from "../provider/store.js";
13
13
  import { compactionEvent, createCliEventHub, createRunObserver, RETRY_AFTER_MAX_MS, summaryRequestedEvent } from "./provider-attempts.js";
14
14
  import { createToolOutputStore, pruneToolOutput } from "./output-store.js";
15
+ import { provideBundledRipgrep } from "./repo.js";
15
16
  import { withArchivableCapture } from "./context-archive.js";
16
17
  import { CONTEXT_ENGINE_MIN_WINDOW, configureModelProfiles, createModelsDevLoader, modelProfile, modelProfileEvidence, ollamaWindow, rememberOllamaWindow, setModelOverrides } from "./model-profile.js";
17
18
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -246,7 +247,7 @@ export function createCliContextBridge({ repoRoot, paths, registry, providerRegi
246
247
  export function scopeCliKeyring(raw) { if (!raw)
247
248
  return null; return { get: (s, a) => raw.get(remapService(s), a), set: (s, a, v) => raw.set(remapService(s), a, v), remove: (s, a) => raw.remove(remapService(s), a) }; }
248
249
  /** The kernel modules the product composes with. Exported so a test can observe one of them while every other stays the production one. */
249
- export async function loadModules(repoRoot) { const src = join(repoRoot, 'harness-ui', 'src'); const [cred, owner, agent, sessions, plugins, search, duck, providerRegistry, probe, destination, readiness, chatImages] = await Promise.all([import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-credential-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'runtime-owner-adapter.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'agent-service.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'session-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'search-source-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'duckduckgo-search.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-probe.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'model-destination.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'sessione-pronta.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'chat-image-attachments.mjs')).href))]); return { createProviderCredentialStore: cred.createProviderCredentialStore, createOwnerRuntimeAdapter: owner.createOwnerRuntimeAdapter, creaFetchMultiProvider: owner.creaFetchMultiProvider, compattaSessione: agent.compattaSessione, chiediAlModelloUnaVolta: agent.chiediAlModelloUnaVolta, avviaSessione: agent.avviaSessione, eseguiComandoDiretto: agent.eseguiComandoDiretto, createSessionRegistry: sessions.createSessionRegistry, verificaTrustPlugin: plugins.verificaTrustPlugin, createSearchSourceStore: search.createSearchSourceStore, creaTrasportoSenzaChiave: duck.creaTrasportoSenzaChiave, ENDPOINT_SENTINELLA_DUCKDUCKGO: duck.ENDPOINT_SENTINELLA_DUCKDUCKGO, REGISTRO_FORNITORI: providerRegistry.REGISTRO_FORNITORI, createProviderProbe: probe.createProviderProbe, separaFonteModello: destination.separaFonteModello, creaProntoFn: readiness.creaProntoFn, createChatImageStore: chatImages.createChatImageStore }; }
250
+ export async function loadModules(repoRoot) { await provideBundledRipgrep(); const src = join(repoRoot, 'harness-ui', 'src'); const [cred, owner, agent, sessions, plugins, search, duck, providerRegistry, probe, destination, readiness, chatImages] = await Promise.all([import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-credential-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'runtime-owner-adapter.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'agent-service.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'session-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'search-source-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'duckduckgo-search.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-probe.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'model-destination.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'sessione-pronta.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'chat-image-attachments.mjs')).href))]); return { createProviderCredentialStore: cred.createProviderCredentialStore, createOwnerRuntimeAdapter: owner.createOwnerRuntimeAdapter, creaFetchMultiProvider: owner.creaFetchMultiProvider, compattaSessione: agent.compattaSessione, chiediAlModelloUnaVolta: agent.chiediAlModelloUnaVolta, avviaSessione: agent.avviaSessione, eseguiComandoDiretto: agent.eseguiComandoDiretto, createSessionRegistry: sessions.createSessionRegistry, verificaTrustPlugin: plugins.verificaTrustPlugin, createSearchSourceStore: search.createSearchSourceStore, creaTrasportoSenzaChiave: duck.creaTrasportoSenzaChiave, ENDPOINT_SENTINELLA_DUCKDUCKGO: duck.ENDPOINT_SENTINELLA_DUCKDUCKGO, REGISTRO_FORNITORI: providerRegistry.REGISTRO_FORNITORI, createProviderProbe: probe.createProviderProbe, separaFonteModello: destination.separaFonteModello, creaProntoFn: readiness.creaProntoFn, createChatImageStore: chatImages.createChatImageStore }; }
250
251
  async function loadTrustSupport(repoRoot) {
251
252
  const src = join(repoRoot, 'harness-ui', 'src');
252
253
  const [plugins, hooks, mcp, mcpSession, pluginSession] = await Promise.all([import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'hook-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'mcp-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'mcp-session.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-session.mjs')).href))]);
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const CLI_VERSION = '0.2.0';
1
+ export const CLI_VERSION = '0.2.2';
2
2
  export const TALOS_BASE_COMMIT = '0c432153a288f64237e98403869d02d20d8fabc7';
@@ -60,6 +60,24 @@ function parseRecord(text, expectedRoot) {
60
60
  throw coded('CHECKPOINT_STORE_INVALID');
61
61
  return value;
62
62
  }
63
+ const STAT_INDEX_SCHEMA = 'talos.cli.checkpoint-stat-index.v1';
64
+ async function loadStatIndex(indexPath) {
65
+ const rows = new Map();
66
+ try {
67
+ const value = JSON.parse(await readFile(indexPath, 'utf8'));
68
+ if (value?.schema !== STAT_INDEX_SCHEMA || !Array.isArray(value.rows))
69
+ return rows;
70
+ for (const row of value.rows) {
71
+ if (!Array.isArray(row) || row.length !== 6)
72
+ continue;
73
+ const [p, size, mtimeMs, ctimeMs, ref, verifiedAtMs] = row;
74
+ if (typeof p === 'string' && typeof ref === 'string' && /^[0-9a-f]{64}$/u.test(ref) && [size, mtimeMs, ctimeMs, verifiedAtMs].every(n => typeof n === 'number' && Number.isFinite(n)))
75
+ rows.set(p, { size, mtimeMs, ctimeMs, ref, verifiedAtMs });
76
+ }
77
+ }
78
+ catch { /* missing or torn: an empty index, every file is read again */ }
79
+ return rows;
80
+ }
63
81
  function dedupeEvidence(rows) { const byKey = new Map(); for (const row of rows)
64
82
  byKey.set(`${row.path}\0${row.beforeHash ?? ''}\0${row.afterHash ?? ''}`, row); return [...byKey.values()]; }
65
83
  export function createCheckpointStore({ rootDir, projectRoot, limits = CHECKPOINT_LIMITS }) {
@@ -85,7 +103,20 @@ export function createCheckpointStore({ rootDir, projectRoot, limits = CHECKPOIN
85
103
  } }
86
104
  async function replace(record) { const x = await init(); if (record.canonicalRoot !== x.canonicalRoot)
87
105
  throw coded('CHECKPOINT_WORKSPACE_MISMATCH'); await atomicWrite(await recordPath(record.id), `${JSON.stringify(record)}\n`); }
88
- async function capture() { const x = await init(); return captureWorkspaceSnapshot({ projectRoot: x.canonicalRoot, blobRoot, limits }); }
106
+ /* P18: the stat index of the last capture (checkpoint.ts StatCache), kept in memory and in the workspace's folder so a new
107
+ `talos -p` process starts from it too. It is only a cache: unreadable means empty (every file read again), and a
108
+ failed save costs speed on the next turn, never a checkpoint. */
109
+ let statRows = null;
110
+ async function capture() {
111
+ const x = await init();
112
+ const indexPath = path.join(x.workspaceDir, 'stat-index.json');
113
+ statRows ??= await loadStatIndex(indexPath);
114
+ const next = new Map();
115
+ const snapshot = await captureWorkspaceSnapshot({ projectRoot: x.canonicalRoot, blobRoot, limits, statCache: { previous: statRows, next } });
116
+ statRows = next;
117
+ await atomicWrite(indexPath, `${JSON.stringify({ schema: STAT_INDEX_SCHEMA, rows: [...next].map(([p, r]) => [p, r.size, r.mtimeMs, r.ctimeMs, r.ref, r.verifiedAtMs]) })}\n`).catch(() => { });
118
+ return snapshot;
119
+ }
89
120
  async function list() { const x = await init(); let names = []; try {
90
121
  names = await readdir(x.recordsDir);
91
122
  }
@@ -5,6 +5,17 @@ import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { resolveWorkspaceIdentity } from "../security/workspace-identity.js";
7
7
  export const CHECKPOINT_LIMITS = { maxEntries: 50_000, maxStoredBytes: 256 * 1024 * 1024, maxStoredFileBytes: 64 * 1024 * 1024 };
8
+ const RACY_MARGIN_MS = 2_000, BLOB_SYNC_BATCH = 16, CAPTURE_CONCURRENCY = 32;
9
+ /* Runs `fn` over `items` with at most `limit` in flight and returns the results in the items' order. */
10
+ async function inOrder(items, limit, fn) {
11
+ const out = new Array(items.length);
12
+ let next = 0;
13
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) {
14
+ const i = next++;
15
+ out[i] = await fn(items[i]);
16
+ } }));
17
+ return out;
18
+ }
8
19
  function coded(code, message = code, details = {}) { return Object.assign(new Error(message === code ? code : `${code}: ${message}`), { code, details }); }
9
20
  function sha256(data) { return createHash('sha256').update(data).digest('hex'); }
10
21
  function slash(value) { return value.split(path.sep).join('/'); }
@@ -59,15 +70,15 @@ catch (error) {
59
70
  if (process.platform !== 'win32')
60
71
  throw error;
61
72
  } }
62
- async function storeBlob(blobRoot, data) {
73
+ async function storeBlob(blobRoot, data, budget) {
63
74
  const hash = sha256(data), target = path.join(blobRoot, hash);
64
- await ensurePrivateDir(blobRoot);
75
+ if (budget.blobs.has(hash))
76
+ return hash;
65
77
  const temp = path.join(blobRoot, `.${hash}.${process.pid}.tmp`);
66
78
  try {
67
79
  const handle = await open(temp, 'wx', 0o600);
68
80
  try {
69
81
  await handle.writeFile(data);
70
- await handle.sync();
71
82
  }
72
83
  finally {
73
84
  await handle.close();
@@ -76,14 +87,35 @@ async function storeBlob(blobRoot, data) {
76
87
  await rm(temp, { force: true });
77
88
  return;
78
89
  } throw error; });
79
- await durableDir(blobRoot);
80
90
  }
81
91
  catch (error) {
82
92
  if (error?.code !== 'EEXIST')
83
93
  throw error;
84
94
  }
95
+ budget.blobs.add(hash);
96
+ budget.pending.push(target);
85
97
  return hash;
86
98
  }
99
+ /* Every blob written by this capture is durable before the capture returns (and so before its record is written); a sync
100
+ error is never swallowed (gate E8/E9). */
101
+ async function syncNewBlobs(blobRoot, budget) {
102
+ if (budget.pending.length === 0)
103
+ return;
104
+ for (let i = 0; i < budget.pending.length; i += BLOB_SYNC_BATCH)
105
+ await Promise.all(budget.pending.slice(i, i + BLOB_SYNC_BATCH).map(durableFile));
106
+ budget.pending = [];
107
+ await durableDir(blobRoot);
108
+ }
109
+ async function assertFileContained(workspace, absolute, budget) {
110
+ const dir = path.dirname(absolute);
111
+ let canonical = budget.dirs.get(dir);
112
+ if (canonical === undefined) {
113
+ canonical = await realpath(dir);
114
+ budget.dirs.set(dir, canonical);
115
+ }
116
+ if (!inside(workspace.canonicalRoot, canonical))
117
+ throw coded('CHECKPOINT_PATH_ESCAPE', `Checkpoint path escapes workspace: ${absolute}`);
118
+ }
87
119
  function consumeFileBudget(budget, size) {
88
120
  if (size > budget.limits.maxStoredFileBytes)
89
121
  throw coded('CHECKPOINT_FILE_TOO_LARGE');
@@ -178,10 +210,13 @@ async function captureActualFile(workspace, absolute, relative, blobRoot, budget
178
210
  }
179
211
  if (!stat.isFile())
180
212
  throw coded('CHECKPOINT_SPECIAL_FILE_UNSUPPORTED', `Unsupported workspace entry: ${relative}`);
181
- await assertExistingPathContained(workspace, absolute);
213
+ await assertFileContained(workspace, absolute, budget);
182
214
  consumeFileBudget(budget, stat.size);
183
- const data = await readFile(absolute);
184
- const ref = await storeBlob(blobRoot, data);
215
+ const seen = budget.cache?.previous.get(relative);
216
+ const unchanged = Boolean(seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs && seen.ctimeMs === stat.ctimeMs
217
+ && stat.mtimeMs < seen.verifiedAtMs - RACY_MARGIN_MS && stat.ctimeMs < seen.verifiedAtMs - RACY_MARGIN_MS && budget.blobs.has(seen.ref));
218
+ const ref = unchanged ? seen.ref : await storeBlob(blobRoot, await readFile(absolute), budget);
219
+ budget.cache?.next.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ref, verifiedAtMs: budget.startedAtMs });
185
220
  const executable = process.platform === 'win32' ? indexExecutable : Boolean(stat.mode & 0o111);
186
221
  return { kind: 'file', source: 'blob', ref, size: stat.size, mode: stat.mode & 0o777, executable };
187
222
  }
@@ -213,6 +248,9 @@ async function captureGit(workspace, blobRoot, budget) {
213
248
  }
214
249
  const entries = {};
215
250
  const seen = new Set();
251
+ /* The index is validated first, in order (unmerged, duplicate, gitlink: the first bad row decides), then the files are
252
+ looked at CAPTURE_CONCURRENCY at a time and their entries stored in index order. */
253
+ const jobs = [];
216
254
  for (const row of trackedRows) {
217
255
  const tab = row.indexOf('\t');
218
256
  if (tab < 0)
@@ -231,60 +269,50 @@ async function captureGit(workspace, blobRoot, budget) {
231
269
  if (!relGit)
232
270
  continue;
233
271
  const relative = safeRelative(relGit);
234
- const absolute = absoluteFromRelative(workspace.canonicalRoot, relative);
272
+ jobs.push({ relative, absolute: absoluteFromRelative(workspace.canonicalRoot, relative), mode, oid, gitPath });
273
+ }
274
+ const tracked = await inOrder(jobs, CAPTURE_CONCURRENCY, async ({ relative, absolute, mode, oid, gitPath }) => {
235
275
  let stat;
236
276
  try {
237
277
  stat = await lstat(absolute);
238
278
  }
239
279
  catch (error) {
240
280
  if (error?.code === 'ENOENT')
241
- continue;
281
+ return null;
242
282
  throw error;
243
283
  }
244
284
  const mustCapture = dirty.has(gitPath) || stat.isSymbolicLink() || !stat.isFile();
245
- if (mustCapture) {
246
- const actual = await captureActualFile(workspace, absolute, relative, blobRoot, budget, mode === '100755');
247
- if (actual)
248
- entries[relative] = actual;
249
- continue;
250
- }
285
+ if (mustCapture)
286
+ return captureActualFile(workspace, absolute, relative, blobRoot, budget, mode === '100755');
251
287
  consumeEntry(budget);
252
- await assertExistingPathContained(workspace, absolute);
253
- entries[relative] = { kind: 'file', source: 'git', ref: oid, size: stat.size, mode: stat.mode & 0o777, executable: mode === '100755', gitPath };
254
- }
255
- for (const gitPath of nulList(untrackedRaw)) {
256
- const relGit = prefix ? (gitPath.startsWith(`${prefix}/`) ? gitPath.slice(prefix.length + 1) : null) : gitPath;
257
- if (!relGit)
258
- continue;
259
- const relative = safeRelative(relGit);
260
- if (entries[relative])
261
- continue;
262
- const actual = await captureActualFile(workspace, absoluteFromRelative(workspace.canonicalRoot, relative), relative, blobRoot, budget);
263
- if (actual)
264
- entries[relative] = actual;
265
- }
288
+ await assertFileContained(workspace, absolute, budget);
289
+ return { kind: 'file', source: 'git', ref: oid, size: stat.size, mode: stat.mode & 0o777, executable: mode === '100755', gitPath };
290
+ });
291
+ jobs.forEach((job, i) => { const entry = tracked[i]; if (entry)
292
+ entries[job.relative] = entry; });
293
+ const untracked = [...new Set(nulList(untrackedRaw).map(gitPath => prefix ? (gitPath.startsWith(`${prefix}/`) ? gitPath.slice(prefix.length + 1) : null) : gitPath).filter((relGit) => Boolean(relGit)).map(safeRelative))].filter(relative => !entries[relative]);
294
+ const captured = await inOrder(untracked, CAPTURE_CONCURRENCY, relative => captureActualFile(workspace, absoluteFromRelative(workspace.canonicalRoot, relative), relative, blobRoot, budget));
295
+ untracked.forEach((relative, i) => { const entry = captured[i]; if (entry)
296
+ entries[relative] = entry; });
266
297
  return { capturedAt: new Date().toISOString(), workspace: { canonicalRoot: workspace.canonicalRoot, identityHash: workspace.identityHash, gitRoot }, entries, git: { root: gitRoot, head, indexHash, worktreeSemanticsHash }, coverage: { tracked: true, untrackedNonIgnored: true, ignoredUntracked: false, gitMetadataRestored: false }, exclusions: ['Untracked files ignored by Git are outside rollback coverage.', 'Empty-directory existence is outside rollback coverage.', 'Git refs, index and object database are observed for conflict evidence but are not restored.'] };
267
298
  }
268
299
  async function captureNonGit(workspace, blobRoot, budget) {
269
300
  const entries = {};
301
+ /* Folders one at a time and in order; the files of a folder CAPTURE_CONCURRENCY at a time. */
270
302
  async function preflight(dirname, relativeDir) {
271
303
  const rows = await readdir(dirname, { withFileTypes: true });
272
304
  rows.sort((a, b) => a.name.localeCompare(b.name));
273
- for (const row of rows) {
305
+ const files = rows.filter(row => !row.isDirectory());
306
+ await inOrder(files, CAPTURE_CONCURRENCY, async (row) => {
274
307
  const relative = safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name);
275
308
  const absolute = path.join(dirname, row.name);
276
- if (row.isDirectory()) {
277
- await assertExistingPathContained(workspace, absolute);
278
- await preflight(absolute, relative);
279
- continue;
280
- }
281
309
  let stat;
282
310
  try {
283
311
  stat = await lstat(absolute);
284
312
  }
285
313
  catch (error) {
286
314
  if (error?.code === 'ENOENT')
287
- continue;
315
+ return;
288
316
  throw error;
289
317
  }
290
318
  consumeEntry(budget);
@@ -298,12 +326,17 @@ async function captureNonGit(workspace, blobRoot, budget) {
298
326
  }
299
327
  if (!inside(workspace.canonicalRoot, canonical))
300
328
  throw coded('CHECKPOINT_SYMLINK_ESCAPE', `Checkpoint symlink escapes workspace: ${relative}`);
301
- continue;
329
+ return;
302
330
  }
303
331
  if (!stat.isFile())
304
332
  throw coded('CHECKPOINT_SPECIAL_FILE_UNSUPPORTED', `Unsupported workspace entry: ${relative}`);
305
- await assertExistingPathContained(workspace, absolute);
333
+ await assertFileContained(workspace, absolute, budget);
306
334
  consumeFileBudget(budget, stat.size);
335
+ });
336
+ for (const row of rows.filter(row => row.isDirectory())) {
337
+ const absolute = path.join(dirname, row.name);
338
+ await assertExistingPathContained(workspace, absolute);
339
+ await preflight(absolute, safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name));
307
340
  }
308
341
  }
309
342
  await preflight(workspace.canonicalRoot, '');
@@ -312,30 +345,30 @@ async function captureNonGit(workspace, blobRoot, budget) {
312
345
  async function walk(dirname, relativeDir) {
313
346
  const rows = await readdir(dirname, { withFileTypes: true });
314
347
  rows.sort((a, b) => a.name.localeCompare(b.name));
315
- for (const row of rows) {
316
- const relative = safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name);
348
+ const files = rows.filter(row => !row.isDirectory()).map(row => safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name));
349
+ const captured = await inOrder(files, CAPTURE_CONCURRENCY, relative => captureActualFile(workspace, path.join(dirname, path.basename(relative)), relative, blobRoot, budget));
350
+ files.forEach((relative, i) => { const entry = captured[i]; if (entry)
351
+ entries[relative] = entry; });
352
+ for (const row of rows.filter(row => row.isDirectory())) {
317
353
  const absolute = path.join(dirname, row.name);
318
- if (row.isDirectory()) {
319
- await assertExistingPathContained(workspace, absolute);
320
- await walk(absolute, relative);
321
- continue;
322
- }
323
- const actual = await captureActualFile(workspace, absolute, relative, blobRoot, budget);
324
- if (actual)
325
- entries[relative] = actual;
354
+ await assertExistingPathContained(workspace, absolute);
355
+ await walk(absolute, safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name));
326
356
  }
327
357
  }
328
358
  await walk(workspace.canonicalRoot, '');
329
359
  return { capturedAt: new Date().toISOString(), workspace: { canonicalRoot: workspace.canonicalRoot, identityHash: workspace.identityHash, gitRoot: null }, entries, git: null, coverage: { tracked: false, untrackedNonIgnored: true, ignoredUntracked: true, gitMetadataRestored: false }, exclusions: ['Filesystem metadata other than file content, executable bit and symlink target is outside rollback coverage.', 'Empty-directory existence is outside rollback coverage.'] };
330
360
  }
331
- export async function captureWorkspaceSnapshot({ projectRoot, blobRoot, limits = CHECKPOINT_LIMITS }) {
361
+ export async function captureWorkspaceSnapshot({ projectRoot, blobRoot, limits = CHECKPOINT_LIMITS, statCache }) {
362
+ const startedAtMs = Date.now();
332
363
  const workspace = await resolveWorkspaceIdentity({ projectRoot });
333
364
  await ensurePrivateDir(blobRoot);
334
365
  const canonicalBlobRoot = await realpath(blobRoot);
335
366
  if (inside(workspace.canonicalRoot, canonicalBlobRoot))
336
367
  throw coded('CHECKPOINT_STORE_INSIDE_WORKSPACE');
337
- const budget = { entries: 0, storedBytes: 0, limits };
338
- return workspace.gitRoot ? captureGit(workspace, canonicalBlobRoot, budget) : captureNonGit(workspace, canonicalBlobRoot, budget);
368
+ const budget = { entries: 0, storedBytes: 0, limits, startedAtMs, blobs: new Set(await readdir(canonicalBlobRoot)), pending: [], dirs: new Map(), cache: statCache ?? null };
369
+ const snapshot = workspace.gitRoot ? await captureGit(workspace, canonicalBlobRoot, budget) : await captureNonGit(workspace, canonicalBlobRoot, budget);
370
+ await syncNewBlobs(canonicalBlobRoot, budget);
371
+ return snapshot;
339
372
  }
340
373
  export function checkpointMutationAllowed(mode) { return mode !== 'plan'; }
341
374
  export function extractWorkspaceWriteEvidence(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talos-code",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "TALOS in the terminal: a coding agent with per-tool permissions, file checkpoints and resumable sessions, on the model you choose.",
5
5
  "keywords": [
6
6
  "coding-agent",
@@ -34,6 +34,7 @@
34
34
  "@microsoft/mxc-sdk": "0.8.0",
35
35
  "@modelcontextprotocol/client": "2.0.0",
36
36
  "@napi-rs/keyring": "1.3.0",
37
+ "@vscode/ripgrep": "1.18.0",
37
38
  "ai": "7.0.93",
38
39
  "diff": "9.0.0",
39
40
  "docx": "9.5.1",
@@ -20,6 +20,8 @@ export function createDesktopContextService({ engine, store, loadLegacy, resolve
20
20
  /* 24/09 — F4: le sessioni per cui questo processo ha già cercato job orfani (basta una volta: in
21
21
  questo processo un job attivo è sempre nella mappa `running` del motore). */
22
22
  const recuperate = new Set();
23
+ /* P18: per sessione, le impronte dei record gia' verificati e la revisione dell'archivio che le ha prodotte. */
24
+ const impronteVerificate = new Map();
23
25
  let closed = false;
24
26
  function serial(sessionId, task) {
25
27
  const previous = queues.get(sessionId) ?? Promise.resolve();
@@ -194,15 +196,28 @@ export function createDesktopContextService({ engine, store, loadLegacy, resolve
194
196
  }
195
197
  fail('CTX_ROUTE_NOT_FOUND', 'Operazione del contesto non trovata.');
196
198
  },
199
+ /*
200
+ * P18 (26/09/2026, patch approvata dall'owner via la lane CLI): l'archivio non si RILEGGE a ogni capture. Le
201
+ * impronte dei record gia' verificati restano in memoria, legate alla revisione dell'archivio che le ha prodotte;
202
+ * se la revisione e' ancora quella (nessun altro ha scritto: una compattazione la cambia), si usano quelle invece
203
+ * di `allRecords` (misurato: ~25 ms a capture a 1.351 messaggi, cresce con la storia). Il confronto con OGNI
204
+ * messaggio resta: la divergenza si scopre come prima.
205
+ */
197
206
  syncOriginals({ sessionId, messages }) {
198
207
  return serial(sessionId, async () => {
199
208
  const snapshot = await ensure(sessionId);
200
209
  if (!Array.isArray(messages)) fail('CTX_INVALID_INPUT', 'Cronologia non valida.');
201
- const saved = await allRecords(sessionId);
202
- if (messages.length < saved.length || saved.some((record, index) => record.sha256 !== digest(messages[index]))) fail('CTX_HISTORY_DIVERGED', 'La cronologia attiva differisce dall’archivio. Gli originali sono conservati; occorre recuperare la versione completa.');
203
- const records = messages.slice(saved.length).map((message, offset) => ({ id: `message-${saved.length + offset + 1}`, message, createdAt: clock(), origin: 'desktop-kernel' }));
210
+ const noto = impronteVerificate.get(sessionId);
211
+ const salvate = noto && Number.isInteger(snapshot?.revision) && noto.revision === snapshot.revision ? noto.impronte : (await allRecords(sessionId)).map((record) => record.sha256);
212
+ if (messages.length < salvate.length || salvate.some((impronta, index) => impronta !== digest(messages[index]))) {
213
+ impronteVerificate.delete(sessionId);
214
+ fail('CTX_HISTORY_DIVERGED', 'La cronologia attiva differisce dall’archivio. Gli originali sono conservati; occorre recuperare la versione completa.');
215
+ }
216
+ const records = messages.slice(salvate.length).map((message, offset) => ({ id: `message-${salvate.length + offset + 1}`, message, createdAt: clock(), origin: 'desktop-kernel' }));
204
217
  if (records.length) await store.appendOriginalBatch({ sessionId, records, expectedRevision: snapshot.revision });
205
- return store.readContextSnapshot({ sessionId });
218
+ const dopo = await store.readContextSnapshot({ sessionId });
219
+ impronteVerificate.set(sessionId, { revision: dopo?.revision, impronte: [...salvate, ...records.map((record) => digest(record.message))] });
220
+ return dopo;
206
221
  });
207
222
  },
208
223
  /*
@@ -3662,6 +3662,125 @@ async function tuttiIPercorsi(disco, { filtro = null } = {}) {
3662
3662
  return { percorsi, dimensioni, stato }
3663
3663
  }
3664
3664
 
3665
+ /**
3666
+ * ⭐⭐⭐ P18 fase 3 (owner 26/09/2026: «TALOS più veloce di tutti»; decisioni: ripgrep DENTRO il pacchetto,
3667
+ * righe trovate con un tetto) — `cerca` nel contenuto con ripgrep.
3668
+ *
3669
+ * Misurato sul banco ermetico (`cli/scripts/bench-harness.ts`, 26/09): quattro `cerca` su un albero di 6.000 file
3670
+ * costavano **9,3 s** (una lettura JS alla volta, fino a `MAX_FILE_LETTI`), e su questo monorepo (6.405 file visibili)
3671
+ * la ricerca si fermava a 5.000 letture: **incompleta**, e una parola assente risultava «non c'e'». Pi fa `rg --json`
3672
+ * (`coding-agent/src/core/tools/grep.ts:119-168`), Claude Code ha ripgrep incluso (`USE_BUILTIN_RIPGREP`), Codex lo usa
3673
+ * dalla shell. ⇒ Stessa semantica di prima (sottostringa, maiuscole indifferenti, `.gitignore` anche fuori da un repo,
3674
+ * `.git`/`node_modules` sempre fuori, i binari saltati), ma ogni risultato porta **riga e testo** (`percorso:riga:testo`):
3675
+ * il modello non deve riaprire il file per sapere dove.
3676
+ *
3677
+ * Il binario: `TALOS_RG_PATH` (la CLI lo mette da `@vscode/ripgrep`, che e' nel suo pacchetto, senza rete), altrimenti
3678
+ * `@vscode/ripgrep` se risolvibile da qui. ⛔ Assente, rotto o in timeout ⇒ `null`, e `cercaNelProgetto` prosegue con la
3679
+ * camminata JS di sempre: nessuna installazione perde la ricerca.
3680
+ */
3681
+ const MAX_CERCA_INSIEME = 8
3682
+ /* Gli attrezzi che non cambiano niente: una partenza anticipata puo' scavalcarli, mai scavalcare altro. */
3683
+ const LETTURE_PURE = new Set(['cerca', 'leggi', 'elenca'])
3684
+ const MAX_RIGHE_RG = 100
3685
+ const MAX_RIGHE_PER_FILE = 5
3686
+ const MAX_COLONNE_RG = 200
3687
+ const MAX_BYTE_RG = 4_000_000
3688
+ const TEMPO_MAX_RG_MS = 20_000
3689
+ let ripgrepRisolto = null
3690
+ function percorsoRipgrep() {
3691
+ const daAmbiente = process.env.TALOS_RG_PATH
3692
+ if (daAmbiente) return Promise.resolve(existsSync(daAmbiente) ? daAmbiente : null)
3693
+ ripgrepRisolto ??= import('@vscode/ripgrep').then(
3694
+ (m) => (typeof m?.rgPath === 'string' && existsSync(m.rgPath) ? m.rgPath : null),
3695
+ () => null)
3696
+ return ripgrepRisolto
3697
+ }
3698
+ function eseguiRipgrep(rg, argomenti, cartella, segnale) {
3699
+ return new Promise((fine) => {
3700
+ let figlio
3701
+ try { figlio = spawn(rg, argomenti, { cwd: cartella, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) }
3702
+ catch { fine(null); return }
3703
+ const pezzi = []
3704
+ let byte = 0, tagliato = false
3705
+ const orologio = setTimeout(() => { tagliato = true; figlio.kill() }, TEMPO_MAX_RG_MS)
3706
+ /* Lo Stop ferma anche la ricerca IN VOLO (vincolo del desktop, 26/09): si uccide rg e si dice «fermata». */
3707
+ let fermato = false
3708
+ const alloStop = () => { fermato = true; figlio.kill() }
3709
+ if (segnale?.aborted) alloStop(); else segnale?.addEventListener?.('abort', alloStop, { once: true })
3710
+ figlio.stdout.on('data', (pezzo) => {
3711
+ if (tagliato) return
3712
+ byte += pezzo.length
3713
+ pezzi.push(pezzo)
3714
+ if (byte >= MAX_BYTE_RG) { tagliato = true; figlio.kill() }
3715
+ })
3716
+ figlio.on('error', () => { clearTimeout(orologio); fine(null) })
3717
+ figlio.on('close', (codice) => {
3718
+ clearTimeout(orologio)
3719
+ segnale?.removeEventListener?.('abort', alloStop)
3720
+ if (fermato) { fine({ testo: '', tagliato: false, scaduto: false, fermato: true }); return }
3721
+ const testo = Buffer.concat(pezzi).toString('utf8')
3722
+ /* 0 = trovato, 1 = niente; 2 = errori (un file illeggibile) con o senza risultati. Un 2 senza uscita,
3723
+ o un processo ucciso senza niente, non e' «non c'e'»: e' un guasto, e decide il ripiego JS. */
3724
+ if (codice === 0 || codice === 1 || testo) fine({ testo, tagliato: tagliato && byte >= MAX_BYTE_RG, scaduto: tagliato && byte < MAX_BYTE_RG })
3725
+ else fine(null)
3726
+ })
3727
+ })
3728
+ }
3729
+ const percorsoDaRg = (p) => p.replace(/\\/gu, '/').replace(/^\.\//u, '')
3730
+ async function cercaConRipgrep(radice, testo, chiaveNome, segnale) {
3731
+ const rg = await percorsoRipgrep()
3732
+ if (!rg) return null
3733
+ const comuni = ['--no-config', '--hidden', '--no-require-git', '--glob', '!.git', '--glob', '!node_modules']
3734
+ /* Il ripiego delle cartelle potate vale, come prima, solo quando non c'e' un `.gitignore` da cui leggere. */
3735
+ if (!existsSync(join(radice, '.gitignore'))) for (const c of POTATE_SENZA_GITIGNORE) comuni.push('--glob', `!${c}`)
3736
+ const ricerca = await eseguiRipgrep(rg, [...comuni, '--line-number', '--with-filename', '--no-heading', '--color', 'never',
3737
+ '--fixed-strings', '--ignore-case', '--max-count', String(MAX_RIGHE_PER_FILE), '--max-columns', String(MAX_COLONNE_RG),
3738
+ '--max-columns-preview', '--max-filesize', String(MAX_BYTE_FILE), '-e', testo, '--', '.'], radice, segnale)
3739
+ if (ricerca === null) return null
3740
+ if (ricerca.fermato) return 'stopped: the search was interrupted.'
3741
+ /* rg lavora in parallelo e non promette un ordine: si ordina qui, cosi' la stessa domanda da' la stessa risposta. */
3742
+ const perFile = new Map()
3743
+ for (const riga of ricerca.testo.split(/\r?\n/u)) {
3744
+ const m = /^(.+?):(\d+):(.*)$/u.exec(riga)
3745
+ if (!m) continue
3746
+ const percorso = percorsoDaRg(m[1])
3747
+ if (!perFile.has(percorso)) perFile.set(percorso, [])
3748
+ perFile.get(percorso).push(`${percorso}:${m[2]}:${m[3].trim()}`)
3749
+ }
3750
+ let perNome = []
3751
+ if (chiaveNome) {
3752
+ const elenco = await eseguiRipgrep(rg, [...comuni, '--files', '.'], radice, segnale)
3753
+ if (elenco === null) return null
3754
+ if (elenco.fermato) return 'stopped: the search was interrupted.'
3755
+ perNome = elenco.testo.split(/\r?\n/u).filter(Boolean).map(percorsoDaRg).filter((p) => p.toLowerCase().includes(chiaveNome)).sort()
3756
+ }
3757
+ const avvisi = []
3758
+ if (ricerca.scaduto) avvisi.push(`⚠ incomplete scan: the search took longer than ${TEMPO_MAX_RG_MS / 1000} s and was stopped. Search inside a subfolder.`)
3759
+ if (ricerca.tagliato) avvisi.push('⚠ incomplete scan: too much output — the matches below are a part of them. Narrow the search.')
3760
+ const coda = avvisi.length > 0 ? `\n${avvisi.join('\n')}` : ''
3761
+ const file = [...perFile.keys()].sort()
3762
+ if (file.length === 0 && perNome.length === 0) {
3763
+ return `no file matches "${testo}" (searched with ripgrep; .gitignore respected, binaries skipped). Try a shorter or different "testo".${coda}`
3764
+ }
3765
+ const righe = perNome.slice(0, MAX_RISULTATI)
3766
+ let righeTesto = 0, fileMostrati = 0
3767
+ for (const f of file) {
3768
+ if (fileMostrati >= MAX_RISULTATI || righeTesto >= MAX_RIGHE_RG) break
3769
+ const trovate = perFile.get(f).slice(0, MAX_RIGHE_RG - righeTesto)
3770
+ righe.push(...trovate)
3771
+ righeTesto += trovate.length
3772
+ fileMostrati += 1
3773
+ }
3774
+ const fileTagliati = file.length - fileMostrati
3775
+ const nomiTagliati = perNome.length - Math.min(perNome.length, MAX_RISULTATI)
3776
+ return righe.join('\n')
3777
+ + (nomiTagliati > 0 ? `\n… and ${nomiTagliati} more paths matching "${chiaveNome}" not shown — narrow the search.` : '')
3778
+ // ⛔ Il taglio si DICHIARA, come nella camminata JS: «40 file» non deve leggersi come «sono 40».
3779
+ + (fileTagliati > 0 ? `\n… and ${fileTagliati} more files with matches not shown — narrow the search.` : '')
3780
+ + (file.some((f) => perFile.get(f).length >= MAX_RIGHE_PER_FILE) ? `\n(at most ${MAX_RIGHE_PER_FILE} matching lines per file are shown)` : '')
3781
+ + coda
3782
+ }
3783
+
3665
3784
  /**
3666
3785
  * ⭐⭐⭐ CERCA — l'attrezzo che toglie la cecita', senza comprare l'inventario.
3667
3786
  *
@@ -3685,18 +3804,28 @@ async function tuttiIPercorsi(disco, { filtro = null } = {}) {
3685
3804
  * @param {{radice?: string}} [opzioni] — la cartella VERA, per leggere il `.gitignore`.
3686
3805
  * Assente (test, ponte del telefono) ⇒ si usa il ripiego `POTATE_SENZA_GITIGNORE`.
3687
3806
  */
3688
- export async function cercaNelProgetto(disco, { testo, nome }, { radice } = {}) {
3807
+ export async function cercaNelProgetto(disco, { testo, nome }, { radice, segnale } = {}) {
3689
3808
  const chiaveTesto = String(testo ?? '').trim().toLowerCase()
3690
3809
  const chiaveNome = String(nome ?? '').trim().toLowerCase()
3691
3810
  if (!chiaveTesto && !chiaveNome) return 'give at least one of "testo" or "nome".'
3811
+ /* P18 fase 3: con la cartella vera e un ripgrep disponibile, la ricerca nel contenuto la fa `rg`.
3812
+ `null` = rg assente o fallito: si prosegue con la camminata JS qui sotto, invariata. */
3813
+ if (chiaveTesto && radice) {
3814
+ const conRg = await cercaConRipgrep(radice, String(testo).trim(), chiaveNome, segnale)
3815
+ if (conRg !== null) return conRg
3816
+ }
3692
3817
 
3693
3818
  const filtro = await filtroDelProgetto(radice)
3694
3819
  const { percorsi, dimensioni, stato } = await tuttiIPercorsi(disco, { filtro })
3820
+ /* P18 (vincolo desktop a, 26/09): lo Stop ferma anche la camminata JS, non solo rg — fino a 8 `cerca` possono
3821
+ essere partite insieme, e nessuna deve finire in sottofondo dopo «Ferma». */
3822
+ if (segnale?.aborted) return 'stopped: the search was interrupted.'
3695
3823
  const perNome = []
3696
3824
  const perTesto = []
3697
3825
  const conto = { letti: 0, binari: 0, troppoGrandi: 0, illeggibili: 0, tettoLetture: false, tettoRicerca: false }
3698
3826
 
3699
3827
  for (const p of percorsi) {
3828
+ if (segnale?.aborted) return 'stopped: the search was interrupted.'
3700
3829
  const basso = p.toLowerCase()
3701
3830
  const combaciaNome = chiaveNome && basso.includes(chiaveNome)
3702
3831
  if (combaciaNome) { perNome.push(p); continue }
@@ -8136,6 +8265,35 @@ export async function talosLavora({
8136
8265
  break
8137
8266
  }
8138
8267
 
8268
+ /*
8269
+ * ⭐⭐ P18 fase 2 (owner 26/09/2026, patch approvata dalla lane desktop) — le `cerca` della STESSA risposta
8270
+ * partono INSIEME, fino a `MAX_CERCA_INSIEME` (Hermes 8, `_MAX_TOOL_WORKERS`; Claude Code 10, i blocchi
8271
+ * «concurrency-safe» dello `StreamingToolExecutor`). Il ciclo qui sotto resta quello di sempre: hook,
8272
+ * approvazioni, eventi `tool-inizio`/`tool-esito` e risultati nella storia, tutto nell'ordine delle chiamate;
8273
+ * il ramo `cerca` aspetta la SUA ricerca già partita invece di avviarla.
8274
+ * ⛔ Solo `cerca`: e' pura (legge dentro la cartella, non chiede approvazioni) e un rifiuto del pre-hook su una
8275
+ * lettura e' gia' ignorato (vedi FASE A qui sotto). `leggi` no: un percorso fuori dal progetto chiede
8276
+ * l'approvazione, e quella va chiesta in ordine, prima di leggere.
8277
+ * ⛔ Lo Stop ferma anche quelle in volo: il segnale arriva fino a rg, che viene ucciso.
8278
+ * Misura (banco ermetico, 4 `cerca` su 6.000 file): 8.648 ms prima di P18, 377 con ripgrep in fila.
8279
+ */
8280
+ /* ⛔⛔ Solo il PREFISSO di sole letture della risposta (`cerca`, `leggi`, `elenca`): una `cerca` che viene DOPO
8281
+ una scrittura o un comando deve vedere il loro effetto, e partita prima non lo vedrebbe (misurato: `scrivi`
8282
+ poi `cerca` nella stessa risposta dava la ricerca SENZA il file appena scritto — p18-cerca-insieme). */
8283
+ const ricercheAvviate = new Map()
8284
+ const primaNonLettura = chiamate.findIndex((c) => !LETTURE_PURE.has(c.function?.name))
8285
+ const prefissoDiLettura = primaNonLettura === -1 ? chiamate : chiamate.slice(0, primaNonLettura)
8286
+ const daCercare = prefissoDiLettura.filter((c) => c.function?.name === 'cerca')
8287
+ if (daCercare.length > 1 && !segnaleStop?.aborted) {
8288
+ for (const c of daCercare.slice(0, MAX_CERCA_INSIEME)) {
8289
+ let argomentiCerca = {}
8290
+ try { argomentiCerca = JSON.parse(c.function?.arguments || '{}') } catch { /* vuoto: il ramo dira' cosa manca */ }
8291
+ const avviata = cercaNelProgetto(disco, argomentiCerca, { radice: cartella, segnale: segnaleStop })
8292
+ avviata.catch(() => {})
8293
+ ricercheAvviate.set(c, avviata)
8294
+ }
8295
+ }
8296
+
8139
8297
  let fermatoDentroIlGiro = false
8140
8298
  for (const c of chiamate) {
8141
8299
  /*
@@ -8232,7 +8390,7 @@ export async function talosLavora({
8232
8390
  * potare. Senza, `cerca` ricade sulla lista fissa — che e' il ripiego per il ponte
8233
8391
  * del telefono e per i test, non il caso normale.
8234
8392
  */
8235
- esito = await cercaNelProgetto(disco, argomenti, { radice: cartella })
8393
+ esito = await (ricercheAvviate.get(c) ?? cercaNelProgetto(disco, argomenti, { radice: cartella, segnale: segnaleStop }))
8236
8394
  }
8237
8395
  else if (nome === 'leggi') {
8238
8396
  /*
@@ -105,8 +105,13 @@ function git(argomenti, cwd) {
105
105
  export async function statoGit(percorso, { profondita = PROFONDITA_REPO_ANNIDATI, eseguiGit = git } = {}) {
106
106
  const dentro = await eseguiGit(['rev-parse', '--is-inside-work-tree'], percorso);
107
107
  if (!dentro || dentro.trim() !== 'true') return null;
108
- const ramo = (await eseguiGit(['rev-parse', '--abbrev-ref', 'HEAD'], percorso) || '').trim() || null;
109
- const stato = await eseguiGit(['status', '--porcelain'], percorso);
108
+ /* P18 (26/09/2026): ramo e stato sono indipendenti, si chiedono INSIEME (misurato all'avvio di un turno: ~55 ms
109
+ in meno, due processi git in fila diventano uno accanto all'altro). */
110
+ const [ramoGrezzo, stato] = await Promise.all([
111
+ eseguiGit(['rev-parse', '--abbrev-ref', 'HEAD'], percorso),
112
+ eseguiGit(['status', '--porcelain'], percorso),
113
+ ]);
114
+ const ramo = (ramoGrezzo || '').trim() || null;
110
115
  const nonSalvate = stato === null ? null : stato.split('\n').filter((r) => r.trim()).length;
111
116
  const annidati = await repoAnnidati(percorso, profondita);
112
117
  return { ramo, nonSalvate, repoAnnidati: annidati };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": "talos.cli.vendor.v1",
3
- "sourceCommit": "f54beedcbd8fb3c6c735881e91dab21fbe13bc1b",
3
+ "sourceCommit": "e553a0cd2616df27b399505afc3fa0eeb8b504fa",
4
4
  "files": 163,
5
5
  "sha256": {
6
6
  "context-engine/package.json": "cd416c2ef02241365eca721ce1bed7a702cff34bb804d79e974f9f36731ced8a",
@@ -36,7 +36,7 @@
36
36
  "harness-ui/src/config.mjs": "ab482ac221f4b5ad3e1b46052d87549ebf89fd2ee30ce113ceda8132b028e1ef",
37
37
  "harness-ui/src/contesto-del-progetto.mjs": "5aec0670f0d81fc545112ae4f2a0a36a5359e76e76f39bf63d403a6cb615d030",
38
38
  "harness-ui/src/context-asset-adapter.mjs": "5a5feccad64c3b427031dcc8dfc47a2ffa5e0db4eec2959a8d3b70e3c87a55fc",
39
- "harness-ui/src/context-desktop-service.mjs": "5415123aa96f5bdeeedcedbcd55cb0b3fe944c629232a92bb98eeda372c8d397",
39
+ "harness-ui/src/context-desktop-service.mjs": "0ac2625b63512909c689d81370047a7e54892fbf0c58622936b5c9a72aff9fc0",
40
40
  "harness-ui/src/context-embedding-runtime.mjs": "de494d5d1bf08e2a9b685dbf78f8e9a16631700e0c4e5d045c91f65a6a4c6ec2",
41
41
  "harness-ui/src/context-inference-scheduler.mjs": "26dcccde934993226d4adb0b239b465a45640701d377c5851fc58209552e7d39",
42
42
  "harness-ui/src/context-native-compaction.mjs": "55069d9a80283d10be924bd1309bb271a234608660424deb48aac82f8e273125",
@@ -73,7 +73,7 @@
73
73
  "harness-ui/src/image-generator.mjs": "8762739d578f81d1b881d24338518480e59fcae5b8a3ff6a071947652bec8008",
74
74
  "harness-ui/src/istruzioni-di-progetto.mjs": "a0c2c9dec9d119799e3bc02b1aee99ffa996d2c141ba679345f7309495a29cff",
75
75
  "harness-ui/src/kernel/dist/kernelPerIlBanco.js": "ff75a3e93b6c6d6d838331016c5eb2d1e6acfea85ffc0609d2f531f07b202a54",
76
- "harness-ui/src/kernel/talosHarness.mjs": "cd41c71a874ffaa25ba456ec98a664265704e71dcfd63eeb1a4216a5924ba613",
76
+ "harness-ui/src/kernel/talosHarness.mjs": "0ee781ebbb507389a0ac109eebb599d45760dedf9d368d6f921e02b2a8e97d1e",
77
77
  "harness-ui/src/library-policy-store.mjs": "b0295e20d7348cc13cb23a8ad2a0b577fe275b4778b94e102ceeb4fb1bb4c70b",
78
78
  "harness-ui/src/library-store.mjs": "b7a0333da5783361536509f9108e1293b1e8c22bddfb02fd5581a4e1ed4bcd4f",
79
79
  "harness-ui/src/llama-server-supervisor.mjs": "d59cc22a0e7875b4deeb4a4eac1daa55c3f524db7f5f9bd1f5e58f2c4be261e3",
@@ -162,7 +162,7 @@
162
162
  "harness-ui/src/workspace-context.mjs": "75bb99b0e5624b2257a73c76997bb8670206758cbabe4613683503c6c785930a",
163
163
  "harness-ui/src/workspace-disk.mjs": "b90a666880079a72bca7512bb936324f2abe3ed1075aec828cbcbeba3965ec7a",
164
164
  "harness-ui/src/workspace-files.mjs": "998f04da2959d8404da801d4cf981e1e42fcab221d2b96456acb19688980c4a4",
165
- "harness-ui/src/workspace-info.mjs": "e685e98675cb432154cb20e076be310076e2f07c240de11bc4d0feab94293d05",
165
+ "harness-ui/src/workspace-info.mjs": "cd3cb40401139e06a5ddc23683822578913c180744bea233d3b5e2c29b417042",
166
166
  "harness-ui/src/workspace-launch-store.mjs": "06e14d1c740377d788609d6cd930575a1180dab32640a1692605a0225cadf9a9",
167
167
  "harness-ui/src/workspace-tree.mjs": "c5723383cdff663ea0537e384fef8593f574893e63b9645c395a076910ac1f8b",
168
168
  "harness-ui/src/workspace-watcher.mjs": "ab1e1a03ab61b98f2a7a5bd559b56e1e6dfc0b3ec201a41edc96d70512f8053f"