@milpa/comal 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/backend/contracts/remote-workspace-v1.schema.json +287 -0
- package/package.json +2 -1
- package/src/cli.mjs +20 -8
- package/src/i18n/es.mjs +9 -1
- package/src/onboarding.mjs +33 -10
- package/src/provider-auth.mjs +1 -0
- package/src/remote-control.mjs +29 -4
- package/src/remote-workspace.mjs +50 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The `@milpa/comal` package includes its JavaScript dependencies; it has no insta
|
|
|
10
10
|
npx @milpa/comal
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
An interactive
|
|
13
|
+
An interactive start reuses saved routing. If routing was never saved but exactly one Comal provider login exists, Comal reconnects with that login and opens the composer. Renewable authorization uses the existing refresh flow. Multiple saved logins show a connection choice. Guided first-run setup appears when no prior connection is available. Press Enter to try the offline demo, choose Codex to authorize the subscription in your browser, or enter an OpenAI-compatible endpoint and let Comal discover its model. A failed connection returns to setup in the same terminal. Successful connection routing is remembered in `~/.config/comal/connection.json` (or `$XDG_CONFIG_HOME/comal/connection.json`); keys are never stored there. Explicit flags, environment settings, profiles and resumed sessions take precedence. Noninteractive invocations retain plain output.
|
|
14
14
|
|
|
15
15
|
npm keeps its cache; Comal's state and credentials remain in your workspace and your configuration, outside that cache. No repository checkout is required.
|
|
16
16
|
|
|
@@ -28,7 +28,7 @@ comal --help
|
|
|
28
28
|
|
|
29
29
|
Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
|
|
30
30
|
|
|
31
|
-
You can pin a version with `npx @milpa/comal@0.1.
|
|
31
|
+
You can pin a version with `npx @milpa/comal@0.1.7` or `npm install --global @milpa/comal@0.1.7`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
|
|
32
32
|
|
|
33
33
|
## Test without account or model
|
|
34
34
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"properties": {
|
|
6
|
+
"version": {
|
|
7
|
+
"const": 1
|
|
8
|
+
},
|
|
9
|
+
"scope": {
|
|
10
|
+
"const": "recorded_work_execution"
|
|
11
|
+
},
|
|
12
|
+
"asked": {
|
|
13
|
+
"const": "unjudged"
|
|
14
|
+
},
|
|
15
|
+
"source": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"session_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"maxLength": 128
|
|
22
|
+
},
|
|
23
|
+
"row": {
|
|
24
|
+
"type": "integer",
|
|
25
|
+
"minimum": 0
|
|
26
|
+
},
|
|
27
|
+
"sha256": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"maxLength": 64,
|
|
30
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
31
|
+
},
|
|
32
|
+
"integrity": {
|
|
33
|
+
"enum": [
|
|
34
|
+
"verified_hash_chain",
|
|
35
|
+
"legacy",
|
|
36
|
+
"invalid"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"required": [
|
|
41
|
+
"session_id",
|
|
42
|
+
"row",
|
|
43
|
+
"sha256",
|
|
44
|
+
"integrity"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"plan": {
|
|
48
|
+
"anyOf": [
|
|
49
|
+
{
|
|
50
|
+
"type": "null"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"type": "object",
|
|
54
|
+
"additionalProperties": false,
|
|
55
|
+
"properties": {
|
|
56
|
+
"version": {
|
|
57
|
+
"type": "integer",
|
|
58
|
+
"minimum": 1
|
|
59
|
+
},
|
|
60
|
+
"objective": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"maxLength": 1000
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"required": [
|
|
66
|
+
"version",
|
|
67
|
+
"objective"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"counts": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"properties": {
|
|
76
|
+
"pending": {
|
|
77
|
+
"type": "integer",
|
|
78
|
+
"minimum": 0
|
|
79
|
+
},
|
|
80
|
+
"in_progress": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"minimum": 0
|
|
83
|
+
},
|
|
84
|
+
"blocked": {
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"minimum": 0
|
|
87
|
+
},
|
|
88
|
+
"done": {
|
|
89
|
+
"type": "integer",
|
|
90
|
+
"minimum": 0
|
|
91
|
+
},
|
|
92
|
+
"needs_verification": {
|
|
93
|
+
"type": "integer",
|
|
94
|
+
"minimum": 0
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": [
|
|
98
|
+
"pending",
|
|
99
|
+
"in_progress",
|
|
100
|
+
"blocked",
|
|
101
|
+
"done",
|
|
102
|
+
"needs_verification"
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
"tasks_total": {
|
|
106
|
+
"type": "integer",
|
|
107
|
+
"minimum": 0
|
|
108
|
+
},
|
|
109
|
+
"tasks": {
|
|
110
|
+
"type": "array",
|
|
111
|
+
"maxItems": 24,
|
|
112
|
+
"items": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"additionalProperties": false,
|
|
115
|
+
"properties": {
|
|
116
|
+
"id": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"maxLength": 64,
|
|
119
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$"
|
|
120
|
+
},
|
|
121
|
+
"title": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"maxLength": 180
|
|
124
|
+
},
|
|
125
|
+
"criterion": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"maxLength": 400
|
|
128
|
+
},
|
|
129
|
+
"status": {
|
|
130
|
+
"enum": [
|
|
131
|
+
"pending",
|
|
132
|
+
"in_progress",
|
|
133
|
+
"blocked",
|
|
134
|
+
"done",
|
|
135
|
+
"needs_verification"
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
"declared_status": {
|
|
139
|
+
"enum": [
|
|
140
|
+
"pending",
|
|
141
|
+
"in_progress",
|
|
142
|
+
"blocked",
|
|
143
|
+
"done",
|
|
144
|
+
"needs_verification"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"phase": {
|
|
148
|
+
"enum": [
|
|
149
|
+
"discovery",
|
|
150
|
+
"implementation",
|
|
151
|
+
"verification"
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
"reason": {
|
|
155
|
+
"type": "string",
|
|
156
|
+
"maxLength": 240
|
|
157
|
+
},
|
|
158
|
+
"held_by": {
|
|
159
|
+
"type": "array",
|
|
160
|
+
"maxItems": 24,
|
|
161
|
+
"items": {
|
|
162
|
+
"type": "string",
|
|
163
|
+
"maxLength": 64,
|
|
164
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$"
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
"checks_total": {
|
|
168
|
+
"type": "integer",
|
|
169
|
+
"minimum": 0
|
|
170
|
+
},
|
|
171
|
+
"checks": {
|
|
172
|
+
"type": "array",
|
|
173
|
+
"maxItems": 4,
|
|
174
|
+
"items": {
|
|
175
|
+
"type": "object",
|
|
176
|
+
"additionalProperties": false,
|
|
177
|
+
"properties": {
|
|
178
|
+
"step_id": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"maxLength": 64,
|
|
181
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$"
|
|
182
|
+
},
|
|
183
|
+
"operation": {
|
|
184
|
+
"type": "string",
|
|
185
|
+
"maxLength": 128
|
|
186
|
+
},
|
|
187
|
+
"status": {
|
|
188
|
+
"enum": [
|
|
189
|
+
"pending",
|
|
190
|
+
"running",
|
|
191
|
+
"failed",
|
|
192
|
+
"refused",
|
|
193
|
+
"interrupted",
|
|
194
|
+
"not_dispatched",
|
|
195
|
+
"stale",
|
|
196
|
+
"passed"
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
"receipt_id": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"maxLength": 36,
|
|
202
|
+
"pattern": "^[a-f0-9-]{36}$"
|
|
203
|
+
},
|
|
204
|
+
"receipt_row": {
|
|
205
|
+
"type": "integer",
|
|
206
|
+
"minimum": 0
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
"required": [
|
|
210
|
+
"step_id",
|
|
211
|
+
"operation",
|
|
212
|
+
"status"
|
|
213
|
+
],
|
|
214
|
+
"dependentRequired": {
|
|
215
|
+
"receipt_id": [
|
|
216
|
+
"receipt_row"
|
|
217
|
+
],
|
|
218
|
+
"receipt_row": [
|
|
219
|
+
"receipt_id"
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
"required": [
|
|
226
|
+
"id",
|
|
227
|
+
"title",
|
|
228
|
+
"criterion",
|
|
229
|
+
"status",
|
|
230
|
+
"declared_status",
|
|
231
|
+
"phase",
|
|
232
|
+
"reason",
|
|
233
|
+
"held_by",
|
|
234
|
+
"checks_total",
|
|
235
|
+
"checks"
|
|
236
|
+
]
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
"receipts": {
|
|
240
|
+
"type": "array",
|
|
241
|
+
"maxItems": 8,
|
|
242
|
+
"items": {
|
|
243
|
+
"type": "object",
|
|
244
|
+
"additionalProperties": false,
|
|
245
|
+
"properties": {
|
|
246
|
+
"id": {
|
|
247
|
+
"type": "string",
|
|
248
|
+
"maxLength": 36,
|
|
249
|
+
"pattern": "^[a-f0-9-]{36}$"
|
|
250
|
+
},
|
|
251
|
+
"operation": {
|
|
252
|
+
"type": "string",
|
|
253
|
+
"maxLength": 128
|
|
254
|
+
},
|
|
255
|
+
"status": {
|
|
256
|
+
"enum": [
|
|
257
|
+
"returned",
|
|
258
|
+
"failed",
|
|
259
|
+
"refused"
|
|
260
|
+
]
|
|
261
|
+
},
|
|
262
|
+
"row": {
|
|
263
|
+
"type": "integer",
|
|
264
|
+
"minimum": 0
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
"required": [
|
|
268
|
+
"id",
|
|
269
|
+
"operation",
|
|
270
|
+
"status",
|
|
271
|
+
"row"
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"required": [
|
|
277
|
+
"version",
|
|
278
|
+
"scope",
|
|
279
|
+
"asked",
|
|
280
|
+
"source",
|
|
281
|
+
"plan",
|
|
282
|
+
"counts",
|
|
283
|
+
"tasks_total",
|
|
284
|
+
"tasks",
|
|
285
|
+
"receipts"
|
|
286
|
+
]
|
|
287
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milpa/comal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Comal: governed operations, execution receipts, and programming assistance in any language",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"backend/verifier/transport.mjs",
|
|
23
23
|
"backend/contracts/artifact-check-v1.schema.json",
|
|
24
24
|
"backend/contracts/remote-commands-v1.json",
|
|
25
|
+
"backend/contracts/remote-workspace-v1.schema.json",
|
|
25
26
|
"governance/chrome-task-v1.json",
|
|
26
27
|
"governance/chrome-task-v2.json",
|
|
27
28
|
"docs",
|
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@ registerMessages(cliSpanish);
|
|
|
4
4
|
import { RemoteHost } from './remote-control.mjs';
|
|
5
5
|
import { remoteCommandHelp, isRemoteCommand } from './remote-commands.mjs';
|
|
6
6
|
import { codeRunDefinition } from './code-run.mjs';
|
|
7
|
-
import { hasProviderSelection, loadConnection, onboard } from './onboarding.mjs';
|
|
7
|
+
import { hasProviderSelection, loadConnection, saveConnection, existingLoginConnection, onboard } from './onboarding.mjs';
|
|
8
8
|
import { stageImage, descriptorForStage, persistHumanImage } from './image-attachments.mjs';
|
|
9
9
|
import { artifactPlugin } from './artifact-tools.mjs';
|
|
10
10
|
import { CloudClient, publishArtifact } from './cloud-client.mjs';
|
|
@@ -37,6 +37,7 @@ import { Operation } from './operations.mjs';
|
|
|
37
37
|
import { validateConnectionCandidate } from './connection-validation.mjs';
|
|
38
38
|
import { PluginRuntime, parsePluginCommand } from './plugins.mjs';
|
|
39
39
|
import { registerWorkOperations, workProjection } from './work-plan.mjs';
|
|
40
|
+
import { projectRemoteWorkspace } from './remote-workspace.mjs';
|
|
40
41
|
import { evidenceOperations } from './work-state.mjs';
|
|
41
42
|
import { auditExecution, auditReport } from './audit.mjs';
|
|
42
43
|
import { comalEnvironment } from './compat.mjs';
|
|
@@ -173,10 +174,15 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
173
174
|
const timing = modelTimingSettings({ timeoutMs: v['model-timeout-ms'], idleTimeoutMs: v['model-idle-timeout-ms'] });
|
|
174
175
|
const chooseSettings = current => sessionProviderSettings({current, profiles, options:v, maxTokens:contextConfig.maxTokens});
|
|
175
176
|
const explicitConnection = hasProviderSelection(v, profiles);
|
|
176
|
-
let savedConnection;
|
|
177
|
+
let savedConnection, recoveredLogin, knownLogins = [];
|
|
177
178
|
if (!explicitConnection) {
|
|
178
|
-
try { savedConnection = await loadConnection();
|
|
179
|
+
try { savedConnection = await loadConnection(); }
|
|
179
180
|
catch (error) { stderr.write(t('Saved connection could not be loaded: ') + error.message + '\n'); }
|
|
181
|
+
if (!savedConnection && !print) {
|
|
182
|
+
const existing = await existingLoginConnection(authManager); knownLogins = existing.logins;
|
|
183
|
+
recoveredLogin = existing.connection; savedConnection = recoveredLogin;
|
|
184
|
+
}
|
|
185
|
+
if (savedConnection) Object.assign(v, savedConnection);
|
|
180
186
|
}
|
|
181
187
|
let settings;
|
|
182
188
|
try { settings = v.demo ? { provider: 'openai' } : chooseSettings(session); }
|
|
@@ -236,11 +242,17 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
236
242
|
};
|
|
237
243
|
let connected;
|
|
238
244
|
if (ui && !explicitConnection && !savedConnection) {
|
|
239
|
-
const result = await onboard({ ui, profiles, authManager, connect, signal: startupSignal });
|
|
245
|
+
const result = await onboard({ ui, profiles, authManager, connect, signal: startupSignal, logins: knownLogins });
|
|
240
246
|
if (!result) return;
|
|
241
247
|
Object.assign(v, result.options); connected = result.connection;
|
|
242
248
|
} else if (!v.demo) {
|
|
243
|
-
try {
|
|
249
|
+
try {
|
|
250
|
+
connected = await connect({});
|
|
251
|
+
if (recoveredLogin) {
|
|
252
|
+
try { await saveConnection({...recoveredLogin, model: connected.provider.model}); }
|
|
253
|
+
catch (error) { ui?.notice(t('Connected, but settings could not be saved: ') + error.message, true); }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
244
256
|
catch (error) {
|
|
245
257
|
if (!ui || startupSignal.aborted) throw error;
|
|
246
258
|
const action = await ui.setupChoice({ title: t('Connection not ready'), description: error.message, signal: startupSignal, items: [
|
|
@@ -248,7 +260,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
248
260
|
{ id: 'exit', label: t('Exit'), description: t('Keep your saved settings.') },
|
|
249
261
|
] });
|
|
250
262
|
if (action !== 'setup') return;
|
|
251
|
-
const result = await onboard({ ui, profiles, authManager, connect: options => connect(options), signal: startupSignal });
|
|
263
|
+
const result = await onboard({ ui, profiles, authManager, connect: options => connect(options), signal: startupSignal, logins: knownLogins });
|
|
252
264
|
if (!result) return;
|
|
253
265
|
// A new choice replaces the failed selector rather than mixing endpoints.
|
|
254
266
|
for (const key of ['provider','base-url','model','profile','auth-provider']) delete v[key];
|
|
@@ -328,7 +340,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
328
340
|
stderr.write(color('90', t("Session {v0} · {v1} calls · {v2} tokens", {v0: event.session_id, v1: event.turns, v2: event.usage.input_tokens + event.usage.output_tokens})) + '\n');
|
|
329
341
|
}
|
|
330
342
|
};
|
|
331
|
-
const house = new House({ cwd, record: event => store.append(event.session_id, event) });
|
|
343
|
+
const house = new House({ cwd, record: async event => { const row=await store.append(event.session_id,event); if(event.session_id===session.id)remoteHost?.invalidateWorkspace(); return row; } });
|
|
332
344
|
if (v.codemode) house.registry.add(codeRunDefinition);
|
|
333
345
|
house.guard('peer-authority',view=>peerGuard.check(view));
|
|
334
346
|
const onPermission = decision => store.append(session.id, { type: 'permission', ...decision });
|
|
@@ -451,7 +463,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal) {
|
|
|
451
463
|
house.registry.add({name:'CloudAccountLogout',description:'Human detaches Remote Control and removes the local Comal account credential.',input_schema:emptyInput,audience:'human',surfaces:['tui'],permission:null,effects:{workspace:'none',external:'write',process:'none',authority:'credential_control'},run:async()=>{await remoteHost?.stop();return cloudClient.logout();}});
|
|
452
464
|
const openRemoteControl=async()=>{
|
|
453
465
|
if(!runtime)throw Error('Remote Control requires an interactive local session.');
|
|
454
|
-
if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),onInput:(input,binding)=>runtime.admitRemote(input,binding),onStop:id=>runtime.stopRemote(id),onNotice:text=>ui.notice(text)});
|
|
466
|
+
if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),onInput:(input,binding)=>runtime.admitRemote(input,binding),onStop:id=>runtime.stopRemote(id),onNotice:text=>ui.notice(text),getWorkspace:async snapshot=>projectRemoteWorkspace(snapshot || await store.load(session.id),house.stateContext())});
|
|
455
467
|
const connection = new AbortController(); authController = connection;
|
|
456
468
|
const cancel = () => remoteHost.stop().catch(() => {});
|
|
457
469
|
connection.signal.addEventListener('abort', cancel, { once: true });
|
package/src/i18n/es.mjs
CHANGED
|
@@ -309,5 +309,13 @@ export default Object.freeze({
|
|
|
309
309
|
"Esc back": "Esc vuelve",
|
|
310
310
|
"Working…": "Trabajando…",
|
|
311
311
|
"Checking…": "Comprobando…",
|
|
312
|
-
"The draft changed while preparing the image. Press Enter again to send the current draft.": "El borrador cambió mientras se preparaba la imagen. Pulsa Enter de nuevo para enviar el borrador actual."
|
|
312
|
+
"The draft changed while preparing the image. Press Enter again to send the current draft.": "El borrador cambió mientras se preparaba la imagen. Pulsa Enter de nuevo para enviar el borrador actual.",
|
|
313
|
+
"Saved logins found. Choose the provider for this session.": "Se encontraron logins guardados. Elige el proveedor para esta sesión.",
|
|
314
|
+
"Use your saved login. No new browser authorization is needed.": "Usa tu login guardado. No necesitas otra autorización en el navegador.",
|
|
315
|
+
"This login expired. Sign in again to reconnect.": "Este login caducó. Inicia sesión otra vez para reconectar.",
|
|
316
|
+
"Connecting {provider}": "Conectando {provider}",
|
|
317
|
+
"Authorize {provider}": "Autoriza {provider}",
|
|
318
|
+
"Sign in again to {provider}": "Vuelve a iniciar sesión con {provider}",
|
|
319
|
+
"Start a new sign-in only if you choose this option.": "Inicia otra autenticación solo si eliges esta opción."
|
|
320
|
+
|
|
313
321
|
});
|
package/src/onboarding.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { t } from './i18n/index.mjs';
|
|
6
|
+
import { AUTH_PROVIDERS } from './provider-auth.mjs';
|
|
6
7
|
|
|
7
8
|
const selectors = ['provider', 'base-url', 'model', 'profile', 'providers-config', 'auth-provider'];
|
|
8
9
|
export function hasProviderSelection(options = {}, profiles = {}, env = process.env) {
|
|
@@ -32,35 +33,57 @@ export async function saveConnection(value, { env = process.env } = {}) {
|
|
|
32
33
|
finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// Local status only: never starts OAuth or imports credentials from another CLI.
|
|
37
|
+
// Authorization can predate connection.json. Multiple known logins are a choice,
|
|
38
|
+
// even if only one is currently usable; do not guess which account is intended.
|
|
39
|
+
export async function existingLoginConnection(authManager) {
|
|
40
|
+
const results = await Promise.allSettled(AUTH_PROVIDERS.map(provider => authManager.status(provider.id)));
|
|
41
|
+
const logins = results.flatMap((result, index) => {
|
|
42
|
+
if (result.status !== 'fulfilled' || !['authenticated', 'configured', 'expired'].includes(result.value?.status)) return [];
|
|
43
|
+
const {status, canRefresh} = result.value;
|
|
44
|
+
return [{id: AUTH_PROVIDERS[index].id, label: AUTH_PROVIDERS[index].label,
|
|
45
|
+
usable: status !== 'expired' || canRefresh === true}];
|
|
46
|
+
});
|
|
47
|
+
return {logins, connection: logins.length === 1 && logins[0].usable ? {'auth-provider': logins[0].id} : null};
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
// The terminal stays owned by the caller. Only a successfully validated connection
|
|
36
51
|
// is remembered; credentials live in AuthManager/environment, never in this file.
|
|
37
|
-
export async function onboard({ ui, profiles, authManager, connect, signal, env = process.env }) {
|
|
52
|
+
export async function onboard({ ui, profiles, authManager, connect, signal, env = process.env, logins = [] }) {
|
|
38
53
|
while (!signal?.aborted && !ui.closed) {
|
|
39
|
-
const
|
|
40
|
-
|
|
54
|
+
const returning = logins.length > 0;
|
|
55
|
+
const demo = {id: 'demo', label: t('Try the offline demo'), description: t('No account or model. A scripted tool cycle; no inference.')};
|
|
56
|
+
const choice = await ui.setupChoice({ title: t(returning ? 'Choose a connection' : 'Your first turn'), description: t(returning ? 'Saved logins found. Choose the provider for this session.' : 'Choose how to start. Stay here while Comal connects.'), signal, items: [
|
|
57
|
+
...(!returning ? [demo] : []),
|
|
58
|
+
...logins.map(login => ({id: 'saved:' + login.id, label: login.label, description: t(login.usable ? 'Use your saved login. No new browser authorization is needed.' : 'This login expired. Sign in again to reconnect.')})),
|
|
59
|
+
...logins.filter(login => login.usable).map(login => ({id: 'login:' + login.id, label: t('Sign in again to {provider}', {provider: login.label}), description: t('Start a new sign-in only if you choose this option.')})),
|
|
41
60
|
...profiles.profiles.map(profile => ({ id: 'profile:' + profile.name, label: profile.name, description: `${profile.provider} · ${profile.model || t('discover model')}` })),
|
|
42
|
-
{ id: 'codex', label: t('Connect Codex'), description: t('Sign in with your existing subscription in the browser.') },
|
|
61
|
+
...(!logins.some(login => login.id === 'codex') ? [{ id: 'codex', label: t('Connect Codex'), description: t('Sign in with your existing subscription in the browser.') }] : []),
|
|
43
62
|
{ id: 'server', label: t('Connect a model server'), description: t('OpenAI-compatible endpoint. API keys stay in your environment.') },
|
|
63
|
+
...(returning ? [demo] : []),
|
|
44
64
|
] });
|
|
45
65
|
if (!choice || signal?.aborted || ui.closed) return null;
|
|
46
66
|
if (choice === 'demo') { ui.clearSetup(); return { options: { demo: true } }; }
|
|
47
67
|
let options;
|
|
48
68
|
try {
|
|
49
69
|
if (choice.startsWith('profile:')) options = { profile: choice.slice(8) };
|
|
50
|
-
else if (choice
|
|
51
|
-
|
|
70
|
+
else if (choice.startsWith('saved:') && logins.some(login => 'saved:' + login.id === choice && login.usable)) options = {'auth-provider': choice.slice(6)};
|
|
71
|
+
else if (choice === 'codex' || choice.startsWith('saved:') || choice.startsWith('login:')) {
|
|
72
|
+
const target = choice === 'codex' ? 'codex' : logins.find(login => ['saved:' + login.id, 'login:' + login.id].includes(choice))?.id;
|
|
73
|
+
if (!target) throw Error('Unknown saved login.');
|
|
74
|
+
ui.setupStatus({ title: target === 'codex' ? t('Connecting Codex') : t('Connecting {provider}', {provider: target}), description: t('Waiting for browser authorization. Esc cancels this connection.') });
|
|
52
75
|
const operation = new AbortController();
|
|
53
76
|
ui.callbacks.onSetupCancel = () => operation.abort();
|
|
54
77
|
try {
|
|
55
|
-
const status = await authManager.status(
|
|
78
|
+
const status = await authManager.status(target);
|
|
56
79
|
operation.signal.throwIfAborted(); signal?.throwIfAborted();
|
|
57
|
-
if (
|
|
80
|
+
if (choice.startsWith('login:') || !['authenticated', 'configured'].includes(status.status)) await authManager.login(target, { signal: signal ? AbortSignal.any([signal, operation.signal]) : operation.signal, onEvent: event => {
|
|
58
81
|
if (operation.signal.aborted || signal?.aborted || ui.closed) return;
|
|
59
|
-
if (event.type === 'device_code') ui.setupStatus({ title: t('Authorize Codex'), description: `${event.verification_uri}\n\n${t('Code')}: ${event.user_code}\n\n${t('Approve only if you started this connection. Esc cancels.')}` });
|
|
82
|
+
if (event.type === 'device_code') ui.setupStatus({ title: target === 'codex' ? t('Authorize Codex') : t('Authorize {provider}', {provider: target}), description: `${event.verification_uri}\n\n${t('Code')}: ${event.user_code}\n\n${t('Approve only if you started this connection. Esc cancels.')}` });
|
|
60
83
|
} });
|
|
61
84
|
operation.signal.throwIfAborted(); signal?.throwIfAborted();
|
|
62
85
|
} finally { delete ui.callbacks.onSetupCancel; }
|
|
63
|
-
options = { 'auth-provider':
|
|
86
|
+
options = { 'auth-provider': target };
|
|
64
87
|
} else {
|
|
65
88
|
const endpoint = await ui.setupInput({ title: t('Model server'), description: t('Endpoint URL. No API key or password in the URL.'), value: 'http://127.0.0.1:8080/v1', signal, validate: text => { try { normalizeConnection({ provider: 'openai', 'base-url': text }); return true; } catch (error) { return error.message; } } });
|
|
66
89
|
if (!endpoint) continue;
|
package/src/provider-auth.mjs
CHANGED
|
@@ -99,6 +99,7 @@ export class AuthManager {
|
|
|
99
99
|
const p = definition(name), record = await this._read(p.id);
|
|
100
100
|
return { provider: p.id, method: record?.method || p.method, subscription: p.subscription,
|
|
101
101
|
status: !record ? 'logged_out' : record.expiresAt && record.expiresAt <= this.now() ? 'expired' : record.method === 'device_code' ? 'authenticated' : 'configured',
|
|
102
|
+
...(record?.method === 'device_code' ? {canRefresh: secret(record.refreshToken)} : {}),
|
|
102
103
|
...(record?.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
103
104
|
...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}), documentation: p.documentation,
|
|
104
105
|
...(p.experimental ? { experimental: true, note: 'OAuth compatible with the public Kimi CLI code; the documented route for external clients is a Kimi Code key (--api-key-env KIMI_CODE_API_KEY).' } : {}),
|
package/src/remote-control.mjs
CHANGED
|
@@ -26,8 +26,8 @@ const cancelled = () => Object.assign(new Error('Remote Control connection cance
|
|
|
26
26
|
const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
27
27
|
|
|
28
28
|
export class RemoteHost {
|
|
29
|
-
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
30
|
-
Object.assign(this,{client,onInput,onStop,onNotice,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
29
|
+
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
30
|
+
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
31
31
|
}
|
|
32
32
|
status(){return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only'}:{enabled:false};}
|
|
33
33
|
current(run,{starting=false}={}) {return this.lifecycle===run && (starting || !this.stopped) && !run?.controller?.signal.aborted;}
|
|
@@ -48,7 +48,8 @@ export class RemoteHost {
|
|
|
48
48
|
const account=await this.client.credentials();this.assertCurrent(run,{starting:true});
|
|
49
49
|
if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
|
|
50
50
|
run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
|
|
51
|
-
const
|
|
51
|
+
const workspace=this.getWorkspace?await this.getWorkspace(session):null;this.assertCurrent(run,{starting:true});
|
|
52
|
+
const opened=await this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[])]},{expectedAccount:run.account,signal:run.controller.signal});
|
|
52
53
|
run.session=opened.session;
|
|
53
54
|
// Abort can race a successful response. Revoke a late-created grant even
|
|
54
55
|
// when the local stop completed before its identifier became available.
|
|
@@ -56,6 +57,7 @@ export class RemoteHost {
|
|
|
56
57
|
this.account=run.account;this.enablingToken=run.enablingToken;this.session=opened.session;this.url=opened.url;this.stopped=false;
|
|
57
58
|
await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
|
|
58
59
|
this.transport='https-polling';this.connectSocket(run);
|
|
60
|
+
if(workspace)this.publishWorkspace(workspace,run);
|
|
59
61
|
this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
|
|
60
62
|
for(const message of (session.historyMessages || []).slice(-20)){
|
|
61
63
|
const event=message.role==='user'&&(typeof message.content==='string'||Array.isArray(message.content)&&message.content.some(block=>block.type==='text'))?{type:'user',text:typeof message.content==='string'?message.content:message.content.filter(block=>block.type==='text').map(block=>block.text).join('\n')}:message.role==='assistant'?{type:'assistant',message}:null;
|
|
@@ -103,8 +105,30 @@ export class RemoteHost {
|
|
|
103
105
|
}catch{/* HTTPS is already active. A later tick retries WSS. */}
|
|
104
106
|
finally{if(this.connecting===marker){this.connecting=null;if(this.current(run))this.nextSocketAt=Date.now()+10000;}}
|
|
105
107
|
}
|
|
108
|
+
publishWorkspace(workspace,run=this.lifecycle) {
|
|
109
|
+
if(!this.current(run))return;
|
|
110
|
+
const {source,...content}=workspace,key=digest(content);
|
|
111
|
+
if(run.workspaceDigest===key)return;
|
|
112
|
+
if(this.pending.length>=512)throw Error('Remote workspace buffer full.');
|
|
113
|
+
this.pending.push({event_id:randomUUID(),event:{type:'workspace.snapshot',text:'',turn_id:this.turnId||'local',workspace}});
|
|
114
|
+
run.workspaceDigest=key;this.flush(run).catch(()=>{});
|
|
115
|
+
}
|
|
116
|
+
invalidateWorkspace() {
|
|
117
|
+
const run=this.lifecycle;if(!this.current(run)||!this.getWorkspace)return;
|
|
118
|
+
run.workspaceDirty=true;
|
|
119
|
+
if(run.workspaceTimer||run.workspaceLoading)return;
|
|
120
|
+
run.workspaceTimer=setTimeout(()=>{run.workspaceTimer=null;this.refreshWorkspace(run);},750);run.workspaceTimer.unref();
|
|
121
|
+
}
|
|
122
|
+
async refreshWorkspace(run=this.lifecycle) {
|
|
123
|
+
if(!this.current(run)||!this.getWorkspace||run.workspaceLoading)return;
|
|
124
|
+
run.workspaceLoading=true;run.workspaceDirty=false;
|
|
125
|
+
try {const workspace=await this.getWorkspace();if(this.current(run))this.publishWorkspace(workspace,run);}
|
|
126
|
+
catch {if(this.current(run))this.onNotice('Remote work state unavailable; local execution continues.');}
|
|
127
|
+
finally {run.workspaceLoading=false;if(this.current(run)&&run.workspaceDirty)this.invalidateWorkspace();}
|
|
128
|
+
}
|
|
106
129
|
observe(event) {
|
|
107
130
|
if(this.stopped)return;
|
|
131
|
+
if(['tool_result','house_waiting','house_answered','result'].includes(event.type))this.invalidateWorkspace();
|
|
108
132
|
const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
|
|
109
133
|
if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
|
|
110
134
|
this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
|
|
@@ -139,6 +163,7 @@ export class RemoteHost {
|
|
|
139
163
|
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(this.current(run))return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId},run);});this.pulsing=work;return work;}
|
|
140
164
|
async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
|
|
141
165
|
async endTurn(){
|
|
166
|
+
this.invalidateWorkspace();
|
|
142
167
|
if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});}
|
|
143
168
|
clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
|
|
144
169
|
}
|
|
@@ -172,7 +197,7 @@ export class RemoteHost {
|
|
|
172
197
|
}
|
|
173
198
|
async stop(){
|
|
174
199
|
const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
|
|
175
|
-
this.lifecycle=null;this.starting=null;run.controller?.abort();
|
|
200
|
+
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);
|
|
176
201
|
this.stopped=true;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
177
202
|
this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
|
|
178
203
|
await this.revoke(run);return this.status();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { foldWorkBook } from './work-plan.mjs';
|
|
2
|
+
import { auditJournal } from './audit.mjs';
|
|
3
|
+
|
|
4
|
+
const text = (value, bytes) => Buffer.from(String(value ?? '')).subarray(0, bytes).toString('utf8').replace(/\uFFFD$/, '');
|
|
5
|
+
const reasons = {
|
|
6
|
+
'Una comprobación perdió vigencia en el libro.':'A recorded check is no longer current.',
|
|
7
|
+
'La evidencia de una dependencia cambió; vuelve a comprobar esta tarea.':'Dependency evidence changed; verify this task again.',
|
|
8
|
+
'Una dependencia perdió su cierre vigente.':'A dependency no longer has current completion evidence.',
|
|
9
|
+
'Espera una respuesta humana.':'Waiting for an answer in the local terminal.',
|
|
10
|
+
};
|
|
11
|
+
const states = new Set(['pending', 'in_progress', 'blocked', 'done', 'needs_verification']);
|
|
12
|
+
const checkStates = new Set(['running', 'failed', 'refused', 'interrupted', 'not_dispatched']);
|
|
13
|
+
|
|
14
|
+
/** Host projection only: no arguments, assertions, file contents, drafts or raw outputs. */
|
|
15
|
+
export function projectRemoteWorkspace(session, context = {}) {
|
|
16
|
+
const rows = session.rows || [], journal = auditJournal(rows), book = foldWorkBook(session, context);
|
|
17
|
+
const counts = Object.fromEntries([...states].map(status => [status, book.tasks.filter(task => task.status === status).length]));
|
|
18
|
+
const tasks = [...book.tasks].sort((a, b) => ['in_progress', 'blocked', 'needs_verification', 'pending', 'done'].indexOf(a.status) - ['in_progress', 'blocked', 'needs_verification', 'pending', 'done'].indexOf(b.status)).slice(0, 24).map(task => ({
|
|
19
|
+
id: task.id, title: text(task.title, 180), criterion: text(task.criterion, 400),
|
|
20
|
+
status: journal.verified ? task.status : task.status === 'done' ? 'needs_verification' : task.status,
|
|
21
|
+
declared_status: states.has(task.declared_status) ? task.declared_status : 'pending',
|
|
22
|
+
phase: task.phase || 'implementation', reason: text(reasons[task.reason] || task.reason, 240), held_by: task.held_by.slice(0, 24),
|
|
23
|
+
checks_total: task.checks.length,
|
|
24
|
+
checks: task.checks.slice(0, 4).map(check => {
|
|
25
|
+
const step = book.steps.find(step => step.task_id === task.id && step.step_id === check.step_id);
|
|
26
|
+
const receipt = book.receipts.get(step?.receipt_id);
|
|
27
|
+
const status = journal.verified && book.checkValid(task, check, step) ? 'passed'
|
|
28
|
+
: step?.preflight_failure ? 'not_dispatched'
|
|
29
|
+
: step?.status === 'returned' ? 'stale'
|
|
30
|
+
: checkStates.has(step?.status) ? step.status : 'pending';
|
|
31
|
+
return { step_id: check.step_id, operation: text(check.operation, 128), status,
|
|
32
|
+
...(receipt ? { receipt_id: receipt.invocation_id, receipt_row: receipt.evidence_row } : {}) };
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
const references = new Set([...book.steps.map(step => step.receipt_id), ...book.tasks.flatMap(task => task.receipt_ids || [])]);
|
|
36
|
+
const receipts = [...book.receipts.values()].filter(receipt => references.has(receipt.invocation_id)).slice(-8).reverse().map(receipt => ({
|
|
37
|
+
id: receipt.invocation_id, operation: text(receipt.operation, 128), status: receipt.status, row: receipt.evidence_row,
|
|
38
|
+
}));
|
|
39
|
+
if (!journal.verified) { const previousDone=counts.done; counts.done=0; counts.needs_verification+=previousDone; }
|
|
40
|
+
const result = {
|
|
41
|
+
version: 1, scope: 'recorded_work_execution', asked: 'unjudged',
|
|
42
|
+
source: { session_id: session.id, row: Math.max(0, rows.length - 1), sha256: journal.tip_sha256 || '0'.repeat(64), integrity: journal.verified ? 'verified_hash_chain' : journal.consistent ? 'legacy' : 'invalid' },
|
|
43
|
+
plan: book.plan ? { version: book.plan.version, objective: text(book.plan.objective, 1000) } : null,
|
|
44
|
+
counts, tasks_total: book.tasks.length, tasks, receipts,
|
|
45
|
+
};
|
|
46
|
+
// The stream has a 48 KiB event ceiling, including its transport envelope.
|
|
47
|
+
// Keep whole task/check records and disclose omissions through total counts.
|
|
48
|
+
while (Buffer.byteLength(JSON.stringify(result)) > 36000 && result.tasks.length) result.tasks.pop();
|
|
49
|
+
return result;
|
|
50
|
+
}
|