mirab 0.0.0 → 0.2.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 (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +223 -3
  3. package/package.json +56 -7
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
  106. package/index.js +0 -1
@@ -0,0 +1,103 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ export const EVENT_NAMES = [
4
+ 'channel.mention',
5
+ 'session',
6
+ 'runner',
7
+ 'push',
8
+ 'pull_request',
9
+ 'pull_request_review',
10
+ 'pull_request_review_comment',
11
+ 'issues',
12
+ 'issue_comment',
13
+ 'workflow_run',
14
+ 'workflow_job',
15
+ 'ping',
16
+ 'merge_queue',
17
+ 'agent_request',
18
+ 'health_changed',
19
+ 'webhook_disabled',
20
+ ] as const
21
+ export const eventName = z.enum(EVENT_NAMES)
22
+ export const hookInput = z.object({
23
+ name: z.literal('web').default('web'),
24
+ config: z.object({
25
+ url: z.string().url(),
26
+ content_type: z.enum(['json', 'form']).default('json'),
27
+ secret: z.string().min(1).max(256),
28
+ insecure_ssl: z.enum(['0']).default('0'),
29
+ }),
30
+ events: z.array(eventName).min(1).max(20).default(['push']),
31
+ active: z.boolean().default(true),
32
+ })
33
+ export const hookPatch = z.object({
34
+ config: hookInput.shape.config.partial().optional(),
35
+ events: z.array(eventName).min(1).max(20).optional(),
36
+ active: z.boolean().optional(),
37
+ })
38
+ export const hookSchema = z.object({
39
+ id: z.number().int(),
40
+ name: z.literal('web'),
41
+ type: z.enum(['Repository', 'Organization']),
42
+ active: z.boolean(),
43
+ events: z.array(eventName),
44
+ config: z.object({
45
+ url: z.string(),
46
+ content_type: z.enum(['json', 'form']),
47
+ insecure_ssl: z.literal('0'),
48
+ }),
49
+ created_at: z.string(),
50
+ updated_at: z.string(),
51
+ url: z.string(),
52
+ ping_url: z.string(),
53
+ deliveries_url: z.string(),
54
+ disabled_reason: z.string().nullable(),
55
+ })
56
+ export const streamEvent = z.object({
57
+ id: z.string(),
58
+ cursor: z.string(),
59
+ event: eventName,
60
+ repository: z.string().nullable(),
61
+ created_at: z.string(),
62
+ payload: z.record(z.string(), z.unknown()),
63
+ })
64
+ export const eventPage = z.object({
65
+ events: z.array(streamEvent),
66
+ cursor: z.string(),
67
+ })
68
+ export const deliverySchema = z.object({
69
+ id: z.number().int().positive(),
70
+ guid: z.string(),
71
+ delivered_at: z.string().nullable(),
72
+ redelivery: z.boolean(),
73
+ duration: z.number(),
74
+ status: z.string(),
75
+ status_code: z.number().nullable(),
76
+ event: eventName,
77
+ action: z.string().nullable(),
78
+ attempts: z.number().int(),
79
+ request: z.object({
80
+ headers: z.record(z.string(), z.string()),
81
+ payload: z.record(z.string(), z.unknown()),
82
+ wire: z.string(),
83
+ }),
84
+ response: z.object({
85
+ headers: z.record(z.string(), z.string()),
86
+ payload: z.string(),
87
+ truncated: z.boolean(),
88
+ }),
89
+ error: z.string().nullable(),
90
+ })
91
+ export type HookInput = z.output<typeof hookInput>
92
+ export type HookPatch = z.output<typeof hookPatch>
93
+ export type Hook = z.output<typeof hookSchema>
94
+ export type StreamEvent = z.output<typeof streamEvent>
95
+ export type Delivery = z.output<typeof deliverySchema>
96
+ export interface Emission {
97
+ created_at?: string
98
+ id: string
99
+ event: (typeof EVENT_NAMES)[number]
100
+ repository: string | null
101
+ audience?: string
102
+ payload: Record<string, unknown>
103
+ }
@@ -0,0 +1,22 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ const text = z.string().max(65536), id = z.string().regex(/^[A-Za-z0-9_.:-]{1,160}$/)
4
+ const at = z.string().datetime(), number = z.number().int().positive().max(1_000_000_000)
5
+ export const provenance = z.object({ forge: z.enum(['github','gitlab']), url: z.string().url(), author: z.string().max(200), id, updatedAt: at, state:z.string().optional(),closedAt:at.nullable().optional(),mergedAt:at.nullable().optional() })
6
+ const base = { number, title: text, body: text, bodyParts:z.number().int().positive().max(128).optional(), createdAt: at, imported: provenance }
7
+ export const importRecord = z.discriminatedUnion('kind', [
8
+ z.object({ kind:z.literal('issue'), ...base, state:z.enum(['open','closed']), labels:z.array(z.string().max(200)).max(100) }),
9
+ z.object({ kind:z.literal('pull'), ...base, state:z.enum(['open','closed','merged']), head:z.string().max(300), base:z.string().max(300), sourceRef:z.string().max(300), labels:z.array(z.string().max(200)).max(100).optional(),sourceHead:z.object({ref:z.string(),sha:z.string().nullable(),repository:z.string().nullable()}).optional(), patchParts:z.number().int().nonnegative().optional(), merged:z.object({ by:text, at, commit:z.string().regex(/^[a-f0-9]{40}$/) }).optional() }),
10
+ z.object({ kind:z.enum(['comment','review']), number, target:z.enum(['issue','pr']), id, body:text, bodyParts:z.number().int().positive().max(128).optional(), createdAt:at, imported:provenance, verdict:z.enum(['approve','changes','comment']).optional(), headOid:z.string().nullable().optional(), path:text.optional(), line:z.number().int().optional(), side:z.enum(['LEFT','RIGHT']).optional() }),
11
+ z.object({ kind:z.literal('label'), id, name:z.string().max(200), color:z.string().max(10), description:text }),
12
+ z.object({kind:z.literal('body'),number,target:z.enum(['issue','pr']),record:z.enum(['issue','pull','comment','review']),id:id.optional(),createdAt:at,version:at,part:z.number().int().nonnegative().max(127),parts:z.number().int().positive().max(128),text:z.string().max(8192)}),
13
+ z.object({ kind:z.literal('patch'), number, part:z.number().int().nonnegative().max(100000), text:z.string().max(16384) }),
14
+ ])
15
+ export type ImportRecord = z.infer<typeof importRecord>
16
+ export const importInput = z.object({ url:z.string().min(1).max(2048), name:z.string().regex(/^[a-z0-9][a-z0-9._-]{0,62}$/i).optional(), private:z.boolean().optional(), mirror:z.boolean().default(false), source_token:z.string().max(16384).optional(), forge:z.enum(['github','gitlab','git']).optional() })
17
+ export const importProgress = z.object({ phase:z.enum(['git','metadata','workflows']), completed:z.number().int().nonnegative(), message:z.string().max(200), checkpoint:z.string().max(2048).optional(),default_branch:z.string().max(300).optional(),internal_prefix:z.string().regex(/^import_*\/pr$/).optional() })
18
+ export const importSummary=z.object({branches:z.number().int().nonnegative(),tags:z.number().int().nonnegative(),commits:z.number().int().nonnegative(),records:z.number().int().nonnegative(),elapsed_ms:z.number().nonnegative()})
19
+ export const importStatus = z.object({ repository:z.string(), source:z.string(), mirror:z.boolean(), state:z.enum(['queued','running','complete','mirroring','failed','cutover']), progress:importProgress, warnings:z.array(z.string()), error:z.string().nullable(), updated_at:z.string(), next_sync:z.string().nullable(),summary:importSummary.optional(),/** Too large for gild's servers: `gild clone` imports it locally. */local:z.boolean().optional() })
20
+ export const importAssignment = z.object({ repository:z.string(), source:z.string(), forge:z.enum(['github','gitlab','git']), token:z.string(), source_token:z.string().optional(), mirror:z.boolean(), checkpoint:z.string() })
21
+ export const importCredentials = z.object({ url:z.string(), token:z.string() })
22
+ export const importBatch = z.object({ records:z.array(importRecord).max(40), progress:importProgress })
@@ -0,0 +1,73 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ // Orchestrator agents: a grant on a repo or org lets an agent spawn child
4
+ // agents there, suspend and resume them, and read every agent's mentions.
5
+ const text = z.string()
6
+ export const agentGrant = z.enum(['pr', 'review', 'merge', 'coordinate', 'queue'])
7
+ export const orchestratorGrant = z.object({
8
+ agent: text,
9
+ /** Where the grant lives: this repo, or the org that owns it. */
10
+ scope: z.enum(['repo', 'org']),
11
+ child_limit: z.number().int(),
12
+ /** Live (not revoked) children it holds, across every repo. */
13
+ children: z.number().int(),
14
+ granted_by: text,
15
+ granted_at: text,
16
+ })
17
+ export const orchestratorList = z.object({
18
+ orchestrators: z.array(orchestratorGrant),
19
+ })
20
+ export const orchestratorInput = z.object({
21
+ /** sponsor/label of an existing agent (an unknown one answers 404). */
22
+ agent: text.min(1).max(80),
23
+ child_limit: z.number().int().min(1).max(100).default(10),
24
+ })
25
+ export const orchestratorRevoked = z.object({
26
+ ok: z.literal(true),
27
+ agent: text,
28
+ /** Every descendant revoked with it. */
29
+ revoked: z.array(text),
30
+ })
31
+ export const childInput = z.object({
32
+ label: text.regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
33
+ publicKey: text.regex(/^ed25519:[A-Za-z0-9+/=]{1,200}$/),
34
+ grants: z.array(agentGrant).max(3).default([]),
35
+ note: text.max(280).optional(),
36
+ })
37
+ export const childAgent = z.object({
38
+ name: text,
39
+ label: text,
40
+ /** The orchestrator that spawned it. */
41
+ sponsor: text,
42
+ /** The person behind the orchestrator, and so behind the child. */
43
+ human: text,
44
+ repo: text,
45
+ grants: z.array(agentGrant),
46
+ suspended: z.boolean(),
47
+ suspended_at: text.nullable(),
48
+ suspended_by: text.nullable(),
49
+ created_at: text,
50
+ revoked_at: text.nullable(),
51
+ })
52
+ export const spawnedChild = childAgent.extend({
53
+ request_id: text,
54
+ scopes: z.array(text),
55
+ /** Returned once; the server keeps only its hash. */
56
+ token: text,
57
+ })
58
+ export const suspension = z.object({
59
+ agent: text,
60
+ suspended: z.boolean(),
61
+ suspended_at: text.nullable(),
62
+ suspended_by: text.nullable(),
63
+ })
64
+ export type ChildAgent = z.output<typeof childAgent>
65
+ /** Agent-facing operations: no token scope, the orchestrator grant (or a
66
+ * person's repo admin) is checked by the orchestrator service itself. */
67
+ export const ORCHESTRATED_OPERATIONS = ['childAgents', 'createChildAgent', 'suspendAgent', 'resumeAgent'] as const
68
+ /** Every operation the orchestrator service executes. */
69
+ export const ORCHESTRATOR_OPERATIONS = [
70
+ ...ORCHESTRATED_OPERATIONS,
71
+ 'repoOrchestrators', 'setRepoOrchestrator', 'deleteRepoOrchestrator',
72
+ 'orgOrchestrators', 'setOrgOrchestrator', 'deleteOrgOrchestrator',
73
+ ] as const
@@ -0,0 +1,25 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ export const runnerRegistration = z.object({
4
+ name: z.string().regex(/^[a-zA-Z0-9][\w.-]{0,63}$/),
5
+ os: z.enum(['darwin', 'macos', 'linux', 'windows', 'win32']),
6
+ arch: z.enum(['x64', 'arm64', 'aarch64', 'amd64']),
7
+ labels: z
8
+ .array(z.string().regex(/^[\w.-]{1,64}$/))
9
+ .max(32)
10
+ .default([]),
11
+ group: z.string().min(1).default('default'),
12
+ })
13
+ export const runnerGroup = z.object({
14
+ id: z.string().min(1),
15
+ name: z.string().trim().min(1).max(64),
16
+ visibility: z.enum(['all', 'selected']),
17
+ allow_public_repositories: z.boolean(),
18
+ repositories: z
19
+ .array(z.string().regex(/^[a-zA-Z0-9][\w.-]*\/[a-zA-Z0-9][\w.-]*$/))
20
+ .max(1000),
21
+ })
22
+ export const groupInput = runnerGroup
23
+ .omit({ id: true })
24
+ .partial()
25
+ .extend({ name: runnerGroup.shape.name })
@@ -0,0 +1,41 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ export const SCOPES = [
3
+ 'repo:read',
4
+ 'repo:write',
5
+ 'channel:write',
6
+ 'issues:write',
7
+ 'pulls:write',
8
+ 'actions',
9
+ 'hooks',
10
+ 'agents',
11
+ 'admin',
12
+ ] as const
13
+ export type Scope = (typeof SCOPES)[number]
14
+ export interface Principal {
15
+ anonymous?: boolean
16
+ id: string
17
+ name: string
18
+ publicKey: string
19
+ scopes: readonly string[] | null
20
+ repos: readonly string[] | null
21
+ }
22
+ export function hasScope(p: Principal, scope: Scope) {
23
+ return p.scopes === null || p.scopes.includes(scope)
24
+ }
25
+ export function permitsRepo(p: Principal, owner: string, repo: string) {
26
+ return (
27
+ p.repos === null ||
28
+ p.repos.some((r) => r.toLowerCase() === `${owner}/${repo}`.toLowerCase())
29
+ )
30
+ }
31
+ export function grantScopes(grants: readonly string[]): Scope[] {
32
+ return [
33
+ 'repo:read',
34
+ ...(grants.includes('pr')
35
+ ? (['repo:write', 'issues:write', 'pulls:write'] as const)
36
+ : []),
37
+ ...(grants.includes('review') || grants.includes('queue')
38
+ ? (['pulls:write'] as const)
39
+ : []),
40
+ ]
41
+ }
@@ -0,0 +1,71 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ const path = z
4
+ .string()
5
+ .min(1)
6
+ .max(1024)
7
+ .refine(
8
+ (p) =>
9
+ !p.startsWith('/') &&
10
+ !p.split('/').includes('..') &&
11
+ !/[\x00-\x1f]/.test(p),
12
+ 'Use a repository-relative path',
13
+ )
14
+ /** Optional live state; no prompts, tool arguments or hook payloads. */
15
+ export const sessionState = z.object({
16
+ status: z.enum(['unknown', 'busy', 'idle', 'waiting', 'tool_start', 'tool_end', 'ended']),
17
+ tool: z.string().regex(/^[a-zA-Z0-9_.:/-]{1,160}$/).optional(),
18
+ last_activity: z.iso.datetime(),
19
+ }).strict()
20
+ export const sessionInput = z
21
+ .object({
22
+ id: z.string().uuid().optional(),
23
+ agent: z.string().max(100).optional(),
24
+ started_at: z.iso.datetime(),
25
+ ended_at: z.iso.datetime().nullable().default(null),
26
+ state: sessionState.optional(),
27
+ model: z.string().min(1).max(120),
28
+ tokens_in: z.number().int().nonnegative().default(0),
29
+ tokens_out: z.number().int().nonnegative().default(0),
30
+ cost: z.number().nonnegative().optional(),
31
+ currency: z
32
+ .string()
33
+ .regex(/^[A-Z]{3}$/)
34
+ .default('USD'),
35
+ commands: z
36
+ .array(
37
+ z.object({
38
+ name: z.string().min(1).max(4096),
39
+ exit_code: z.number().int().nullable(),
40
+ duration_ms: z.number().nonnegative(),
41
+ }),
42
+ )
43
+ .max(200)
44
+ .default([]),
45
+ files: z
46
+ .object({
47
+ read: z.array(path).max(1000).default([]),
48
+ written: z.array(path).max(1000).default([]),
49
+ })
50
+ .default({ read: [], written: [] }),
51
+ notes: z.string().max(16000).default(''),
52
+ })
53
+ .refine(
54
+ (s) => !s.ended_at || Date.parse(s.ended_at) >= Date.parse(s.started_at),
55
+ 'ended_at must follow started_at',
56
+ )
57
+ export const receiptPayload = sessionInput.safeExtend({
58
+ id: z.string().uuid(),
59
+ agent: z.string(),
60
+ repository: z.string(),
61
+ target: z.string(),
62
+ created_at: z.iso.datetime(),
63
+ updated_at: z.iso.datetime(),
64
+ version: z.number().int().positive(),
65
+ closed: z.boolean(),
66
+ })
67
+ export const receiptSchema = receiptPayload.safeExtend({
68
+ reported_by: z.literal('agent').default('agent'),
69
+ })
70
+ export type SessionInput = z.output<typeof sessionInput>
71
+ export type Receipt = z.output<typeof receiptSchema>
@@ -0,0 +1,50 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ const link = z.union([
4
+ z.literal(''),
5
+ z
6
+ .url()
7
+ .max(2048)
8
+ .refine((s) => /^https?:\/\//.test(s), 'Use an http or https URL'),
9
+ ])
10
+ const email = z.union([z.literal(''), z.email().max(254)])
11
+ export const profileSchema = z.object({
12
+ displayName: z.string().max(100),
13
+ publicEmail: email,
14
+ description: z.string().max(1000),
15
+ url: link,
16
+ socialLinks: z.array(link).max(4),
17
+ location: z.string().max(100),
18
+ picture: z.string().nullable(),
19
+ })
20
+ export const profileInput = profileSchema
21
+ .omit({ picture: true })
22
+ .partial()
23
+ .strict()
24
+
25
+ export const renameStatus = z.object({
26
+ job: z
27
+ .object({
28
+ id: z.string(),
29
+ from: z.string(),
30
+ to: z.string(),
31
+ org: z.boolean(),
32
+ orgOwner: z.boolean(),
33
+ phase: z.enum([
34
+ 'waiting',
35
+ 'copying',
36
+ 'publishing',
37
+ 'rebinding',
38
+ 'rollback',
39
+ 'complete',
40
+ 'failed',
41
+ ]),
42
+ repos: z.array(
43
+ z.object({ from: z.string(), to: z.string(), id: z.string() }),
44
+ ),
45
+ copied: z.number(),
46
+ rebound: z.number(),
47
+ error: z.string().nullable(),
48
+ })
49
+ .nullable(),
50
+ })
@@ -0,0 +1,97 @@
1
+ // The station's end of an agent's channel: what other members say goes into
2
+ // the agent's VM over its pty, and what the agent says there comes back as
3
+ // the agent's messages (wire.ts). The VM is untrusted: only well-formed say
4
+ // lines count, they are posted as the agent and nobody else, and an answer
5
+ // may only point at a message the agent was given.
6
+ import type { PtyHandle } from '../isolation/session'
7
+ import type { LocalChannel } from './local'
8
+ import {
9
+ decodeFromAgent,
10
+ decodeToAgent,
11
+ encodeToAgent,
12
+ lineSplitter,
13
+ printable,
14
+ } from './wire'
15
+
16
+ /** Lines of the VM's other output shown per chat. */
17
+ const VM_LINES = 200
18
+
19
+ export function bridgeAgent(o: {
20
+ channel: LocalChannel
21
+ /** The agent's member name. */
22
+ agent: string
23
+ pty: Pick<PtyHandle, 'write' | 'onData'>
24
+ /** Whatever else the VM printed (its errors), for the person's log. */
25
+ log?: (line: string) => void
26
+ }): () => void {
27
+ const given = new Set<string>()
28
+ let off = false
29
+ // Nothing goes in until the guest says it is here: by then its pty is raw,
30
+ // so a message is neither echoed nor cut at the line-editing limit.
31
+ let ready = false
32
+ const queued: string[] = []
33
+ let refused = 0
34
+ let shown = 0
35
+ const post = (
36
+ text: string,
37
+ options: { note?: boolean; answers?: string },
38
+ ) => {
39
+ try {
40
+ o.channel.post(o.agent, text, options)
41
+ if (options.answers) given.delete(options.answers)
42
+ return true
43
+ } catch (e) {
44
+ // Said once, not once per message a flooding VM sends.
45
+ if (refused++ === 0)
46
+ o.log?.(`the agent's messages aren't taken: ${(e as Error).message}`)
47
+ return false
48
+ }
49
+ }
50
+ o.pty.onData(
51
+ lineSplitter((line) => {
52
+ if (off) return
53
+ const said = decodeFromAgent(line)
54
+ if (!said) {
55
+ // The pty echoing a message is neither the agent speaking nor worth
56
+ // showing; anything else is, made safe for a terminal.
57
+ if (line.trim() && !decodeToAgent(line)) {
58
+ // Enough to see what went wrong; never a firehose.
59
+ shown++
60
+ if (shown <= VM_LINES)
61
+ o.log?.(`(vm) ${printable(line.slice(0, 500))}`)
62
+ else if (shown === VM_LINES + 1)
63
+ o.log?.('(vm) … more output from the VM is not shown')
64
+ }
65
+ return
66
+ }
67
+ const answers =
68
+ said.answers && given.has(said.answers) ? said.answers : undefined
69
+ if (!post(said.text, { note: said.note, answers }) && answers)
70
+ // An answer that couldn't be kept still finishes its message, so the
71
+ // chat can end; the station says why, not the VM.
72
+ try {
73
+ o.channel.post(o.agent, "the agent's answer couldn't be kept", {
74
+ note: true,
75
+ answers,
76
+ reserved: true,
77
+ })
78
+ given.delete(answers)
79
+ } catch {}
80
+ if (!ready && said.note) {
81
+ ready = true
82
+ for (const line of queued.splice(0)) o.pty.write(line)
83
+ }
84
+ }),
85
+ )
86
+ const stop = o.channel.onMessage((m) => {
87
+ if (m.from === o.agent || m.note) return
88
+ given.add(m.id)
89
+ const line = encodeToAgent({ id: m.id, from: m.from, text: m.text })
90
+ if (ready) o.pty.write(line)
91
+ else queued.push(line)
92
+ })
93
+ return () => {
94
+ off = true
95
+ stop()
96
+ }
97
+ }
@@ -0,0 +1,154 @@
1
+ // A channel the station hosts (docs/architecture.md section 4; harar section
2
+ // 3b): the chat in `mirab dev` between the person at this terminal and the
3
+ // agent in its VM. The station is the app here: it holds the messages, one
4
+ // JSON line each, in a file only this machine's user can read, and never lets
5
+ // them leave the machine. Membership is fixed when the channel opens; a
6
+ // message is untrusted input from whoever wrote it, and being a member grants
7
+ // nothing else. A channel id is never reused, so a new chat never shows an
8
+ // old one's messages.
9
+ import { randomBytes } from 'node:crypto'
10
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ /** The longest message a channel takes; a longer one is refused, never cut. */
14
+ export const MAX_MESSAGE = 64 * 1024
15
+ /** The most a station keeps for one channel, so a VM that floods it can't
16
+ * fill the disk. */
17
+ export const MAX_CHANNEL = 64 * 1024 * 1024
18
+
19
+ export interface Member {
20
+ /** An actor's harar name, e.g. `gg.addis.human.sami`, `gg.addis.agent.ops`. */
21
+ name: string
22
+ can: 'speak' | 'listen'
23
+ /** Named by this machine alone: nothing (no harar token) vouched for it. */
24
+ unverified?: true
25
+ }
26
+
27
+ export interface Message {
28
+ /** Never reused, and ordered by the millisecond: a ULID. */
29
+ id: string
30
+ from: string
31
+ at: string
32
+ text: string
33
+ /** Said about the chat (the agent reloaded, a turn failed), not in it. */
34
+ note?: true
35
+ /** The message this finishes: its reply, or why there is none. */
36
+ answers?: string
37
+ }
38
+
39
+ export interface LocalChannel {
40
+ /** `<org>.channel.ch-<ulid>` */
41
+ readonly id: string
42
+ readonly members: readonly Member[]
43
+ /** Where the messages are kept. */
44
+ readonly path: string
45
+ post(
46
+ from: string,
47
+ text: string,
48
+ options?: {
49
+ note?: boolean
50
+ answers?: string
51
+ /** The station's own note: it may use the room kept back for it, so
52
+ * whatever fills the channel, a message can still be answered. */
53
+ reserved?: boolean
54
+ },
55
+ ): Message
56
+ messages(): Message[]
57
+ /** Every message posted from now on, in order. */
58
+ onMessage(listener: (message: Message) => void): () => void
59
+ }
60
+
61
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
62
+
63
+ /** 48 bits of milliseconds and 80 random bits, in Crockford base32. */
64
+ export function ulid(now = Date.now()): string {
65
+ let time = ''
66
+ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32))
67
+ time = CROCKFORD[t % 32] + time
68
+ let random = ''
69
+ const bytes = randomBytes(10)
70
+ let bits = 0
71
+ let value = 0
72
+ for (const byte of bytes) {
73
+ value = (value << 8) | byte
74
+ bits += 8
75
+ while (bits >= 5) {
76
+ bits -= 5
77
+ random += CROCKFORD[(value >> bits) & 31]
78
+ }
79
+ value &= (1 << bits) - 1
80
+ }
81
+ return time + random
82
+ }
83
+
84
+ export function openChannel(o: {
85
+ /** The directory the station keeps channels in (private to this user). */
86
+ dir: string
87
+ /** The org whose channel it is: the hosting agent's. */
88
+ org: string
89
+ members: Member[]
90
+ now?: () => Date
91
+ /** Tests make it small. */
92
+ maxBytes?: number
93
+ }): LocalChannel {
94
+ const now = o.now ?? (() => new Date())
95
+ const id = `${o.org}.channel.ch-${ulid(now().getTime()).toLowerCase()}`
96
+ const path = join(o.dir, `${id}.jsonl`)
97
+ const members = o.members.map((m) => ({ ...m }))
98
+ const byName = new Map(members.map((m) => [m.name, m]))
99
+ if (byName.size !== members.length)
100
+ throw new Error('a channel lists each member once')
101
+ mkdirSync(o.dir, { recursive: true, mode: 0o700 })
102
+ // The first line says what the channel is; `flag: 'wx'` because an id is
103
+ // never reused.
104
+ appendFileSync(
105
+ path,
106
+ JSON.stringify({ channel: id, members, opened: now().toISOString() }) +
107
+ '\n',
108
+ { mode: 0o600, flag: 'wx' },
109
+ )
110
+ const listeners = new Set<(message: Message) => void>()
111
+ let kept = 0
112
+ return {
113
+ id,
114
+ members,
115
+ path,
116
+ post(from, text, options) {
117
+ const member = byName.get(from)
118
+ if (!member) throw new Error(`${from} isn't a member of ${id}`)
119
+ if (member.can !== 'speak')
120
+ throw new Error(`${from} only listens in ${id}`)
121
+ if (Buffer.byteLength(text) > MAX_MESSAGE)
122
+ throw new Error(`a message is at most ${MAX_MESSAGE} bytes`)
123
+ const at = now()
124
+ const message: Message = {
125
+ id: ulid(at.getTime()),
126
+ from,
127
+ at: at.toISOString(),
128
+ text,
129
+ ...(options?.note ? { note: true as const } : {}),
130
+ ...(options?.answers ? { answers: options.answers } : {}),
131
+ }
132
+ const line = JSON.stringify(message) + '\n'
133
+ const max = o.maxBytes ?? MAX_CHANNEL
134
+ const room = options?.reserved ? max : max - Math.min(max / 8, 1 << 20)
135
+ if (kept + Buffer.byteLength(line) > room)
136
+ throw new Error(`${id} is full; open a new chat`)
137
+ appendFileSync(path, line)
138
+ kept += Buffer.byteLength(line)
139
+ for (const listener of listeners) listener(message)
140
+ return message
141
+ },
142
+ messages() {
143
+ return readFileSync(path, 'utf8')
144
+ .split('\n')
145
+ .slice(1)
146
+ .filter(Boolean)
147
+ .map((line) => JSON.parse(line) as Message)
148
+ },
149
+ onMessage(listener) {
150
+ listeners.add(listener)
151
+ return () => void listeners.delete(listener)
152
+ },
153
+ }
154
+ }
@@ -0,0 +1,11 @@
1
+ // Generated by gild-site/scripts/generate-api-client.mjs. Edit the site source.
2
+ import { z } from 'zod'
3
+ import { sessionState } from '../api/sessions-contract.ts'
4
+ export const channelState = sessionState
5
+ export type ChannelState = z.output<typeof channelState>
6
+ export function stateLabel(state: ChannelState) {
7
+ if (state.status === 'waiting') return '◐ waiting'
8
+ if (state.status === 'busy' || state.status === 'tool_start') return '● busy' + (state.tool ? ` · ${state.tool}` : '')
9
+ if (['idle', 'tool_end', 'ended'].includes(state.status)) return '○ idle'
10
+ return '○ unknown'
11
+ }