@alisio/core 0.5.2 → 0.6.0

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 (169) hide show
  1. package/dist/analysis/janitor.d.ts +48 -0
  2. package/dist/analysis/janitor.js +170 -18
  3. package/dist/analysis/jobs.d.ts +11 -2
  4. package/dist/analysis/jobs.js +13 -0
  5. package/dist/analysis/oci.d.ts +11 -2
  6. package/dist/analysis/oci.js +20 -4
  7. package/dist/analysis/python/sources.js +2 -1
  8. package/dist/analysis/rerun.d.ts +1 -1
  9. package/dist/analysis/status.d.ts +3 -1
  10. package/dist/analysis/status.js +3 -0
  11. package/dist/application.d.ts +28 -2
  12. package/dist/application.js +189 -4
  13. package/dist/changelog/data.js +26 -0
  14. package/dist/config.d.ts +110 -2
  15. package/dist/config.js +188 -6
  16. package/dist/core/call-internals.d.ts +22 -0
  17. package/dist/core/call-internals.js +9 -0
  18. package/dist/core/contracts.d.ts +2 -2
  19. package/dist/core/runner.js +111 -38
  20. package/dist/forge/broker.d.ts +51 -0
  21. package/dist/forge/broker.js +244 -0
  22. package/dist/forge/budget.d.ts +27 -0
  23. package/dist/forge/budget.js +60 -0
  24. package/dist/forge/catalog-view.d.ts +24 -0
  25. package/dist/forge/catalog-view.js +43 -0
  26. package/dist/forge/errors.d.ts +26 -0
  27. package/dist/forge/errors.js +49 -0
  28. package/dist/forge/guides/javascript.d.ts +25 -0
  29. package/dist/forge/guides/javascript.js +90 -0
  30. package/dist/forge/guides/provider.d.ts +42 -0
  31. package/dist/forge/guides/provider.js +96 -0
  32. package/dist/forge/guides/python-environment.d.ts +15 -0
  33. package/dist/forge/guides/python-environment.js +81 -0
  34. package/dist/forge/guides/python.d.ts +38 -0
  35. package/dist/forge/guides/python.js +170 -0
  36. package/dist/forge/javascript/engine.d.ts +44 -0
  37. package/dist/forge/javascript/engine.js +229 -0
  38. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  39. package/dist/forge/javascript/hosts/factory.js +21 -0
  40. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  41. package/dist/forge/javascript/hosts/framing.js +49 -0
  42. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  43. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  44. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  45. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  46. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  47. package/dist/forge/javascript/hosts/process-host.js +160 -0
  48. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  49. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  50. package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
  51. package/dist/forge/javascript/hosts/worker-host.js +101 -0
  52. package/dist/forge/javascript/prelude.d.ts +2 -0
  53. package/dist/forge/javascript/prelude.js +333 -0
  54. package/dist/forge/javascript/probe.d.ts +2 -0
  55. package/dist/forge/javascript/probe.js +53 -0
  56. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  57. package/dist/forge/javascript/runtime-manifest.js +14 -0
  58. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  59. package/dist/forge/javascript/vm-runner.js +301 -0
  60. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  61. package/dist/forge/javascript/wasm-asset.js +8 -0
  62. package/dist/forge/javascript/wasm.d.ts +15 -0
  63. package/dist/forge/javascript/wasm.js +54 -0
  64. package/dist/forge/journal.d.ts +105 -0
  65. package/dist/forge/journal.js +174 -0
  66. package/dist/forge/kernel.d.ts +76 -0
  67. package/dist/forge/kernel.js +338 -0
  68. package/dist/forge/limits.d.ts +38 -0
  69. package/dist/forge/limits.js +73 -0
  70. package/dist/forge/nested.d.ts +96 -0
  71. package/dist/forge/nested.js +214 -0
  72. package/dist/forge/ports.d.ts +120 -0
  73. package/dist/forge/ports.js +1 -0
  74. package/dist/forge/progress.d.ts +24 -0
  75. package/dist/forge/progress.js +76 -0
  76. package/dist/forge/protocol.d.ts +110 -0
  77. package/dist/forge/protocol.js +171 -0
  78. package/dist/forge/python/backend-selector.d.ts +38 -0
  79. package/dist/forge/python/backend-selector.js +111 -0
  80. package/dist/forge/python/backend.d.ts +89 -0
  81. package/dist/forge/python/backend.js +1 -0
  82. package/dist/forge/python/engine.d.ts +84 -0
  83. package/dist/forge/python/engine.js +279 -0
  84. package/dist/forge/python/frame-parser.d.ts +24 -0
  85. package/dist/forge/python/frame-parser.js +107 -0
  86. package/dist/forge/python/frame-transport.d.ts +40 -0
  87. package/dist/forge/python/frame-transport.js +101 -0
  88. package/dist/forge/python/import-scan.d.ts +22 -0
  89. package/dist/forge/python/import-scan.js +133 -0
  90. package/dist/forge/python/managed-backend.d.ts +30 -0
  91. package/dist/forge/python/managed-backend.js +111 -0
  92. package/dist/forge/python/oci-backend.d.ts +27 -0
  93. package/dist/forge/python/oci-backend.js +84 -0
  94. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  95. package/dist/forge/python/pyodide-backend.js +289 -0
  96. package/dist/forge/python/pyodide-install.d.ts +41 -0
  97. package/dist/forge/python/pyodide-install.js +167 -0
  98. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  99. package/dist/forge/python/pyodide-manifest.js +376 -0
  100. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  101. package/dist/forge/python/pyodide-runner-source.js +3 -0
  102. package/dist/forge/python/rss.d.ts +2 -0
  103. package/dist/forge/python/rss.js +33 -0
  104. package/dist/forge/python/scratch-copy.d.ts +6 -0
  105. package/dist/forge/python/scratch-copy.js +61 -0
  106. package/dist/forge/python/settle.d.ts +6 -0
  107. package/dist/forge/python/settle.js +21 -0
  108. package/dist/forge/python/status.d.ts +8 -0
  109. package/dist/forge/python/status.js +15 -0
  110. package/dist/forge/python/wasm-node.d.ts +59 -0
  111. package/dist/forge/python/wasm-node.js +220 -0
  112. package/dist/forge/python/wiring.d.ts +39 -0
  113. package/dist/forge/python/wiring.js +47 -0
  114. package/dist/forge/registry.d.ts +26 -0
  115. package/dist/forge/registry.js +83 -0
  116. package/dist/forge/result.d.ts +58 -0
  117. package/dist/forge/result.js +86 -0
  118. package/dist/forge/routing-rule.d.ts +19 -0
  119. package/dist/forge/routing-rule.js +31 -0
  120. package/dist/forge/runtime.d.ts +64 -0
  121. package/dist/forge/runtime.js +88 -0
  122. package/dist/forge/scratch-files.d.ts +36 -0
  123. package/dist/forge/scratch-files.js +94 -0
  124. package/dist/forge/scratch-provider.d.ts +41 -0
  125. package/dist/forge/scratch-provider.js +79 -0
  126. package/dist/forge/scratch-quota.d.ts +8 -0
  127. package/dist/forge/scratch-quota.js +25 -0
  128. package/dist/forge/selector.d.ts +35 -0
  129. package/dist/forge/selector.js +51 -0
  130. package/dist/forge/session-dir.d.ts +59 -0
  131. package/dist/forge/session-dir.js +239 -0
  132. package/dist/forge/session-path.d.ts +31 -0
  133. package/dist/forge/session-path.js +61 -0
  134. package/dist/forge/slots.d.ts +16 -0
  135. package/dist/forge/slots.js +47 -0
  136. package/dist/forge/state-delta.d.ts +54 -0
  137. package/dist/forge/state-delta.js +99 -0
  138. package/dist/forge/state-machine.d.ts +20 -0
  139. package/dist/forge/state-machine.js +40 -0
  140. package/dist/forge/state-store.d.ts +25 -0
  141. package/dist/forge/state-store.js +50 -0
  142. package/dist/forge/task-log.d.ts +9 -0
  143. package/dist/forge/task-log.js +42 -0
  144. package/dist/forge/verdict.d.ts +20 -0
  145. package/dist/forge/verdict.js +28 -0
  146. package/dist/forge-isolate.d.ts +6 -0
  147. package/dist/forge-isolate.js +6 -0
  148. package/dist/index.d.ts +7 -0
  149. package/dist/index.js +7 -0
  150. package/dist/permissions/decide.d.ts +42 -0
  151. package/dist/permissions/decide.js +24 -0
  152. package/dist/resources/context.d.ts +2 -1
  153. package/dist/resources/context.js +2 -1
  154. package/dist/runtime/access.d.ts +34 -0
  155. package/dist/runtime/access.js +80 -0
  156. package/dist/runtime/process.d.ts +2 -1
  157. package/dist/runtime/process.js +5 -5
  158. package/dist/runtime/store.d.ts +11 -0
  159. package/dist/runtime/store.js +103 -0
  160. package/dist/tools/analysis.d.ts +25 -2
  161. package/dist/tools/analysis.js +315 -267
  162. package/dist/tools/execute.js +6 -7
  163. package/dist/tools/forge-guide.d.ts +6 -0
  164. package/dist/tools/forge-guide.js +60 -0
  165. package/dist/tools/js-run.d.ts +19 -0
  166. package/dist/tools/js-run.js +126 -0
  167. package/dist/tools/standard.d.ts +7 -1
  168. package/dist/tools/standard.js +44 -43
  169. package/package.json +7 -2
@@ -0,0 +1,50 @@
1
+ import { DEFAULT_STATE_TOTAL_BYTES, planCommit, } from "./state-delta.js";
2
+ export class SqliteStateStore {
3
+ #db;
4
+ #now;
5
+ #maxTotalBytes;
6
+ constructor(db, options = {}) {
7
+ this.#db = db;
8
+ this.#now = options.now ?? Date.now;
9
+ const max = options.maxTotalBytes ?? DEFAULT_STATE_TOTAL_BYTES;
10
+ this.#maxTotalBytes = typeof max === "function" ? max : () => max;
11
+ }
12
+ /** Live values and every revision (tombstones included) of a root session. */
13
+ snapshot(rootSessionId) {
14
+ const snapshot = { values: {}, revisions: {} };
15
+ for (const [key, entry] of this.#read(rootSessionId)) {
16
+ snapshot.revisions[key] = entry.revision;
17
+ if (entry.valueJson !== "")
18
+ snapshot.values[key] = JSON.parse(entry.valueJson);
19
+ }
20
+ return snapshot;
21
+ }
22
+ /**
23
+ * Applies a delta against the snapshot it was built from. Throws `STATE_CONFLICT` or
24
+ * `BUDGET_EXCEEDED` without writing anything. Returns the number of rows written.
25
+ */
26
+ commit(rootSessionId, snapshot, ops) {
27
+ return this.#db.transaction(() => {
28
+ const plan = planCommit({
29
+ current: this.#read(rootSessionId),
30
+ snapshot,
31
+ ops,
32
+ maxTotalBytes: this.#maxTotalBytes(),
33
+ });
34
+ const upsert = this.#db.prepare(`INSERT INTO forge_state(root_session_id,key,value_json,revision,updated_at)
35
+ VALUES(?,?,?,?,?)
36
+ ON CONFLICT(root_session_id,key) DO UPDATE SET
37
+ value_json=excluded.value_json, revision=excluded.revision, updated_at=excluded.updated_at`);
38
+ const now = this.#now();
39
+ for (const write of plan.writes)
40
+ upsert.run(rootSessionId, write.key, write.valueJson, write.revision, now);
41
+ return plan.writes.length;
42
+ });
43
+ }
44
+ #read(rootSessionId) {
45
+ const rows = this.#db
46
+ .prepare("SELECT key, value_json, revision FROM forge_state WHERE root_session_id=?")
47
+ .all(rootSessionId);
48
+ return new Map(rows.map((row) => [row.key, { valueJson: row.value_json, revision: Number(row.revision) }]));
49
+ }
50
+ }
@@ -0,0 +1,9 @@
1
+ export declare const TASK_LOG_MAX_BYTES: number;
2
+ export declare class TaskLog {
3
+ #private;
4
+ constructor(path: string, max?: number);
5
+ /** Appends one line (a newline is added). Over the bound, one marker line is written and the rest dropped. */
6
+ write(line: string): void;
7
+ /** Resolves when everything written so far reached the file. */
8
+ flush(): Promise<void>;
9
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The live log of an execution, `<sessionDir>/tasks/<executionId>.log` (spec §9.4): bounded,
3
+ * created `0600`, best effort. The model can `tail` it while a long script runs. A failure to
4
+ * write never affects the execution.
5
+ */
6
+ import { appendFile, mkdir } from "node:fs/promises";
7
+ import { dirname } from "node:path";
8
+ export const TASK_LOG_MAX_BYTES = 256 * 1024;
9
+ export class TaskLog {
10
+ #path;
11
+ #max;
12
+ #written = 0;
13
+ #closed = false;
14
+ #chain = Promise.resolve();
15
+ constructor(path, max = TASK_LOG_MAX_BYTES) {
16
+ this.#path = path;
17
+ this.#max = max;
18
+ }
19
+ /** Appends one line (a newline is added). Over the bound, one marker line is written and the rest dropped. */
20
+ write(line) {
21
+ if (this.#closed)
22
+ return;
23
+ const text = line.endsWith("\n") ? line : `${line}\n`;
24
+ const bytes = Buffer.byteLength(text);
25
+ let data = text;
26
+ if (this.#written + bytes > this.#max) {
27
+ this.#closed = true;
28
+ data = "[log truncated: size limit reached]\n";
29
+ }
30
+ this.#written += Buffer.byteLength(data);
31
+ this.#chain = this.#chain
32
+ .then(async () => {
33
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
34
+ await appendFile(this.#path, data, { mode: 0o600 });
35
+ })
36
+ .catch(() => { });
37
+ }
38
+ /** Resolves when everything written so far reached the file. */
39
+ async flush() {
40
+ await this.#chain;
41
+ }
42
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Who decides how an execution ends (spec §5.2, D5). Pure: no I/O.
3
+ *
4
+ * The host's own stop (a user cancellation, or its deadline) decides the terminal state whatever
5
+ * the guest said afterwards; a guest that merely noticed the same thing first reports the same
6
+ * code; anything else keeps the guest's own outcome. A guest that says TIMEOUT or CANCELLED when
7
+ * the host stopped nothing is only reporting a failure: the terminal states are the host's alone.
8
+ */
9
+ import type { ForgeErrorCode } from "./errors.ts";
10
+ import type { EngineOutcome } from "./ports.ts";
11
+ import type { ForgeStatus } from "./result.ts";
12
+ export interface Verdict {
13
+ status: ForgeStatus;
14
+ error?: {
15
+ code: ForgeErrorCode;
16
+ message: string;
17
+ };
18
+ value?: unknown;
19
+ }
20
+ export declare function verdictOf(outcome: EngineOutcome, stop: "cancelled" | "timeout" | undefined, timeoutMs: number): Verdict;
@@ -0,0 +1,28 @@
1
+ const timedOut = (timeoutMs) => ({
2
+ status: "timed_out",
3
+ error: { code: "TIMEOUT", message: `Execution exceeded ${timeoutMs} ms` },
4
+ });
5
+ const cancelled = () => ({
6
+ status: "cancelled",
7
+ error: { code: "CANCELLED", message: "Execution was cancelled" },
8
+ });
9
+ export function verdictOf(outcome, stop, timeoutMs) {
10
+ if (stop === "timeout")
11
+ return timedOut(timeoutMs);
12
+ if (stop === "cancelled")
13
+ return cancelled();
14
+ if (outcome.kind === "completed")
15
+ return {
16
+ status: "succeeded",
17
+ ...(outcome.value !== undefined ? { value: outcome.value } : {}),
18
+ };
19
+ if (outcome.code === "TIMEOUT" || outcome.code === "CANCELLED")
20
+ return {
21
+ status: "failed",
22
+ error: {
23
+ code: "SCRIPT_RUNTIME",
24
+ message: `the script reported ${outcome.code} on its own: ${outcome.message}`,
25
+ },
26
+ };
27
+ return { status: "failed", error: { code: outcome.code, message: outcome.message } };
28
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The entry the compiled `alisio` binary runs for its hidden `__forge-isolate` command (spec D-21):
3
+ * the body of a Forja process isolate. It is a separate export on purpose, so the CLI loads it
4
+ * without loading the application, the configuration or SQLite.
5
+ */
6
+ export { runProcessIsolate } from "./forge/javascript/hosts/isolate-main.ts";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The entry the compiled `alisio` binary runs for its hidden `__forge-isolate` command (spec D-21):
3
+ * the body of a Forja process isolate. It is a separate export on purpose, so the CLI loads it
4
+ * without loading the application, the configuration or SQLite.
5
+ */
6
+ export { runProcessIsolate } from "./forge/javascript/hosts/isolate-main.js";
package/dist/index.d.ts CHANGED
@@ -42,6 +42,13 @@ export { RunClock } from "./core/run-clock.ts";
42
42
  export { AgentRunner, type CompactionResult, defaultTokenBudget, type RunnerOptions, type RunnerSettingsPatch, type RunOptions, } from "./core/runner.ts";
43
43
  export { CircuitBreaker, type CircuitBreakerOptions, type CircuitState, DECISION_LIMITS, DEFAULT_DECISIONS_CONFIG, type DecisionAttempt, DecisionError, type DecisionMetricEntry, DecisionMetrics, type DecisionMetricsSnapshot, type DecisionOutcome, DecisionRegistry, DecisionService, type DecisionServiceDeps, type DecisionStatus, type DecisionsConfig, formatDecisionStatsSection, formatDecisionsReport, HEALTH_TIMEOUT_MS, type RegisteredDecisionProvider, splitSupported, validateAnswers, validateRequest, } from "./decisions/index.ts";
44
44
  export { type ExtensionConflict, ExtensionRegistry } from "./extensions/registry.ts";
45
+ export { ForgeError } from "./forge/errors.ts";
46
+ export { downloadSize as pyodideDownloadSize, inspectPyodide, installPyodide, PyodideIntegrityError, pyodideDir, } from "./forge/python/pyodide-install.ts";
47
+ export { PYODIDE_MANIFEST, type PyodideManifest, type PyodideProfile, } from "./forge/python/pyodide-manifest.ts";
48
+ export { describePythonBackends } from "./forge/python/status.ts";
49
+ export { createPythonBackends } from "./forge/python/wiring.ts";
50
+ export { describeForge, type ForgeStatus, forgeEngineFor } from "./forge/runtime.ts";
51
+ export { emptyScratch, listScratch, openScratchFile, SCRATCH_LIST_LIMIT, type ScratchFileInfo, } from "./forge/scratch-files.ts";
45
52
  export * from "./goal/index.ts";
46
53
  export { HerdrBridge } from "./integrations/herdr.ts";
47
54
  export { McpConnector, type McpServerInfo, type McpStatus, type McpToolInfo, } from "./mcp/connector.ts";
package/dist/index.js CHANGED
@@ -41,6 +41,13 @@ export { RunClock } from "./core/run-clock.js";
41
41
  export { AgentRunner, defaultTokenBudget, } from "./core/runner.js";
42
42
  export { CircuitBreaker, DECISION_LIMITS, DEFAULT_DECISIONS_CONFIG, DecisionError, DecisionMetrics, DecisionRegistry, DecisionService, formatDecisionStatsSection, formatDecisionsReport, HEALTH_TIMEOUT_MS, splitSupported, validateAnswers, validateRequest, } from "./decisions/index.js";
43
43
  export { ExtensionRegistry } from "./extensions/registry.js";
44
+ export { ForgeError } from "./forge/errors.js";
45
+ export { downloadSize as pyodideDownloadSize, inspectPyodide, installPyodide, PyodideIntegrityError, pyodideDir, } from "./forge/python/pyodide-install.js";
46
+ export { PYODIDE_MANIFEST, } from "./forge/python/pyodide-manifest.js";
47
+ export { describePythonBackends } from "./forge/python/status.js";
48
+ export { createPythonBackends } from "./forge/python/wiring.js";
49
+ export { describeForge, forgeEngineFor } from "./forge/runtime.js";
50
+ export { emptyScratch, listScratch, openScratchFile, SCRATCH_LIST_LIMIT, } from "./forge/scratch-files.js";
44
51
  export * from "./goal/index.js";
45
52
  export { HerdrBridge } from "./integrations/herdr.js";
46
53
  export { McpConnector, } from "./mcp/connector.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The effect gate of a tool call as a pure decision (spec D-25). The runner, `availableTools`, the
3
+ * legacy `execute` tool and the Forja nested-call dispatcher all ask this one function, so a call
4
+ * made from inside a script gets exactly the decision a direct call would get; there is no second
5
+ * permission system.
6
+ *
7
+ * Rules, kept from the runner they were extracted from:
8
+ * - `read` and `internal` always run; `write`, `process` and `external` run when the policy says so.
9
+ * - A tool with no declared effect is `external`.
10
+ * - A capability tool (`analysis.run`) also runs on the `--allow-analysis` flag alone, and on a
11
+ * stored grant; the grant never widens the effect.
12
+ * - Otherwise the call asks, but only for `write`, `process` and `external`, and only when a
13
+ * person can be asked. Anything else is denied.
14
+ *
15
+ * The policy is read by reference on purpose: an "allow for the session" answer widens the very
16
+ * same object, and the next decision must see it.
17
+ */
18
+ import type { ToolDefinition } from "@alisio/sdk";
19
+ import type { Policy } from "../core/contracts.ts";
20
+ export type EffectDecision = {
21
+ kind: "allow";
22
+ via: "effect" | "flag" | "grant";
23
+ } | {
24
+ kind: "ask";
25
+ } | {
26
+ kind: "deny";
27
+ };
28
+ export interface DecideContext {
29
+ /** Whether an interactive approval could be opened for this call. */
30
+ approvals: boolean;
31
+ /** Whether the tool's capability holds a stored grant for the session. */
32
+ capabilityGranted: boolean;
33
+ }
34
+ /** The tool's effect; a tool that declares none is treated as `external`. */
35
+ export declare const effectOf: (tool: Pick<ToolDefinition, "effect">) => string;
36
+ /** Whether the policy lets an effect run without asking. */
37
+ export declare const effectAllowed: (policy: Policy, effect: string) => boolean;
38
+ /** Whether an effect can ever be put to a person (`read` and `internal` never are). */
39
+ export declare const effectAskable: (effect: string) => effect is "write" | "process" | "external";
40
+ /** A tool's capability is pre-allowed by the policy alone (`--allow-analysis`). */
41
+ export declare const capabilityFlagAllowed: (policy: Policy, tool: Pick<ToolDefinition, "capability">) => boolean;
42
+ export declare function decideEffect(policy: Policy, tool: Pick<ToolDefinition, "effect" | "capability">, context: DecideContext): EffectDecision;
@@ -0,0 +1,24 @@
1
+ /** The tool's effect; a tool that declares none is treated as `external`. */
2
+ export const effectOf = (tool) => tool.effect ?? "external";
3
+ /** Whether the policy lets an effect run without asking. */
4
+ export const effectAllowed = (policy, effect) => effect === "read" ||
5
+ effect === "internal" ||
6
+ ((effect === "write" || effect === "process" || effect === "external") && !!policy[effect]);
7
+ /** Whether an effect can ever be put to a person (`read` and `internal` never are). */
8
+ export const effectAskable = (effect) => effect === "write" || effect === "process" || effect === "external";
9
+ /** A tool's capability is pre-allowed by the policy alone (`--allow-analysis`). */
10
+ export const capabilityFlagAllowed = (policy, tool) => tool.capability === "analysis.run" && !!policy.analysis;
11
+ export function decideEffect(policy, tool, context) {
12
+ const effect = effectOf(tool);
13
+ const byEffect = effectAllowed(policy, effect);
14
+ if (tool.capability) {
15
+ // A broad process grant or --allow-analysis: allowed, audited as a flag decision.
16
+ if (byEffect || capabilityFlagAllowed(policy, tool))
17
+ return { kind: "allow", via: "flag" };
18
+ if (context.capabilityGranted)
19
+ return { kind: "allow", via: "grant" };
20
+ }
21
+ else if (byEffect)
22
+ return { kind: "allow", via: "effect" };
23
+ return context.approvals && effectAskable(effect) ? { kind: "ask" } : { kind: "deny" };
24
+ }
@@ -16,7 +16,8 @@ export interface ProjectContextOptions {
16
16
  }
17
17
  export declare class ProjectContext implements ContextSource {
18
18
  readonly workspace: string;
19
- extras: Array<() => Promise<string>>;
19
+ /** Extra system-prompt sections; `sessionId` lets one depend on the session (the scratchpad path). */
20
+ extras: Array<(sessionId?: string) => Promise<string>>;
20
21
  readonly cwd: string;
21
22
  private options;
22
23
  /** Per session: nested instruction file path → fingerprint already attached. */
@@ -18,6 +18,7 @@ const PREAMBLE = "You are Alisio, a coding assistant. Follow the user's task. To
18
18
  const HEADER = "# Project instructions (AGENTS.md)\nFiles are listed from the global and repository root down to the current directory; when they conflict, the closest file wins. Explicit user prompts override these instructions. Instructions for nested directories are attached when you touch paths below them.";
19
19
  export class ProjectContext {
20
20
  workspace;
21
+ /** Extra system-prompt sections; `sessionId` lets one depend on the session (the scratchpad path). */
21
22
  extras = [];
22
23
  cwd;
23
24
  options;
@@ -143,7 +144,7 @@ export class ProjectContext {
143
144
  if (files.length)
144
145
  chunks.push(HEADER, ...this.capped(files));
145
146
  for (const extra of this.extras)
146
- chunks.push(await extra());
147
+ chunks.push(await extra(sessionId));
147
148
  return chunks.join("\n\n");
148
149
  }
149
150
  /** Keeps the closest files within the byte budget; drops or truncates the farthest ones. */
@@ -23,6 +23,15 @@ export interface PathAccessOptions {
23
23
  approve?: ExternalDirectoryHandler;
24
24
  /** Fully locked: declared roots are ignored and no external directory is ever approvable. */
25
25
  readOnly?: boolean;
26
+ /**
27
+ * The verified scratchpad of a session's ROOT session (Forja, spec §9.4), created on first use.
28
+ * When given, that folder is an authorized root: a path inside it needs no directory approval.
29
+ * Absent when Forja is off, so nothing changes; ignored under `--read-only`. A failure to
30
+ * provide it just means "no scratchpad".
31
+ */
32
+ scratchpad?: (session: string) => Promise<string | undefined>;
33
+ /** `forge.scratch.maxBytes`, read on every check: the quota of one scratchpad. */
34
+ scratchMaxBytes?: () => number;
26
35
  }
27
36
  export interface ResolvePathOptions {
28
37
  /** Workspace to resolve a relative path against (child sessions may use a worktree). */
@@ -41,11 +50,36 @@ export declare class PathAccess {
41
50
  private readonly roots;
42
51
  private readonly approve?;
43
52
  private readonly readOnly;
53
+ private readonly scratchpad?;
54
+ private readonly scratchMaxBytes?;
44
55
  /** Session-approved directory subtrees (allow for the session). */
45
56
  private readonly approved;
46
57
  /** Canonicalized roots, memoized; a nonexistent declared root never matches. */
47
58
  private readonly canonical;
48
59
  constructor(options: PathAccessOptions);
60
+ /**
61
+ * `target` made safe inside the session's scratchpad, or undefined when it is not there: outside
62
+ * it, no session, no scratchpad, or a symlink on the way (an escaping link is "outside").
63
+ */
64
+ private scratchTarget;
65
+ /** Whether an already resolved path lies in the session's verified scratchpad (never read-only). */
66
+ isScratchPath(target: string, session?: string): Promise<boolean>;
67
+ /** Scratchpad writers in flight, one queue per scratchpad directory (see `lockScratch`). */
68
+ private scratchLocks;
69
+ /**
70
+ * Serializes the quota check and the write that follows it (L1): two parallel writes that each
71
+ * fit would otherwise both pass the check and together overflow. Resolves to the release. Keyed
72
+ * by the scratchpad directory, so a child session shares the lock of its root session.
73
+ */
74
+ lockScratch(session: string | undefined): Promise<() => void>;
75
+ /**
76
+ * Why a write of about `incomingBytes` into the session's scratchpad must be refused (spec §9.4,
77
+ * FJ-AC-34), or undefined when it fits. Nothing is ever deleted to make room: the model is told,
78
+ * plainly, that the scratchpad is full.
79
+ */
80
+ scratchQuotaProblem(session: string | undefined, incomingBytes: number,
81
+ /** Files the write replaces: their current size is not counted twice. */
82
+ replacing?: readonly string[]): Promise<string | undefined>;
49
83
  /** Extra roots actually in force (always empty under `--read-only`). */
50
84
  get extraRoots(): readonly string[];
51
85
  /** Directory subtrees approved for the session (diagnostics and tests). */
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { lstat, realpath } from "node:fs/promises";
15
15
  import { dirname, resolve } from "node:path";
16
+ import { scratchUsage } from "../forge/session-dir.js";
16
17
  import { inside, outsideRootsMessage, safePath } from "./paths.js";
17
18
  /** Nearest existing ancestor directory of `target`, canonicalized; used as the approval boundary. */
18
19
  async function nearestExistingDirectory(target) {
@@ -38,6 +39,8 @@ export class PathAccess {
38
39
  roots;
39
40
  approve;
40
41
  readOnly;
42
+ scratchpad;
43
+ scratchMaxBytes;
41
44
  /** Session-approved directory subtrees (allow for the session). */
42
45
  approved = new Set();
43
46
  /** Canonicalized roots, memoized; a nonexistent declared root never matches. */
@@ -48,6 +51,80 @@ export class PathAccess {
48
51
  this.readOnly = !!options.readOnly;
49
52
  if (options.approve && !options.readOnly)
50
53
  this.approve = options.approve;
54
+ if (options.scratchpad && !options.readOnly)
55
+ this.scratchpad = options.scratchpad;
56
+ if (options.scratchMaxBytes)
57
+ this.scratchMaxBytes = options.scratchMaxBytes;
58
+ }
59
+ /**
60
+ * `target` made safe inside the session's scratchpad, or undefined when it is not there: outside
61
+ * it, no session, no scratchpad, or a symlink on the way (an escaping link is "outside").
62
+ */
63
+ async scratchTarget(target, session) {
64
+ if (!this.scratchpad || !session)
65
+ return undefined;
66
+ try {
67
+ const base = await this.scratchpad(session);
68
+ if (!base || !inside(base, target))
69
+ return undefined;
70
+ return await safePath(base, target);
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ /** Whether an already resolved path lies in the session's verified scratchpad (never read-only). */
77
+ async isScratchPath(target, session) {
78
+ return (await this.scratchTarget(target, session)) !== undefined;
79
+ }
80
+ /** Scratchpad writers in flight, one queue per scratchpad directory (see `lockScratch`). */
81
+ scratchLocks = new Map();
82
+ /**
83
+ * Serializes the quota check and the write that follows it (L1): two parallel writes that each
84
+ * fit would otherwise both pass the check and together overflow. Resolves to the release. Keyed
85
+ * by the scratchpad directory, so a child session shares the lock of its root session.
86
+ */
87
+ async lockScratch(session) {
88
+ const base = this.scratchpad && session
89
+ ? await this.scratchpad(session).catch(() => undefined)
90
+ : undefined;
91
+ const key = base ?? "";
92
+ const previous = this.scratchLocks.get(key) ?? Promise.resolve();
93
+ let release = () => { };
94
+ const mine = new Promise((resolve) => {
95
+ release = resolve;
96
+ });
97
+ const tail = previous.then(() => mine);
98
+ this.scratchLocks.set(key, tail);
99
+ await previous;
100
+ return () => {
101
+ release();
102
+ if (this.scratchLocks.get(key) === tail)
103
+ this.scratchLocks.delete(key);
104
+ };
105
+ }
106
+ /**
107
+ * Why a write of about `incomingBytes` into the session's scratchpad must be refused (spec §9.4,
108
+ * FJ-AC-34), or undefined when it fits. Nothing is ever deleted to make room: the model is told,
109
+ * plainly, that the scratchpad is full.
110
+ */
111
+ async scratchQuotaProblem(session, incomingBytes,
112
+ /** Files the write replaces: their current size is not counted twice. */
113
+ replacing = []) {
114
+ if (!this.scratchpad || !this.scratchMaxBytes || !session)
115
+ return undefined;
116
+ const base = await this.scratchpad(session).catch(() => undefined);
117
+ if (!base)
118
+ return undefined;
119
+ const max = this.scratchMaxBytes();
120
+ let used = await scratchUsage(base);
121
+ for (const path of replacing)
122
+ used -= (await lstat(path).catch(() => undefined))?.size ?? 0;
123
+ used = Math.max(0, used);
124
+ if (used + incomingBytes <= max)
125
+ return undefined;
126
+ return (`The session scratchpad is full: ${used} of ${max} bytes used and this write needs about ` +
127
+ `${incomingBytes}. Nothing was deleted; remove files you no longer need, or ask the user to clear it.`);
51
128
  }
52
129
  /** Extra roots actually in force (always empty under `--read-only`). */
53
130
  get extraRoots() {
@@ -88,6 +165,9 @@ export class PathAccess {
88
165
  const root = await this.rootFor(target, workspace);
89
166
  if (root)
90
167
  return safePath(root, target);
168
+ const scratch = await this.scratchTarget(target, options.session);
169
+ if (scratch)
170
+ return scratch;
91
171
  if (this.readOnly)
92
172
  throw new Error(outsideRootsMessage(target, "--read-only disables access outside the workspace; run without it to allow it"));
93
173
  const boundary = await nearestExistingDirectory(target);
@@ -27,7 +27,8 @@ export declare function runProcess(command: string, args: string[], options: {
27
27
  timeoutMs?: number;
28
28
  maxBytes?: number;
29
29
  env?: Record<string, string>;
30
- onData?: (chunk: string) => void;
30
+ /** Live text; the second argument says which stream it came from. */
31
+ onData?: (chunk: string, stream: "stdout" | "stderr") => void;
31
32
  /** On cancellation: SIGTERM the process group, then SIGKILL after this grace (default 5s). */
32
33
  killGraceMs?: number;
33
34
  /**
@@ -129,7 +129,7 @@ export async function runProcess(command, args, options) {
129
129
  written += chunk.byteLength;
130
130
  };
131
131
  };
132
- const consume = (stream, logPath) => new Promise((resolveText, reject) => {
132
+ const consume = (stream, name, logPath) => new Promise((resolveText, reject) => {
133
133
  const decoder = new TextDecoder();
134
134
  const log = logger(logPath);
135
135
  let text = "", done = false;
@@ -155,7 +155,7 @@ export async function runProcess(command, args, options) {
155
155
  if (tailMode) {
156
156
  const live = decoder.decode(value, { stream: true });
157
157
  if (live)
158
- options.onData?.(live);
158
+ options.onData?.(live, name);
159
159
  tail.push(value);
160
160
  tailBytes += value.byteLength;
161
161
  while (tailBytes > max && tail.length) {
@@ -178,7 +178,7 @@ export async function runProcess(command, args, options) {
178
178
  const chunk = decoder.decode(slice, { stream: true });
179
179
  text += chunk;
180
180
  if (chunk)
181
- options.onData?.(chunk);
181
+ options.onData?.(chunk, name);
182
182
  if (value.byteLength > remaining) {
183
183
  truncated = true;
184
184
  kill();
@@ -191,8 +191,8 @@ export async function runProcess(command, args, options) {
191
191
  });
192
192
  try {
193
193
  const [stdout, stderr, exitCode] = await Promise.all([
194
- consume(child.stdout, options.logFiles?.stdout),
195
- consume(child.stderr, options.logFiles?.stderr),
194
+ consume(child.stdout, "stdout", options.logFiles?.stdout),
195
+ consume(child.stderr, "stderr", options.logFiles?.stderr),
196
196
  exited,
197
197
  ]);
198
198
  signal.throwIfAborted();
@@ -46,6 +46,17 @@ export declare class SQLiteStore implements SessionStore {
46
46
  runId?: string;
47
47
  startedAt?: number;
48
48
  }>;
49
+ /**
50
+ * Files the nested `write` calls of Forja executions of a session touched (D-31): one entry per
51
+ * journaled call that did not fail, with the tool call (`js_run`/`python_run`) that ran it so the
52
+ * panel can attach the run. `paths` are the absolute paths the gate resolved. Oldest first.
53
+ */
54
+ forgeWriteCalls(session: string): Array<{
55
+ toolCallId: string;
56
+ paths: string[];
57
+ runId?: string;
58
+ startedAt?: number;
59
+ }>;
49
60
  /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
50
61
  callResult(id: string, callId: string): ToolResult | undefined;
51
62
  reconcile(id: string, acknowledge?: boolean): void;
@@ -231,6 +231,86 @@ export class SQLiteStore {
231
231
  add("owner_pid", "INTEGER");
232
232
  this.db.exec("INSERT OR IGNORE INTO schema_migrations VALUES(8)");
233
233
  });
234
+ if (!has(9))
235
+ this.db.transaction(() => {
236
+ // v9 (additive): Forja journal (`forge_executions`, `forge_calls`) and the small JSON state
237
+ // shared across executions (`forge_state`). Only hashes of inputs are stored, never the
238
+ // inputs or results themselves. `owner_pid` lets a restart tell its own dead executions
239
+ // from another live process's.
240
+ this.db.exec(`CREATE TABLE IF NOT EXISTS forge_executions(
241
+ id TEXT PRIMARY KEY,
242
+ root_session_id TEXT NOT NULL,
243
+ session_id TEXT NOT NULL,
244
+ tool_call_id TEXT NOT NULL,
245
+ engine TEXT NOT NULL,
246
+ isolation TEXT NOT NULL,
247
+ selection_reason TEXT NOT NULL,
248
+ status TEXT NOT NULL,
249
+ code_sha256 TEXT NOT NULL,
250
+ started_at INTEGER NOT NULL, ended_at INTEGER,
251
+ metrics_json TEXT,
252
+ error_code TEXT,
253
+ termination_unconfirmed INTEGER NOT NULL DEFAULT 0,
254
+ analysis_execution_id TEXT REFERENCES analysis_executions(id),
255
+ owner_pid INTEGER);
256
+ CREATE INDEX IF NOT EXISTS forge_executions_root
257
+ ON forge_executions(root_session_id, started_at);
258
+ CREATE TABLE IF NOT EXISTS forge_calls(
259
+ execution_id TEXT NOT NULL REFERENCES forge_executions(id),
260
+ call_id TEXT NOT NULL,
261
+ tool_name TEXT NOT NULL,
262
+ effect TEXT NOT NULL,
263
+ status TEXT NOT NULL,
264
+ duration_ms INTEGER,
265
+ input_sha256 TEXT NOT NULL,
266
+ error_code TEXT,
267
+ paths_json TEXT,
268
+ PRIMARY KEY(execution_id, call_id));
269
+ CREATE INDEX IF NOT EXISTS forge_calls_execution
270
+ ON forge_calls(execution_id, call_id);
271
+ CREATE TABLE IF NOT EXISTS forge_state(
272
+ root_session_id TEXT NOT NULL,
273
+ key TEXT NOT NULL,
274
+ value_json TEXT NOT NULL,
275
+ revision INTEGER NOT NULL,
276
+ updated_at INTEGER NOT NULL,
277
+ PRIMARY KEY(root_session_id, key));
278
+ INSERT OR IGNORE INTO schema_migrations VALUES(9);`);
279
+ });
280
+ if (!has(10)) {
281
+ // v10: `analysis_executions.runtime` admits `wasm` (the Pyodide backend). SQLite cannot alter
282
+ // a CHECK, so the table is rebuilt with the official recipe: foreign keys off, copy, drop,
283
+ // rename. Rows, ids and the artifacts that point at them are untouched.
284
+ this.db.exec("PRAGMA foreign_keys=OFF");
285
+ try {
286
+ this.db.transaction(() => {
287
+ this.db.exec(`CREATE TABLE analysis_executions_v10(
288
+ id TEXT PRIMARY KEY,
289
+ session TEXT NOT NULL,
290
+ root_session TEXT NOT NULL,
291
+ workspace TEXT NOT NULL,
292
+ run_id TEXT, call_id TEXT,
293
+ runtime TEXT NOT NULL CHECK(runtime IN ('managed','oci','wasm')),
294
+ status TEXT NOT NULL CHECK(status IN ('running','completed','failed','cancelled','timed_out')),
295
+ script_sha256 TEXT NOT NULL,
296
+ exit_code INTEGER, error TEXT,
297
+ rel_dir TEXT NOT NULL,
298
+ rerun_of TEXT,
299
+ created_at INTEGER NOT NULL, ended_at INTEGER);
300
+ INSERT INTO analysis_executions_v10 SELECT id,session,root_session,workspace,run_id,call_id,
301
+ runtime,status,script_sha256,exit_code,error,rel_dir,rerun_of,created_at,ended_at
302
+ FROM analysis_executions;
303
+ DROP TABLE analysis_executions;
304
+ ALTER TABLE analysis_executions_v10 RENAME TO analysis_executions;
305
+ CREATE INDEX IF NOT EXISTS analysis_executions_session
306
+ ON analysis_executions(root_session, created_at);
307
+ INSERT OR IGNORE INTO schema_migrations VALUES(10);`);
308
+ });
309
+ }
310
+ finally {
311
+ this.db.exec("PRAGMA foreign_keys=ON");
312
+ }
313
+ }
234
314
  }
235
315
  /**
236
316
  * The root of a session (itself for roots), following `parent_id`. Unknown ids are their own
@@ -433,6 +513,29 @@ export class SQLiteStore {
433
513
  ...(row.started_at != null ? { startedAt: Number(row.started_at) } : {}),
434
514
  }));
435
515
  }
516
+ /**
517
+ * Files the nested `write` calls of Forja executions of a session touched (D-31): one entry per
518
+ * journaled call that did not fail, with the tool call (`js_run`/`python_run`) that ran it so the
519
+ * panel can attach the run. `paths` are the absolute paths the gate resolved. Oldest first.
520
+ */
521
+ forgeWriteCalls(session) {
522
+ const rows = this.db
523
+ .prepare(`SELECT e.tool_call_id AS tool_call_id, c.paths_json AS paths_json,
524
+ t.run_id AS run_id, t.started_at AS started_at
525
+ FROM forge_calls c
526
+ JOIN forge_executions e ON e.id = c.execution_id
527
+ LEFT JOIN tool_calls t ON t.session = e.session_id AND t.call_id = e.tool_call_id
528
+ WHERE e.session_id = ? AND c.effect = 'write' AND c.status != 'failed'
529
+ AND c.paths_json IS NOT NULL
530
+ ORDER BY t.started_at, c.rowid`)
531
+ .all(session);
532
+ return rows.map((row) => ({
533
+ toolCallId: row.tool_call_id,
534
+ paths: JSON.parse(row.paths_json),
535
+ ...(row.run_id ? { runId: row.run_id } : {}),
536
+ ...(row.started_at != null ? { startedAt: Number(row.started_at) } : {}),
537
+ }));
538
+ }
436
539
  /** Rich (ui/image) parts persist as part of the serialized result and replay verbatim. */
437
540
  callResult(id, callId) {
438
541
  const row = this.db