@intentic/sandbox-contract 1.152.0 → 1.154.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 (48) hide show
  1. package/dist/contracts/agent.contract.d.ts +9 -0
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agent.contract.js +2 -1
  4. package/dist/contracts/agent.contract.js.map +1 -1
  5. package/dist/contracts/agents.contract.d.ts +2 -1
  6. package/dist/contracts/agents.contract.d.ts.map +1 -1
  7. package/dist/contracts/agents.contract.js +2 -2
  8. package/dist/contracts/agents.contract.js.map +1 -1
  9. package/dist/contracts/capabilities.contract.d.ts +13 -0
  10. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  11. package/dist/contracts/git.contract.d.ts +109 -4
  12. package/dist/contracts/git.contract.d.ts.map +1 -1
  13. package/dist/contracts/git.contract.js +10 -2
  14. package/dist/contracts/git.contract.js.map +1 -1
  15. package/dist/contracts/push.contract.d.ts +26 -0
  16. package/dist/contracts/push.contract.d.ts.map +1 -0
  17. package/dist/contracts/push.contract.js +9 -0
  18. package/dist/contracts/push.contract.js.map +1 -0
  19. package/dist/contracts/settings.contract.d.ts +18 -18
  20. package/dist/contracts/system.contract.d.ts +4 -4
  21. package/dist/contracts/usage.contract.d.ts +23 -0
  22. package/dist/contracts/usage.contract.d.ts.map +1 -0
  23. package/dist/contracts/usage.contract.js +6 -0
  24. package/dist/contracts/usage.contract.js.map +1 -0
  25. package/dist/contracts/vpn.contract.d.ts +2 -0
  26. package/dist/contracts/vpn.contract.d.ts.map +1 -1
  27. package/dist/events.d.ts +10 -0
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/events.js +3 -1
  30. package/dist/events.js.map +1 -1
  31. package/dist/index.d.ts +202 -23
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/schemas.d.ts +327 -13
  36. package/dist/schemas.d.ts.map +1 -1
  37. package/dist/schemas.js +117 -20
  38. package/dist/schemas.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/contracts/agent.contract.ts +4 -1
  41. package/src/contracts/agents.contract.ts +7 -6
  42. package/src/contracts/git.contract.ts +22 -1
  43. package/src/contracts/push.contract.ts +17 -0
  44. package/src/contracts/usage.contract.ts +10 -0
  45. package/src/events.ts +8 -3
  46. package/src/index.ts +6 -0
  47. package/src/schemas.test.ts +40 -0
  48. package/src/schemas.ts +287 -33
package/src/events.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { AgentSummarySchema, PermissionModeSchema, RateLimitInfoSchema } from "./schemas.js";
2
+ import { AgentProviderSchema, AgentSummarySchema, PermissionModeSchema, RateLimitInfoSchema } from "./schemas.js";
3
3
 
4
4
  // The wire shapes streamed from the daemon's event-iterator procedures. This is their canonical home: the
5
5
  // daemon yields them and the browser client consumes them from the same schema, so the two can't drift (they
@@ -41,8 +41,9 @@ export const PermissionAskSchema = z.object({
41
41
  });
42
42
  export type PermissionAsk = z.infer<typeof PermissionAskSchema>;
43
43
 
44
- // One provider-advertised slash command (an ACP agent's available_commands entry). `hint` is the argument
45
- // placeholder the popover shows after the name.
44
+ // One provider-advertised slash command an ACP agent's available_commands entry, or a Claude Code session's
45
+ // supportedCommands() (its built-ins plus the workspace's own .claude/commands and any plugin/skill commands).
46
+ // `hint` is the argument placeholder the popover shows after the name.
46
47
  export const AgentCommandSchema = z.object({
47
48
  name: z.string(),
48
49
  description: z.string(),
@@ -50,6 +51,10 @@ export const AgentCommandSchema = z.object({
50
51
  });
51
52
  export type AgentCommand = z.infer<typeof AgentCommandSchema>;
52
53
 
54
+ // GET /agent/commands — which provider's last-published list to read; absent = claude, matching AgentTurn.
55
+ export const AgentCommandsQuerySchema = z.object({ agent: AgentProviderSchema.optional() });
56
+ export const AgentCommandsSchema = z.object({ commands: z.array(AgentCommandSchema) });
57
+
53
58
  // One TodoWrite/Task checklist item, surfaced live so the UI shows the agent's plan-of-work (Claude Code style).
54
59
  export const TodoItemSchema = z.object({
55
60
  content: z.string(),
package/src/index.ts CHANGED
@@ -17,11 +17,13 @@ import { kimiContract } from "./contracts/kimi.contract.js";
17
17
  import { logsContract } from "./contracts/logs.contract.js";
18
18
  import { panelsContract } from "./contracts/panels.contract.js";
19
19
  import { portsContract } from "./contracts/ports.contract.js";
20
+ import { pushContract } from "./contracts/push.contract.js";
20
21
  import { secretsContract } from "./contracts/secrets.contract.js";
21
22
  import { sessionsContract } from "./contracts/sessions.contract.js";
22
23
  import { settingsContract } from "./contracts/settings.contract.js";
23
24
  import { systemContract } from "./contracts/system.contract.js";
24
25
  import { translatorContract } from "./contracts/translator.contract.js";
26
+ import { usageContract } from "./contracts/usage.contract.js";
25
27
  import { vpnContract } from "./contracts/vpn.contract.js";
26
28
  import { workspaceContract } from "./contracts/workspace.contract.js";
27
29
 
@@ -44,11 +46,13 @@ export { kimiContract } from "./contracts/kimi.contract.js";
44
46
  export { logsContract } from "./contracts/logs.contract.js";
45
47
  export { panelsContract } from "./contracts/panels.contract.js";
46
48
  export { portsContract } from "./contracts/ports.contract.js";
49
+ export { pushContract } from "./contracts/push.contract.js";
47
50
  export { secretsContract } from "./contracts/secrets.contract.js";
48
51
  export { sessionsContract } from "./contracts/sessions.contract.js";
49
52
  export { settingsContract } from "./contracts/settings.contract.js";
50
53
  export { systemContract } from "./contracts/system.contract.js";
51
54
  export { translatorContract } from "./contracts/translator.contract.js";
55
+ export { usageContract } from "./contracts/usage.contract.js";
52
56
  export { vpnContract } from "./contracts/vpn.contract.js";
53
57
  export { workspaceContract } from "./contracts/workspace.contract.js";
54
58
  export * from "./effects.js";
@@ -85,8 +89,10 @@ export const sandboxContract = {
85
89
  logs: logsContract,
86
90
  panels: panelsContract,
87
91
  ports: portsContract,
92
+ push: pushContract,
88
93
  secrets: secretsContract,
89
94
  system: systemContract,
90
95
  translator: translatorContract,
96
+ usage: usageContract,
91
97
  vpn: vpnContract,
92
98
  };
@@ -0,0 +1,40 @@
1
+ import { expect, test } from "vitest";
2
+ import { SandboxSettingsSchema } from "./schemas.js";
3
+
4
+ /* The settings shape spans a version seam that really moves: the browser ships with the platform, the daemon
5
+ * ships inside the user's sandbox image, so a web build routinely parses a payload from an OLDER daemon. These
6
+ * tests pin the property that makes that survivable — an absent key is that flag's default, not a parse
7
+ * failure — because failing instead reaches the user as a settings page whose switches silently do nothing. */
8
+
9
+ test("a payload from a build that predates a toggle parses, with the new toggle at its default", () => {
10
+ // What a daemon built before the output-cleaner backend switch answers with: every key it knew, and
11
+ // nothing for the one added after it shipped.
12
+ const older = {
13
+ stableSystemPrompt: false,
14
+ skills: [],
15
+ hashlineEdits: false,
16
+ terseOutput: true,
17
+ iqSearch: true,
18
+ outputCleaners: "-cap",
19
+ outputHoldout: 0.1,
20
+ };
21
+ expect(SandboxSettingsSchema.parse(older)).toEqual({ ...older, filterBackend: "native" });
22
+ });
23
+
24
+ test("an empty object is the full default settings object", () => {
25
+ expect(SandboxSettingsSchema.parse({})).toEqual({
26
+ stableSystemPrompt: false,
27
+ skills: [],
28
+ hashlineEdits: false,
29
+ terseOutput: false,
30
+ iqSearch: false,
31
+ outputCleaners: "off",
32
+ outputHoldout: 0,
33
+ filterBackend: "native",
34
+ });
35
+ });
36
+
37
+ test("a key of the wrong type is still a parse failure — tolerance is for absence, not for garbage", () => {
38
+ expect(SandboxSettingsSchema.safeParse({ iqSearch: "yes" }).success).toBe(false);
39
+ expect(SandboxSettingsSchema.safeParse({ outputHoldout: 4 }).success).toBe(false);
40
+ });
package/src/schemas.ts CHANGED
@@ -222,9 +222,11 @@ export type TranslatorAccounts = z.infer<typeof TranslatorAccountsSchema>;
222
222
  // clarifying questions, a per-tool permission prompt — parks on the SAME registry keyed by `requestId`, so
223
223
  // one route resolves all three; the `kind` says which card answered and carries its payload.
224
224
  export const AgentReplySchema = z.discriminatedUnion("kind", [
225
- // ExitPlanMode approval. `mode` is the posture to execute the approved plan in — Claude Code's "yes, and
226
- // auto-accept edits" (acceptEdits) vs "yes, and manually approve edits" (default); it rides back to the SDK
227
- // as a session setMode. Rejection feedback loops back into the model as the denial reason.
225
+ // ExitPlanMode approval. `mode` is the posture to execute the approved plan in — auto-accept edits
226
+ // (acceptEdits), approve each one (default), or run everything (bypassPermissions); it rides back to the SDK
227
+ // as a session setMode. Absent, the turn returns to the posture it STARTED in, so an agent that put itself
228
+ // into plan mode does not cost the user the permissions they granted. Rejection feedback loops back into the
229
+ // model as the denial reason.
228
230
  z.object({
229
231
  kind: z.literal("plan"),
230
232
  requestId: z.string().min(1),
@@ -240,8 +242,9 @@ export const AgentReplySchema = z.discriminatedUnion("kind", [
240
242
  answers: z.record(z.string(), z.array(z.string())).optional(),
241
243
  cancelled: z.boolean().optional(),
242
244
  }),
243
- // A per-tool permission prompt. 'once' allows this call only; 'always' also persists the SDK's suggested
244
- // rules so the same tool stops asking; 'deny' blocks it and feeds `feedback` back as the reason.
245
+ // A per-tool permission prompt. 'once' allows this call only; 'always' allows the whole TOOL for the rest
246
+ // of the session (plus the SDK's own narrower suggestions), which is what the card's label promises;
247
+ // 'deny' blocks it and feeds `feedback` back as the reason.
245
248
  z.object({
246
249
  kind: z.literal("permission"),
247
250
  requestId: z.string().min(1),
@@ -402,16 +405,23 @@ export const SessionTranscriptSchema = z.object({ messages: z.array(SessionTrans
402
405
  // A/B backend switch, so native and rtk can be benchmarked head-to-head.
403
406
  // The booleans default off, skills defaults [] (no skill loaded), outputCleaners defaults "off" (cleaning off),
404
407
  // outputHoldout 0, filterBackend "native" — a fresh sandbox starts with cleaning and iq off until the owner enables them.
405
-
408
+ //
409
+ // Every field carries that default IN THE SCHEMA, so a settings object written before a field existed still
410
+ // parses — the absent key reads as its default. That is not a compatibility layer, it is the seam this shape
411
+ // spans: the browser ships with the platform while the daemon ships inside the user's sandbox image, so a web
412
+ // build is routinely NEWER than the daemon answering it. Requiring the key instead makes the whole settings
413
+ // surface fail to parse the moment a toggle is added — which reaches the user as a page of switches that are
414
+ // silently dead, not as an error. It also means an older on-disk manifest keeps the owner's other picks rather
415
+ // than being discarded whole.
406
416
  export const SandboxSettingsSchema = z.object({
407
- stableSystemPrompt: z.boolean(),
408
- skills: z.array(z.string()),
409
- hashlineEdits: z.boolean(),
410
- terseOutput: z.boolean(),
411
- iqSearch: z.boolean(),
412
- outputCleaners: z.string(),
413
- outputHoldout: z.number().min(0).max(1),
414
- filterBackend: z.enum(["native", "rtk"]),
417
+ stableSystemPrompt: z.boolean().default(false),
418
+ skills: z.array(z.string()).default([]),
419
+ hashlineEdits: z.boolean().default(false),
420
+ terseOutput: z.boolean().default(false),
421
+ iqSearch: z.boolean().default(false),
422
+ outputCleaners: z.string().default("off"),
423
+ outputHoldout: z.number().min(0).max(1).default(0),
424
+ filterBackend: z.enum(["native", "rtk"]).default("native"),
415
425
  });
416
426
  export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
417
427
 
@@ -437,19 +447,40 @@ export const IntenticRunSchema = z.object({ args: z.array(z.string()) });
437
447
 
438
448
  // ---- git ----
439
449
 
450
+ // What a commit records — two shapes, each a real git spelling:
451
+ // all: true ⇒ stage every change in the repo, then commit (`commit -a`; VSCode's "stage all and commit")
452
+ // absent ⇒ commit whatever is staged (plain `git commit`)
453
+ //
454
+ // There is deliberately no `paths`. The index IS git's mechanism for choosing what a commit contains, so a
455
+ // second path-selection channel alongside it can only disagree with it: a `commit --only` over a partially
456
+ // staged file records the WORKTREE content while the row the user picked showed the INDEX content. Staging is
457
+ // the selection; this endpoint only ever records it.
440
458
  export const CommitSchema = RepoParamSchema.extend({
441
459
  message: z.string().min(1),
442
- // Repo-relative paths to commit; absent ⇒ commit everything changed in the repo.
443
- paths: z.array(z.string().min(1)).max(500).optional(),
460
+ all: z.boolean().optional(),
444
461
  });
445
462
  export const DiscardSchema = RepoParamSchema.extend({
446
463
  // Repo-relative paths to discard; absent ⇒ discard every uncommitted change in the repo.
447
464
  paths: z.array(z.string().min(1)).max(500).optional(),
448
465
  });
449
- export const PushSchema = RepoParamSchema.extend({ branch: z.string().min(1) });
466
+ // Index moves. Both are per-path and never touch the worktree, so they are always safe and need no checkpoint.
467
+ export const GitStageSchema = RepoParamSchema.extend({ paths: z.array(z.string().min(1)).max(500) });
468
+ // `branch` defaults to the checked-out one. There is deliberately no "set upstream" flag: the daemon publishes
469
+ // (`push -u`) exactly when the branch has no upstream yet, which is never destructive and is the only way the
470
+ // result is coherent — see pushBranch.
471
+ export const PushSchema = RepoParamSchema.extend({ branch: z.string().min(1).optional() });
450
472
  export const GitFileQuerySchema = RepoParamSchema.extend({ path: z.string().min(1) });
451
473
  export const GitFileWriteSchema = RepoParamSchema.extend({ path: z.string().min(1), content: z.string() });
452
- export const GitFileDiffQuerySchema = RepoParamSchema.extend({ path: z.string().min(1) });
474
+ // Which of the working tree's diffs to open — the same split the Changes panel lists under. A path that is
475
+ // staged AND edited again has genuinely different diffs, so the side is required rather than defaulted: a
476
+ // caller that doesn't say which one it means doesn't know what it is showing.
477
+ // staged ⇒ index vs HEAD (what a bare `git commit` would record)
478
+ // unstaged ⇒ worktree vs index (untracked ⇒ no before side)
479
+ // conflicted ⇒ HEAD vs worktree (what you had vs what the merge left, markers included — an unmerged path
480
+ // has no stage 0, so the index is not a side it can be diffed against)
481
+ export const GitDiffSideSchema = z.enum(["staged", "unstaged", "conflicted"]);
482
+ export type GitDiffSide = z.infer<typeof GitDiffSideSchema>;
483
+ export const GitFileDiffQuerySchema = RepoParamSchema.extend({ path: z.string().min(1), side: GitDiffSideSchema });
453
484
  export const GitStatusSchema = z.object({ branch: z.string(), dirty: z.boolean(), files: z.array(z.string()) });
454
485
  export const GitFilesSchema = z.object({ files: z.array(z.string()) });
455
486
  export const GitFileSchema = z.object({ path: z.string(), content: z.string() });
@@ -460,29 +491,120 @@ export const CommitResultSchema = z.object({ committed: z.boolean() });
460
491
  export const GitChangeSchema = z.object({
461
492
  // Repo-relative path with forward slashes; for "renamed" the NEW path (`from` carries the old one).
462
493
  path: z.string(),
463
- status: z.enum(["added", "modified", "deleted", "renamed", "type-changed"]),
494
+ // "conflicted" is git's unmerged state (`U`), and it is not a kind of modification: the index holds "ours"
495
+ // and "theirs" at stages 2/3 with NO stage 0, so there is nothing a commit could record for this path and
496
+ // git refuses to commit while one exists. It belongs to neither side — see RepoChanges.conflicted.
497
+ status: z.enum(["added", "modified", "deleted", "renamed", "type-changed", "conflicted"]),
464
498
  from: z.string().optional(),
465
499
  additions: z.number().optional(),
466
500
  deletions: z.number().optional(),
467
501
  });
468
502
  export type GitChange = z.infer<typeof GitChangeSchema>;
503
+
504
+ // Where a repo's checked-out branch stands against its remote. Every field is optional-or-zero because every
505
+ // one of them is legitimately absent in a healthy repo: no remote configured yet, a branch created locally and
506
+ // never pushed, a detached HEAD. `ahead` = commits only we have; `behind` = commits only the upstream has,
507
+ // which is meaningful only as of the last fetch — the panel's Fetch button is what refreshes it.
508
+ export const GitRemoteStateSchema = z.object({
509
+ // The remote this branch pushes to: its OWN remote when it tracks one, else the first `git remote` lists
510
+ // (where a never-pushed branch would publish). Those differ in a fork — `origin` and `upstream` both
511
+ // configured — and pushing to the wrong one succeeds while leaving `ahead` stuck. Absent ⇒ no remote.
512
+ remote: z.string().optional(),
513
+ // The checked-out branch; absent on a detached HEAD or an unborn repo.
514
+ branch: z.string().optional(),
515
+ // The tracking ref ("origin/main"); absent ⇒ this branch has no upstream, so the next push publishes it.
516
+ upstream: z.string().optional(),
517
+ ahead: z.number(),
518
+ behind: z.number(),
519
+ });
520
+ export type GitRemoteState = z.infer<typeof GitRemoteStateSchema>;
521
+
522
+ // A ref name (branch/tag), validated structurally — git enforces the rest of ref-name legality itself.
523
+ const RefNameSchema = z
524
+ .string()
525
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/)
526
+ .max(200);
527
+
528
+ // One local branch, for the switcher. `at` is its tip's committer time in ms (the list sorts newest-first).
529
+ export const GitBranchSchema = z.object({
530
+ name: z.string(),
531
+ current: z.boolean(),
532
+ upstream: z.string().optional(),
533
+ ahead: z.number(),
534
+ behind: z.number(),
535
+ // The configured upstream no longer exists on the remote (a merged PR's deleted branch) — distinct from
536
+ // "no upstream", and the signal that this local branch is safe to delete.
537
+ gone: z.boolean().optional(),
538
+ at: z.number(),
539
+ });
540
+ export type GitBranch = z.infer<typeof GitBranchSchema>;
541
+ export const GitBranchesSchema = z.object({ branches: z.array(GitBranchSchema) });
542
+ // Create at `start` (a sha or ref; absent ⇒ HEAD); `checkout` switches to it immediately (`git switch -c`).
543
+ export const GitBranchCreateAtSchema = RepoParamSchema.extend({
544
+ name: RefNameSchema,
545
+ start: z.string().min(1).optional(),
546
+ checkout: z.boolean().optional(),
547
+ });
548
+ // `force` is the deliberate retry after git refuses to drop an unmerged branch.
549
+ export const GitBranchDeleteSchema = RepoParamSchema.extend({ name: RefNameSchema, force: z.boolean().optional() });
550
+
469
551
  export const RepoChangesSchema = z.object({
470
552
  // The {repo} param the per-repo git routes accept: "root" or a repo id (its root-relative dir).
471
553
  repo: z.string(),
472
554
  // Absent on an unborn HEAD (a repo initialized but never committed).
473
555
  branch: z.string().optional(),
474
- changes: z.array(GitChangeSchema),
556
+ // Unmerged paths — a merge, rebase, cherry-pick or pull that git could not finish. First, because until
557
+ // they are resolved nothing else in this repo can be committed at all: git refuses outright. Held apart
558
+ // from the two sides rather than listed in them, because "staged or not" is not a question an unmerged path
559
+ // has an answer to. Staging one (`git add`) is exactly how you tell git it is resolved.
560
+ conflicted: z.array(GitChangeSchema),
561
+ // The two sides git actually models, kept apart because a path can appear on BOTH with different statuses
562
+ // (a staged edit that was then edited again — the classic `MM`). `staged` is index-vs-HEAD: exactly what a
563
+ // bare `git commit` would record. `unstaged` is worktree-vs-index plus untracked files. Each side's
564
+ // additions/deletions describe the diff it is listed under, never a conflation of the two.
565
+ staged: z.array(GitChangeSchema),
566
+ unstaged: z.array(GitChangeSchema),
567
+ // Where this repo stands against its remote; `ahead`/`behind` are 0 with no remote or no upstream.
568
+ remote: GitRemoteStateSchema.optional(),
569
+ // WHICH AGENT PUT IT THERE: repo-relative path → the agent ids that landed it, newest land first. Keyed by
570
+ // PATH rather than carried on each GitChange because a path can be listed on two sides at once (staged and
571
+ // edited again) and its origin is the same fact for both. Only agents can appear here — a main-tree turn,
572
+ // a terminal edit and your own typing never pass through land, so they are simply absent (see
573
+ // agents/origins.ts), which is why the panel badges an agent and says nothing at all for anyone else.
574
+ // Ids, not titles: every client already mirrors the fleet registry and can resolve one to the other.
575
+ origins: z.record(z.string(), z.array(z.string())).optional(),
475
576
  // Why the repo could not be scanned at all, condensed to git's own one-line reason ("fatal: bad object HEAD").
476
577
  // A repo left torn by a canceled or failed upload used to be dropped from the response entirely, so it just
477
- // vanished from the panel with nothing to act on; it now arrives with empty `changes` and this set instead.
578
+ // vanished from the panel with nothing to act on; it now arrives with empty change lists and this set instead.
478
579
  error: z.string().optional(),
479
580
  });
480
581
  export type RepoChanges = z.infer<typeof RepoChangesSchema>;
481
- // The aggregated review set across every repo (root + every discovered repo); a repo appears when it has changes
482
- // or when it failed to scan.
582
+ // The aggregated review set across every repo (root + every discovered repo); a repo appears when it has changes,
583
+ // when it is out of sync with its remote, or when it failed to scan.
483
584
  export const GitChangesSchema = z.object({ repos: z.array(RepoChangesSchema) });
484
585
  export type GitChanges = z.infer<typeof GitChangesSchema>;
485
586
 
587
+ // One file an agent touched, plus whether that change is ALREADY in the main tree. The review lists the
588
+ // agent's CUMULATIVE output (base → worktree), not just the not-yet-landed remainder, because landing is not
589
+ // the end of the review: a clean turn auto-lands within milliseconds, and a list scoped to the remainder shows
590
+ // the user an empty panel for work they never got to look at. `landed` is what still separates the two — the
591
+ // remainder is what "Land now" would apply, and the panel filters on exactly this flag.
592
+ export const AgentChangeSchema = GitChangeSchema.extend({ landed: z.boolean() });
593
+ export type AgentChange = z.infer<typeof AgentChangeSchema>;
594
+
595
+ // An agent conversation-worktree's delta vs its recorded base — deliberately NOT RepoChanges. There is no index
596
+ // side to speak of here: the question a fleet review answers is "what did this agent write", which is one flat
597
+ // set. Sharing the working-tree shape would have forced a meaningless empty `staged` on every
598
+ // row and invited the panel to render a staging affordance that cannot work on a worktree the user never checks out.
599
+ export const AgentRepoChangesSchema = z.object({
600
+ repo: z.string(),
601
+ branch: z.string().optional(),
602
+ changes: z.array(AgentChangeSchema),
603
+ });
604
+ export type AgentRepoChanges = z.infer<typeof AgentRepoChangesSchema>;
605
+ export const AgentChangesSchema = z.object({ repos: z.array(AgentRepoChangesSchema) });
606
+ export type AgentChanges = z.infer<typeof AgentChangesSchema>;
607
+
486
608
  // ---- git history graph (the "Git Graph" view over a repo's real commits) ----
487
609
  // A hex sha (full or git-abbreviated): the only shape the graph ever sends back, so the per-commit routes
488
610
  // constrain to it rather than accepting an arbitrary git revision expression.
@@ -524,11 +646,8 @@ export const GitCommitFileDiffQuerySchema = RepoParamSchema.extend({ sha: ShaSch
524
646
  // merge / rebase / drop) add or replay commits and are auto-checkpointed daemon-side; a conflict aborts and
525
647
  // reports `ok:false` (an expected outcome, not a throw). Checkout and reset move HEAD (reset --hard discards
526
648
  // the worktree) — also auto-checkpointed. A `{repo, sha}` names the target commit for every commit-scoped
527
- // action; a ref name (branch/tag) is validated structurally, git enforces the rest of ref-name legality.
528
- const RefNameSchema = z
529
- .string()
530
- .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/)
531
- .max(200);
649
+ // action; a ref name (branch/tag) is validated structurally, git enforces the rest of ref-name legality
650
+ // (RefNameSchema is declared above, with the branch schemas that first use it).
532
651
  export const GitBranchCreateSchema = RepoParamSchema.extend({ sha: ShaSchema, name: RefNameSchema });
533
652
  export const GitTagCreateSchema = RepoParamSchema.extend({ sha: ShaSchema, name: RefNameSchema });
534
653
  export const GitCheckoutSchema = RepoParamSchema.extend({ ref: RefNameSchema });
@@ -958,6 +1077,21 @@ export type VpnProvider = z.infer<typeof VpnProviderSchema>;
958
1077
 
959
1078
  const autoConnect = z.enum(["on", "off"]).default("on");
960
1079
 
1080
+ // FortiClient wraps every stored credential in its own "EncX <hex>" (older builds: "Enc <hex>") encryption,
1081
+ // keyed to the machine that exported the config — it is NOT recoverable from the file. Pasting one is an easy
1082
+ // mistake to make, because in the XML it sits exactly where the credential belongs, and the failure it causes
1083
+ // is unreadable: phase 1 negotiates fine and IKE then reports "calculated HASH does not match HASH payload",
1084
+ // which says nothing about where the bad value came from. Rejecting it here turns that into a sentence at the
1085
+ // point of entry. (The FortiClient importer already drops these — this catches a hand-paste.)
1086
+ // Exported so the add form can flag it inline on blur instead of only on a rejected round-trip — one
1087
+ // definition of what "this is ciphertext, not a credential" means, shared by the browser and the daemon.
1088
+ export const isForticlientCiphertext = (value: string): boolean => /^Enc[X]?\s+[0-9A-Fa-f]{8,}$/.test(value.trim());
1089
+
1090
+ const notForticlientCiphertext = <T extends z.ZodType<string>>(field: T, label: string): T =>
1091
+ field.refine((value) => !isForticlientCiphertext(value), {
1092
+ message: `That looks like a value copied straight out of a FortiClient config — FortiClient encrypts it with a key tied to the machine that exported it, so it can't be used here. Enter the actual ${label} (ask whoever administers the gateway).`,
1093
+ }) as unknown as T;
1094
+
961
1095
  export const WireguardVpnConfigSchema = z.object({
962
1096
  provider: z.literal("wireguard"),
963
1097
  // The pasted .conf ([Interface] + [Peer]) — it holds the private key, so it's this arm's secret field.
@@ -970,7 +1104,7 @@ export const FortinetVpnConfigSchema = z.object({
970
1104
  server: z.string().min(1),
971
1105
  port: z.coerce.number().int().min(1).max(65535).default(443),
972
1106
  username: z.string().min(1),
973
- password: z.string().min(1),
1107
+ password: notForticlientCiphertext(z.string().min(1), "password"),
974
1108
  // A FortiGate on a self-signed/private-CA certificate: openconnect pins this digest
975
1109
  // ("sha256:…", copied from its own refusal message) instead of trusting a CA. Absent ⇒ normal CA validation.
976
1110
  trustedCert: z.string().min(1).optional(),
@@ -981,14 +1115,25 @@ export const FortinetVpnConfigSchema = z.object({
981
1115
  export const IpsecVpnConfigSchema = z.object({
982
1116
  provider: z.literal("ipsec"),
983
1117
  server: z.string().min(1),
984
- presharedKey: z.string().min(1),
1118
+ presharedKey: notForticlientCiphertext(z.string().min(1), "pre-shared key"),
985
1119
  // The local IKE identity (FortiClient's <localid>) — dial-up FortiGates key their phase-1 selection off it.
986
1120
  localId: z.string().min(1).optional(),
987
1121
  remoteId: z.string().min(1).optional(),
988
1122
  // XAuth (FortiClient's <xauth>) — absent for PSK-only tunnels.
989
1123
  username: z.string().min(1).optional(),
990
- password: z.string().min(1).optional(),
1124
+ password: notForticlientCiphertext(z.string().min(1), "XAuth password").optional(),
991
1125
  ikeVersion: z.enum(["1", "2"]).default("1"),
1126
+ // Perfect Forward Secrecy for phase 2. Must match the gateway EXACTLY: it decides whether a KE payload is
1127
+ // sent in quick mode, and a mismatch fails with NO_PROPOSAL_CHOSEN only after phase 1 and XAuth have
1128
+ // succeeded — which reads like anything but a phase 2 problem. FortiClient stores it as <pfs> under
1129
+ // <ipsec_settings> and defaults it on, so that is the default here too.
1130
+ pfs: z.enum(["on", "off"]).default("on"),
1131
+ // The Diffie-Hellman group, as FortiClient numbers them. ONE field for both phases on purpose: in IKEv1
1132
+ // strongSwan sends a single KE payload in quick mode and the phase-2 group ends up following phase 1, so
1133
+ // offering a phase-1 list that starts with a different group than the gateway wants for phase 2 fails with
1134
+ // NO_PROPOSAL_CHOSEN no matter what the esp= line says. 14 (modp2048) is FortiClient's phase-2 default;
1135
+ // it is <dhgroup> under <ipsec_settings> in an export.
1136
+ dhGroup: z.enum(["2", "5", "14", "15", "16", "19", "20"]).default("14"),
992
1137
  // IKEv1 aggressive mode: insecure by construction, and exactly what FortiGate dial-up with a group PSK
993
1138
  // requires — hence opt-in per connection rather than a global strongSwan setting.
994
1139
  aggressive: z.enum(["on", "off"]).default("on"),
@@ -1140,6 +1285,9 @@ export const ForticlientConnectionSchema = z.object({
1140
1285
  // ipsec-only, and only when the file stored them in the clear.
1141
1286
  localId: z.string().optional(),
1142
1287
  aggressive: z.boolean().optional(),
1288
+ // Phase-2 settings, read from <ipsec_settings> — the pair that decides whether quick mode can succeed.
1289
+ pfs: z.boolean().optional(),
1290
+ dhGroup: z.string().optional(),
1143
1291
  // What the user still has to supply for this connection to dial (always at least the password).
1144
1292
  needs: z.array(z.string()),
1145
1293
  });
@@ -1576,9 +1724,75 @@ export const ActivityStatusSchema = z.object({
1576
1724
  });
1577
1725
  export type ActivityStatus = z.infer<typeof ActivityStatusSchema>;
1578
1726
 
1727
+ // ---- usage: the durable spend ledger ----
1728
+ // One row per attributed turn, appended at turn end and NEVER pruned. This exists because the activity log
1729
+ // can't answer a money question: it prunes to its most recent entries, so a month's spend is unanswerable and
1730
+ // — worse for a cost readout — the totals SHRINK as newer turns evict older ones. The ledger keeps the raw
1731
+ // per-turn facts and the rollup projects them on read, so a new grouping (by day, by model, by conversation)
1732
+ // needs no new storage and no migration.
1733
+ export const UsageTurnSchema = z.object({
1734
+ // Epoch ms at turn end. Kept alongside `day` so a future timezone-aware rollup is a pure change over data
1735
+ // already on disk.
1736
+ at: z.number(),
1737
+ // The UTC calendar day (YYYY-MM-DD) `at` fell in — precomputed so a rollup never re-derives a timezone.
1738
+ day: z.string(),
1739
+ provider: z.string(),
1740
+ // Absent on an env-token turn, which has no account to attribute to (same rule as the activity log).
1741
+ account: z.string().optional(),
1742
+ // The model the turn ACTUALLY ran, resolved past the client's pick and every provider default. Absent only
1743
+ // when the provider's own subscription default served it without the daemon naming one.
1744
+ model: z.string().optional(),
1745
+ harness: z.string(),
1746
+ // The conversation this turn belonged to, so spend can join to a fleet agent. Absent on a main-tree turn.
1747
+ conversationId: z.string().optional(),
1748
+ // The provider's own turn count for the request (a Claude "turn" can be several under the hood), so turns
1749
+ // and cost stay comparable across providers. 1 when the provider reported none.
1750
+ turns: z.number(),
1751
+ inputTokens: z.number(),
1752
+ outputTokens: z.number(),
1753
+ cacheReadTokens: z.number(),
1754
+ cacheCreationTokens: z.number(),
1755
+ costUsd: z.number(),
1756
+ durationMs: z.number(),
1757
+ });
1758
+ export type UsageTurn = z.infer<typeof UsageTurnSchema>;
1759
+
1760
+ // The ledger grouped by day × provider × account × model × harness × conversation — the finest grouping any
1761
+ // dashboard panel needs, and a handful of rows per active day instead of one per turn, so a year of history is
1762
+ // well under a MB over the tunnel. Every panel (spend per day, cost by model, cost by agent, cache hit rate) is
1763
+ // a projection of these.
1764
+ // The conversation is in the KEY, not merely along for the ride, because cost-by-agent has to answer within the
1765
+ // same window as every other panel on the screen. The fleet registry also carries a per-agent total, but only a
1766
+ // cumulative, all-time one — reading it beside a "last 7 days" filter would print an all-time number under a
1767
+ // windowed heading, which is the shrinking-totals bug wearing a different hat.
1768
+ export const UsageRollupRowSchema = z.object({
1769
+ day: z.string(),
1770
+ provider: z.string(),
1771
+ account: z.string().optional(),
1772
+ model: z.string().optional(),
1773
+ harness: z.string(),
1774
+ conversationId: z.string().optional(),
1775
+ turns: z.number(),
1776
+ inputTokens: z.number(),
1777
+ outputTokens: z.number(),
1778
+ cacheReadTokens: z.number(),
1779
+ cacheCreationTokens: z.number(),
1780
+ costUsd: z.number(),
1781
+ durationMs: z.number(),
1782
+ });
1783
+ export type UsageRollupRow = z.infer<typeof UsageRollupRowSchema>;
1784
+ // Inclusive UTC day bounds (YYYY-MM-DD). Both absent ⇒ the whole ledger.
1785
+ export const UsageRollupQuerySchema = z.object({
1786
+ from: z.string().optional(),
1787
+ to: z.string().optional(),
1788
+ });
1789
+ export type UsageRollupQuery = z.infer<typeof UsageRollupQuerySchema>;
1790
+ export const UsageRollupSchema = z.object({ rows: z.array(UsageRollupRowSchema) });
1791
+
1579
1792
  // ---- usage: per-account token/cost totals ----
1580
- // Aggregated from the activity log's turn.completed events (their `usage` extra), grouped by provider+account.
1581
- // Totals cover the retained log window (the log prunes to its most recent entries), not all-time.
1793
+ // The account picker's headroom readout, folded from the ledger above (all-time, not a log window), grouped by
1794
+ // provider+account. `account` is the attribution key, so env-token turns are excluded rather than pooled under
1795
+ // a blank id — an unattributed turn belongs to no account's total.
1582
1796
  export const UsageAccountSchema = z.object({
1583
1797
  provider: z.string(),
1584
1798
  account: z.string(),
@@ -1631,3 +1845,43 @@ export const PresenceReportSchema = z.object({
1631
1845
  path: z.string().optional(),
1632
1846
  });
1633
1847
  export type PresenceReport = z.infer<typeof PresenceReportSchema>;
1848
+
1849
+ // ---- push: web-push notifications to the owner's devices ----
1850
+ // The daemon is the only tier that knows what the agent is doing, so it is the sender. Subscriptions are
1851
+ // per-BROWSER (the endpoint is minted by that browser's push service — Google's, Mozilla's, Apple's), which
1852
+ // is why they live here and not on the platform: the platform is off the command path and would have to be
1853
+ // told about every turn to be useful.
1854
+
1855
+ // A browser's PushSubscription, in the exact shape `web-push` consumes — the browser produces it via
1856
+ // PushManager.subscribe() and the client posts it back verbatim, so the daemon never reshapes it.
1857
+ export const PushSubscriptionSchema = z.object({
1858
+ endpoint: z.string().url(),
1859
+ keys: z.object({
1860
+ // The client's public key and auth secret for payload encryption (RFC 8291). Opaque base64url here.
1861
+ p256dh: z.string().min(1),
1862
+ auth: z.string().min(1),
1863
+ }),
1864
+ });
1865
+ export type PushSubscription = z.infer<typeof PushSubscriptionSchema>;
1866
+
1867
+ // What the service worker renders. `url` is the in-app route the notification opens (the click handler
1868
+ // focuses an existing tab there rather than spawning a new one); `tag` collapses repeats — a second
1869
+ // "waiting on you" for the same conversation REPLACES the first instead of stacking.
1870
+ export const PushNotificationSchema = z.object({
1871
+ title: z.string().min(1),
1872
+ body: z.string(),
1873
+ url: z.string().optional(),
1874
+ tag: z.string().optional(),
1875
+ // Whether the notification stays on screen until dismissed. Set for the "agent is blocked on you" cases,
1876
+ // where a notification that auto-dismisses is a request that silently went unanswered.
1877
+ requireInteraction: z.boolean().optional(),
1878
+ });
1879
+ export type PushNotification = z.infer<typeof PushNotificationSchema>;
1880
+
1881
+ // The VAPID public key a browser needs to subscribe, plus whether this browser's endpoint is already known —
1882
+ // so the settings toggle can render its true state instead of trusting the browser's permission alone (a
1883
+ // granted permission with no server-side row would notify nothing).
1884
+ export const PushConfigSchema = z.object({ publicKey: z.string(), subscribed: z.boolean() });
1885
+ export const PushEndpointSchema = z.object({ endpoint: z.string().url() });
1886
+ // `endpoint` identifies which browser is asking, so `subscribed` can be answered for THIS one.
1887
+ export const PushConfigQuerySchema = z.object({ endpoint: z.string().url().optional() });