@buildaharness/aielia 0.3.5 → 0.3.7

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.
Files changed (47) hide show
  1. package/README.md +97 -19
  2. package/dist/action-approval-service.d.ts +34 -2
  3. package/dist/action-record.d.ts +13 -0
  4. package/dist/activity-log.d.ts +4 -0
  5. package/dist/agent-loop.d.ts +83 -1
  6. package/dist/assistant-session.d.ts +20 -1
  7. package/dist/assistant-types.d.ts +3 -1
  8. package/dist/assistant.d.ts +32 -0
  9. package/dist/{bin-CjKpxDxl.js → bin-C_hgDdSf.js} +369 -219
  10. package/dist/bin-C_hgDdSf.js.map +1 -0
  11. package/dist/cli-config.d.ts +3 -0
  12. package/dist/cli-session.d.ts +5 -4
  13. package/dist/cli.d.ts +9 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +3 -1
  16. package/dist/debug-log.d.ts +10 -1
  17. package/dist/email.d.ts +5 -3
  18. package/dist/file-tools-mcp-server.mjs +155 -30
  19. package/dist/file-tools.d.ts +2 -0
  20. package/dist/git-exclude.d.ts +9 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +127 -126
  23. package/dist/ink-select-prompt.d.ts +2 -0
  24. package/dist/ink-test-render.d.ts +2 -0
  25. package/dist/memory-service.d.ts +8 -0
  26. package/dist/next-step-proposer.d.ts +7 -0
  27. package/dist/plan-drafting-service.d.ts +3 -0
  28. package/dist/plan-store.d.ts +5 -0
  29. package/dist/{provider-setup-LFsaTkpf.js → provider-setup-wX2IBw2X.js} +2183 -919
  30. package/dist/provider-setup-wX2IBw2X.js.map +1 -0
  31. package/dist/reply-audit.d.ts +67 -0
  32. package/dist/side-call-policy.d.ts +30 -0
  33. package/dist/tool-step.d.ts +6 -0
  34. package/dist/trace-events.d.ts +1 -1
  35. package/dist/trust-tagging.d.ts +2 -0
  36. package/dist/{tui-app-BLR-PRFp.js → tui-app-C0J6HRzv.js} +127 -26
  37. package/dist/tui-app-C0J6HRzv.js.map +1 -0
  38. package/dist/tui-app.d.ts +35 -0
  39. package/dist/tui-mode-flag.d.ts +1 -1
  40. package/dist/turn-abort.d.ts +18 -0
  41. package/dist/turn-intent-classifier.d.ts +8 -0
  42. package/dist/turn-interpreter.d.ts +2 -0
  43. package/dist/version.d.ts +1 -1
  44. package/package.json +3 -3
  45. package/dist/bin-CjKpxDxl.js.map +0 -1
  46. package/dist/provider-setup-LFsaTkpf.js.map +0 -1
  47. package/dist/tui-app-BLR-PRFp.js.map +0 -1
@@ -18,10 +18,13 @@ export declare function isConfigKey(key: string): key is keyof AssistantConfig;
18
18
  * before this module existed (ASSISTANT_ENABLE_WEB/ASSISTANT_ENABLE_SHELL must be exactly "1").
19
19
  */
20
20
  export declare function envOverridesFromProcessEnv(env: NodeJS.ProcessEnv): Partial<AssistantConfig>;
21
+ export declare function findInvalidNumericSettings(config: AssistantConfig): string[];
21
22
  /** Thrown by parseConfigValue on a value that doesn't fit the target key's type — cli.ts reports .message and leaves the config unchanged. */
22
23
  export declare class ConfigValueParseError extends Error {
23
24
  }
24
25
  /** Parses a raw `/config set <key> <value>` string into the type AssistantConfig[key] expects. */
25
26
  export declare function parseConfigValue(key: keyof AssistantConfig, raw: string): unknown;
27
+ /** A URL with any user:password, and every query-string value, masked, so a credential embedded in proxyUrl is never printed. Text that is not a URL is returned unchanged. */
28
+ export declare function redactUrlCredentials(text: string): string;
26
29
  /** Renders /config's full listing as a single multi-line string — cli.ts just console.logs the result. */
27
30
  export declare function formatConfigListing(config: AssistantConfig, overriddenKeys: ReadonlySet<keyof AssistantConfig>): string;
@@ -7,11 +7,12 @@ import { GoalGraphState } from './goal-graph-service.js';
7
7
  import { UndoLogEntry } from './action-snapshot.js';
8
8
  import { UserFact } from './fact-extraction.js';
9
9
  /**
10
- * Pure formatting/logic backing cli.ts's session-level commands (/help, /status, /export,
11
- * /memory, /cost, /doctor) — split out so it's unit-testable without a real readline loop,
12
- * the same way cli-config.ts already is for the /config family. cli.ts stays thin glue
13
- * (reading live state, calling these, doing actual file I/O) on top of these functions.
10
+ * Text that came from the model, a tool result, a file or a web page must not reach the terminal raw: an
11
+ * embedded escape sequence can clear the screen, rewrite earlier lines (hiding what an approval prompt is
12
+ * really asking), set the window title or write the clipboard (OSC 52). Newlines and tabs survive; everything
13
+ * that could drive the terminal is removed. Plain text is returned unchanged.
14
14
  */
15
+ export declare function stripTerminalControls(text: string): string;
15
16
  export interface CliCommandHelp {
16
17
  command: string;
17
18
  description: string;
package/dist/cli.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface RunCliOptions {
28
28
  id: string;
29
29
  status: TaskStatus;
30
30
  }[]) => void;
31
+ /**
32
+ * Called with true when a conversational turn starts being worked on and false when it has finished (in a
33
+ * `finally`, so also after an error) — including turns run as deferred follow-ups, which no `dispatchLine()`
34
+ * promise covers. The TUI's liveness indicator follows this rather than guessing from output events.
35
+ */
36
+ onTurnBusyChange?: (busy: boolean) => void;
31
37
  dataDir?: string;
32
38
  configStore?: ConfigStore;
33
39
  backend?: FsBackend;
@@ -97,6 +103,9 @@ export declare function runCli(options?: RunCliOptions): Promise<CliInstance>;
97
103
  * `runTuiApp` is imported dynamically so a disabled (the default) or non-TTY run never loads Ink
98
104
  * at all — `main()` behaves byte-for-byte as it does today in both those cases.
99
105
  */
106
+ /** A reason the assistant refuses to start (invalid configuration, unusable environment): shown to the user verbatim, exit code 1. */
107
+ export declare class StartupError extends Error {
108
+ }
100
109
  export declare function main(argv?: readonly string[]): Promise<void>;
101
110
  /**
102
111
  * True when `entryArg` (a `process.argv[1]` value) refers to the same file as
package/dist/cli.js CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import "./bin-CjKpxDxl.js";
2
+ import "./bin-C_hgDdSf.js";
3
3
  //# sourceMappingURL=cli.js.map
package/dist/config.d.ts CHANGED
@@ -141,7 +141,7 @@ export interface AssistantConfig {
141
141
  * Phase 4 of the internal plan — the rollout flag for the
142
142
  * Ink-driven pinned-bottom-input interactive shell (tui-app.ts's runTuiApp()). Undefined (the
143
143
  * default, for this plan's whole rollout window) means cli.ts's main() falls back to
144
- * DEFAULT_TUI_MODE ('disabled') — today's plain readline-based REPL, byte-for-byte. Mirrors
144
+ * DEFAULT_TUI_MODE ('enabled'); 'disabled' is the plain readline-based REPL. Mirrors
145
145
  * oneLoopMode/askMode/planMode's exact chain: env override (ASSISTANT_TUI, cli-config.ts) and
146
146
  * a package-owned default kept out of DEFAULT_CONFIG so an unset value stays undefined. Unlike
147
147
  * those three, this has no chat-ui/desktop build-time counterpart — there's no terminal to pin
@@ -157,6 +157,8 @@ export interface AssistantConfig {
157
157
  * check, never the explicit `aielia update` command. CLI-only, like `tuiMode`.
158
158
  */
159
159
  updateCheck?: UpdateCheckMode;
160
+ /** Colour theme of the chat-ui/desktop window: 'system' (default, follows the OS), 'dark' or 'light'. The CLI ignores it. */
161
+ theme?: 'system' | 'dark' | 'light';
160
162
  /**
161
163
  * The project label newly captured project-scoped facts are tagged with, and the one
162
164
  * project-scoped facts are filtered against when building a turn's context (see
@@ -5,12 +5,21 @@
5
5
  * caller opts into this explicitly (see PersonalAssistantOptions.onDebugLog) knowing it
6
6
  * carries real content, not just metadata.
7
7
  *
8
+ * `approval_request` is a state-changing action (write/shell/email) staged for the user's yes/no, and
9
+ * `approval_decision` is how that was resolved ('approved' or 'declined'); the executed result of an
10
+ * approved action is the separate `tool_call` entry, so a log reader can reconstruct the whole exchange.
11
+ *
12
+ * `next_steps` is the numbered list of suggestions printed under a reply (one entry per turn that produced any).
13
+ *
14
+ * `note` records a robustness action the loop took on its own (an empty final answer retried, or replaced by a
15
+ * deterministic fallback), so a log reader can see why a reply is not what the model first returned.
16
+ *
8
17
  * Split into its own file so both assistant.ts (user_message/assistant_reply kinds, emitted from
9
18
  * `turn()`) and agent-loop.ts (tool_call kind, emitted from the ReAct loop) can depend on it
10
19
  * without either owning the other.
11
20
  */
12
21
  export interface DebugLogEntry {
13
- kind: 'user_message' | 'assistant_reply' | 'tool_call';
22
+ kind: 'user_message' | 'user_message_queued' | 'assistant_reply' | 'tool_call' | 'approval_request' | 'approval_decision' | 'note' | 'next_steps';
14
23
  sessionId: string;
15
24
  content: string;
16
25
  }
package/dist/email.d.ts CHANGED
@@ -53,8 +53,10 @@ export interface ResendSenderOptions {
53
53
  */
54
54
  export declare function createResendSender(options: ResendSenderOptions): SendEmail;
55
55
  /**
56
- * One-line human-readable summary of a staged email, for the approval prompt and trace. Kept here
56
+ * Human-readable summary of a staged email, for the approval prompt and trace. Kept here
57
57
  * (not in agent-loop.ts) so the MCP server (file-tools-mcp-server.mjs) and the CLI can format the
58
- * same way without importing the loop.
58
+ * same way without importing the loop. The approver must see everything that will be sent: every
59
+ * recipient (Cc/Bcc included, since a hidden Bcc is a silent exfiltration channel) and the whole
60
+ * body, never a truncated preview.
59
61
  */
60
- export declare function formatEmailApprovalReason(message: Pick<EmailMessage, 'to' | 'subject' | 'body'>): string;
62
+ export declare function formatEmailApprovalReason(message: Pick<EmailMessage, 'to' | 'subject' | 'body' | 'cc' | 'bcc'>): string;
@@ -86,7 +86,7 @@
86
86
  * logic without a real MCP client attached over stdio): node file-tools-mcp-server.mjs --test
87
87
  */
88
88
 
89
- import { readFile, writeFile, mkdir, readdir, realpath as fsRealpath, mkdtemp, rm } from 'node:fs/promises'
89
+ import { readFile, writeFile, mkdir, readdir, realpath as fsRealpath, mkdtemp, rm, rename } from 'node:fs/promises'
90
90
  import { readFileSync } from 'node:fs'
91
91
  import { tmpdir } from 'node:os'
92
92
  import { randomUUID } from 'node:crypto'
@@ -111,10 +111,16 @@ export class PathOutsideWorkspaceError extends Error {
111
111
  }
112
112
  }
113
113
 
114
+ // Mirrors file-tools.ts's normalizePath: splits on both `/` and `\`, and treats a Windows drive-letter
115
+ // prefix as absolute — otherwise a backslash-style `..\..\x` is one opaque segment here that never
116
+ // pops, yet the real OS resolves it as a traversal.
114
117
  function normalizePath(path) {
115
- const absolute = path.startsWith('/')
118
+ const driveMatch = /^([A-Za-z]:)[\\/]/.exec(path)
119
+ const absolute = driveMatch !== null || path.startsWith('/') || path.startsWith('\\')
120
+ const prefix = driveMatch ? driveMatch[1] : ''
121
+ const rest = driveMatch ? path.slice(driveMatch[0].length) : path
116
122
  const segments = []
117
- for (const part of path.split('/')) {
123
+ for (const part of rest.split(/[\\/]/)) {
118
124
  if (part === '' || part === '.') continue
119
125
  if (part === '..') {
120
126
  if (segments.length > 0 && segments[segments.length - 1] !== '..') segments.pop()
@@ -123,12 +129,13 @@ function normalizePath(path) {
123
129
  segments.push(part)
124
130
  }
125
131
  }
126
- return (absolute ? '/' : '') + segments.join('/')
132
+ return prefix + (absolute ? '/' : '') + segments.join('/')
127
133
  }
128
134
 
129
135
  export function resolveInWorkspace(workspaceRoot, requestedPath) {
130
136
  const root = normalizePath(workspaceRoot)
131
- const combined = requestedPath.startsWith('/') ? requestedPath : `${root}/${requestedPath}`
137
+ const requestedIsAbsolute = /^[A-Za-z]:[\\/]/.test(requestedPath) || requestedPath.startsWith('/') || requestedPath.startsWith('\\')
138
+ const combined = requestedIsAbsolute ? requestedPath : `${root}/${requestedPath}`
132
139
  const resolved = normalizePath(combined)
133
140
  if (resolved !== root && !resolved.startsWith(`${root}/`)) {
134
141
  throw new PathOutsideWorkspaceError(requestedPath)
@@ -176,7 +183,18 @@ function isEnoent(err) {
176
183
  // once the current one resolves, instead of dropping it.
177
184
  let lastStagedIdThisProcess
178
185
 
179
- export async function stagePendingAction(workspaceRoot, payload) {
186
+ // Two staging calls in flight at once (a client may issue tool calls concurrently) would both read the same
187
+ // `lastStagedIdThisProcess` and both link it forward, so one link overwrote the other and that action was never
188
+ // surfaced for approval. Staging is therefore serialized.
189
+ let stagingChain = Promise.resolve()
190
+
191
+ export function stagePendingAction(workspaceRoot, payload) {
192
+ const run = stagingChain.then(() => stagePendingActionNow(workspaceRoot, payload))
193
+ stagingChain = run.then(() => undefined, () => undefined)
194
+ return run
195
+ }
196
+
197
+ async function stagePendingActionNow(workspaceRoot, payload) {
180
198
  const id = randomUUID()
181
199
  const dir = `${workspaceRoot}/${PENDING_ACTIONS_DIR}`
182
200
  await mkdir(dir, { recursive: true })
@@ -375,7 +393,18 @@ function doRequestToolGate(port, payload, options) {
375
393
  // ── Trust boundary for fetched content — mirrors trust-tagging.ts ──────────
376
394
 
377
395
  export function wrapUntrusted(text) {
378
- return `<untrusted_external_content>\n${text}\n</untrusted_external_content>`
396
+ // Same defanging as trust-tagging.ts: content cannot close (or re-open) the boundary itself.
397
+ // String scan, not a regex: this is a structural delimiter check, not a natural-language gate.
398
+ const safe = text
399
+ .split('<')
400
+ .map((part, i) => {
401
+ if (i === 0) return part
402
+ let rest = part.trimStart()
403
+ if (rest.startsWith('/')) rest = rest.slice(1).trimStart()
404
+ return rest.toLowerCase().startsWith('untrusted_external_content') ? '&lt;' + part : '<' + part
405
+ })
406
+ .join('')
407
+ return `<untrusted_external_content>\n${safe}\n</untrusted_external_content>`
379
408
  }
380
409
 
381
410
  // Reads the same canonical JSON trust-tagging.ts's own INJECTION_PATTERNS compiles from
@@ -427,17 +456,46 @@ function isPrivateIPv4(ip) {
427
456
  if (a === 172 && b >= 16 && b <= 31) return true // RFC1918
428
457
  if (a === 192 && b === 168) return true // RFC1918
429
458
  if (a === 169 && b === 254) return true // link-local, includes the 169.254.169.254 cloud metadata endpoint
459
+ if (a === 100 && b >= 64 && b <= 127) return true // carrier-grade NAT
460
+ if (a === 198 && (b === 18 || b === 19)) return true // benchmarking
461
+ if (a >= 224) return true // multicast, reserved, broadcast
430
462
  if (a === 0) return true // "this network"
431
463
  return false
432
464
  }
433
465
 
466
+ // Same logic as web-fetch-core.ts's ipv6Groups/isPrivateIPv6 (kept in sync by hand, see file header).
467
+ // Notably URL parsing serialises `[::ffff:127.0.0.1]` as `::ffff:7f00:1`, which the old dotted-quad-only
468
+ // regex never matched — a literal-IP fetch of a mapped loopback address got through.
469
+ function ipv6Groups(ip) {
470
+ let text = ip
471
+ const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)
472
+ if (v4) {
473
+ const octets = v4[1].split('.').map(Number)
474
+ if (octets.some((o) => o > 255)) return null
475
+ text = `${text.slice(0, -v4[1].length)}${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`
476
+ }
477
+ const halves = text.split('::')
478
+ if (halves.length > 2) return null
479
+ const head = halves[0] === '' ? [] : halves[0].split(':')
480
+ const tail = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : []
481
+ const missing = 8 - head.length - tail.length
482
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null
483
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail].map((g) => parseInt(g, 16))
484
+ if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) return null
485
+ return groups
486
+ }
487
+
434
488
  function isPrivateIPv6(ip) {
435
- const normalized = ip.toLowerCase()
436
- if (normalized === '::1' || normalized === '::') return true
437
- if (normalized.startsWith('fe80:')) return true // link-local
438
- if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true // unique local, fc00::/7
439
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized)
440
- if (mapped) return isPrivateIPv4(mapped[1])
489
+ const groups = ipv6Groups(ip.toLowerCase().split('%')[0])
490
+ if (!groups) return true // unparseable: refuse rather than guess
491
+ const embeddedV4 = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`
492
+ const firstFiveZero = groups.slice(0, 5).every((g) => g === 0)
493
+ if (firstFiveZero && groups[5] === 0 && groups[6] === 0 && (groups[7] === 0 || groups[7] === 1)) return true // :: and ::1
494
+ if (firstFiveZero && (groups[5] === 0xffff || groups[5] === 0)) return isPrivateIPv4(embeddedV4) // mapped / compatible
495
+ if (groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(embeddedV4) // NAT64
496
+ if ((groups[0] & 0xffc0) === 0xfe80) return true // link-local fe80::/10
497
+ if ((groups[0] & 0xfe00) === 0xfc00) return true // unique local fc00::/7
498
+ if ((groups[0] & 0xff00) === 0xff00) return true // multicast
441
499
  return false
442
500
  }
443
501
 
@@ -457,6 +515,13 @@ export async function assertPublicHttpUrl(url) {
457
515
  throw new PrivateNetworkTargetError(url, `unsupported scheme "${parsed.protocol}"`)
458
516
  }
459
517
 
518
+ if (parsed.username || parsed.password) {
519
+ throw new PrivateNetworkTargetError(url, 'credentials in the URL are not allowed')
520
+ }
521
+ if (parsed.port && parsed.port !== '80' && parsed.port !== '443') {
522
+ throw new PrivateNetworkTargetError(url, `port "${parsed.port}" is not allowed (only 80/443)`)
523
+ }
524
+
460
525
  const hostname = stripBrackets(parsed.hostname)
461
526
  if (hostname === 'localhost') {
462
527
  throw new PrivateNetworkTargetError(url, '"localhost" resolves to a loopback address')
@@ -495,22 +560,72 @@ function truncateFetchedText(text) {
495
560
  return `${text.slice(0, MAX_FETCH_CHARS)}\n\n[... truncated at ${MAX_FETCH_CHARS} characters; the page is longer than shown here ...]`
496
561
  }
497
562
 
563
+ const FETCH_TIMEOUT_MS = 10_000
564
+ // Streamed-read ceiling (a lying/absent Content-Length must not force an unbounded download); mirrors
565
+ // web-fetch-core.ts's DEFAULT_MAX_FETCH_BYTES.
566
+ const MAX_FETCH_BYTES = 4 * MAX_FETCH_CHARS
567
+
568
+ async function readCappedText(response, signal) {
569
+ const reader = response.body?.getReader()
570
+ if (!reader) return (await response.text()).slice(0, MAX_FETCH_BYTES)
571
+ const chunks = []
572
+ let total = 0
573
+ try {
574
+ while (total < MAX_FETCH_BYTES) {
575
+ if (signal.aborted) throw new Error('aborted')
576
+ const { done, value } = await new Promise((resolve, reject) => {
577
+ const onAbort = () => reject(new Error('aborted'))
578
+ signal.addEventListener('abort', onAbort, { once: true })
579
+ reader.read().then(
580
+ (r) => { signal.removeEventListener('abort', onAbort); resolve(r) },
581
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e) },
582
+ )
583
+ })
584
+ if (done) break
585
+ chunks.push(value)
586
+ total += value.byteLength
587
+ }
588
+ } finally {
589
+ void reader.cancel().catch(() => {})
590
+ }
591
+ const merged = new Uint8Array(Math.min(total, MAX_FETCH_BYTES))
592
+ let offset = 0
593
+ for (const chunk of chunks) {
594
+ const room = merged.length - offset
595
+ if (room <= 0) break
596
+ merged.set(chunk.subarray(0, room), offset)
597
+ offset += Math.min(chunk.length, room)
598
+ }
599
+ return new TextDecoder('utf-8', { fatal: false }).decode(merged)
600
+ }
601
+
498
602
  export async function fetchUrlSafely(url) {
499
603
  let currentUrl = url
500
604
  for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
501
605
  await assertPublicHttpUrl(currentUrl)
502
- const response = await fetch(currentUrl, { redirect: 'manual' })
503
- if (response.status >= 300 && response.status < 400) {
504
- const location = response.headers.get('location')
505
- if (!location) throw new Error(`Redirect response from "${currentUrl}" had no Location header`)
506
- currentUrl = new URL(location, currentUrl).toString()
507
- continue
606
+ // One timer bounds the headers and the body read, as in web-fetch-core.ts.
607
+ const controller = new AbortController()
608
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
609
+ try {
610
+ const response = await fetch(currentUrl, { redirect: 'manual', signal: controller.signal })
611
+ if (response.status >= 300 && response.status < 400) {
612
+ const location = response.headers.get('location')
613
+ await response.body?.cancel().catch(() => {})
614
+ if (!location) throw new Error(`Redirect response from "${currentUrl}" had no Location header`)
615
+ currentUrl = new URL(location, currentUrl).toString()
616
+ continue
617
+ }
618
+ const body = await readCappedText(response, controller.signal)
619
+ // Same rule as web-tools.ts's fetchUrlSafely: a 5xx is a server fault, not page content — an error the model and the
620
+ // parent's ControlState can see (a 4xx page stays content).
621
+ if (response.status >= 500) throw new Error(`HTTP ${response.status} from ${url}: ${body.slice(0, 200).trim()}`)
622
+ return truncateFetchedText(body)
623
+ } catch (err) {
624
+ if (controller.signal.aborted) throw new Error(`Timed out fetching "${currentUrl}" after ${FETCH_TIMEOUT_MS}ms`)
625
+ throw err
626
+ } finally {
627
+ clearTimeout(timer)
508
628
  }
509
- const body = await response.text()
510
- // Same rule as web-tools.ts's fetchUrlSafely: a 5xx is a server fault, not page content — an error the model and the
511
- // parent's ControlState can see (a 4xx page stays content).
512
- if (response.status >= 500) throw new Error(`HTTP ${response.status} from ${url}: ${body.slice(0, 200).trim()}`)
513
- return truncateFetchedText(body)
514
629
  }
515
630
  throw new Error(`Too many redirects while fetching "${url}"`)
516
631
  }
@@ -623,14 +738,24 @@ async function readRemindersFile(remindersFile) {
623
738
 
624
739
  async function writeRemindersFile(remindersFile, reminders) {
625
740
  await mkdir(remindersFile.slice(0, remindersFile.lastIndexOf('/')), { recursive: true })
626
- await writeFile(remindersFile, JSON.stringify({ key: REMINDERS_KEY, value: reminders }), 'utf-8')
741
+ // Write-then-rename so a crash mid-write cannot leave a truncated file that fails to parse on every later read.
742
+ const tmp = `${remindersFile}.tmp-${randomUUID()}`
743
+ await writeFile(tmp, JSON.stringify({ key: REMINDERS_KEY, value: reminders }), 'utf-8')
744
+ await rename(tmp, remindersFile)
627
745
  }
628
746
 
629
- export async function createReminder(remindersFile, rawText) {
630
- const reminders = await readRemindersFile(remindersFile)
631
- const record = { id: randomUUID(), rawText, createdAt: new Date().toISOString(), dueAt: null, done: false }
632
- await writeRemindersFile(remindersFile, [...reminders, record])
633
- return record
747
+ // Concurrent create_reminder calls would each read the same list and the last write would drop the others.
748
+ let reminderChain = Promise.resolve()
749
+
750
+ export function createReminder(remindersFile, rawText) {
751
+ const run = reminderChain.then(async () => {
752
+ const reminders = await readRemindersFile(remindersFile)
753
+ const record = { id: randomUUID(), rawText, createdAt: new Date().toISOString(), dueAt: null, done: false }
754
+ await writeRemindersFile(remindersFile, [...reminders, record])
755
+ return record
756
+ })
757
+ reminderChain = run.then(() => undefined, () => undefined)
758
+ return run
634
759
  }
635
760
 
636
761
  async function main() {
@@ -33,6 +33,8 @@ export declare function assertRealPathInWorkspace(backend: FsBackend, workspaceR
33
33
  * "nothing to diff against" signal `applyPendingAction`'s own `previousContent` uses.
34
34
  */
35
35
  export declare function readCurrentFileContent(backend: FsBackend, workspaceRoot: string, requestedPath: string): Promise<string | undefined>;
36
+ /** A workspace file path in one comparable form: relative to the workspace root, no leading "./" or "/". Absolute paths inside the workspace and relative ones name the same file. */
37
+ export declare function workspaceRelative(workspaceRoot: string, requestedPath: string): string;
36
38
  export declare const READ_FILE_TOOL: ToolDefinition;
37
39
  export declare const LIST_DIRECTORY_TOOL: ToolDefinition;
38
40
  export declare const WRITE_FILE_TOOL: ToolDefinition;
@@ -0,0 +1,9 @@
1
+ /** Directories Aielia keeps inside the workspace for its own state. */
2
+ export declare const AIELIA_STATE_DIRS: readonly [".pending-actions", ".undo-log", ".buildaharness"];
3
+ /**
4
+ * Keeps Aielia's own state directories out of version control when the workspace is inside a git repo, by adding them to
5
+ * `<gitdir>/info/exclude` — the per-clone ignore file git itself provides for this. The user's tracked `.gitignore` is never
6
+ * touched, and a non-git directory is left exactly as it was. Idempotent and best-effort: returns the entries it added and
7
+ * never throws (a read-only .git must not stop the assistant from starting).
8
+ */
9
+ export declare function excludeAieliaStateFromGit(workspaceRoot: string): string[];
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { braveSearch } from './web-search-provider.js';
34
34
  export type { BraveSearchOptions } from './web-search-provider.js';
35
35
  export { buildClaudePrompt, parseClaudeCliOutput, ALREADY_STAGED_ACTION_TOOL, stagedActionInput } from './claude-cli-prompt.js';
36
36
  export type { ParsedClaudeCliOutput } from './claude-cli-prompt.js';
37
- export { stripMcpToolPrefix, summarizeToolStep } from './tool-step.js';
37
+ export { stripMcpToolPrefix, summarizeToolStep, summarizeSettledToolStep } from './tool-step.js';
38
38
  export type { AssistantToolStep } from './tool-step.js';
39
39
  export type { DecomposedTaskSpec } from './decomposition-classifier.js';
40
40
  export { detectHomogeneousBatchList } from './batch-list-detector.js';