gestalt-mobile 0.1.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.
- package/LICENSE +661 -0
- package/README.md +133 -0
- package/dist/client/assets/index-0wjiXBGW.js +10 -0
- package/dist/client/assets/index-CfoipqOl.css +1 -0
- package/dist/client/branding/dyne-logotype-black.svg +17 -0
- package/dist/client/branding/dyne-logotype-white.svg +17 -0
- package/dist/client/branding/p_glogo_grey.svg +8 -0
- package/dist/client/branding/p_glogo_white.svg +8 -0
- package/dist/client/branding/t_glogo_grey.svg +9 -0
- package/dist/client/branding/t_glogo_white.svg +9 -0
- package/dist/client/index.html +19 -0
- package/dist/server/server/app.js +148 -0
- package/dist/server/server/cli/list-sessions.js +13 -0
- package/dist/server/server/cli.js +126 -0
- package/dist/server/server/composition.js +237 -0
- package/dist/server/server/config.js +37 -0
- package/dist/server/server/features/catalog/application/ports.js +6 -0
- package/dist/server/server/features/catalog/get-bootstrap/endpoint.js +9 -0
- package/dist/server/server/features/catalog/get-bootstrap/request.js +6 -0
- package/dist/server/server/features/catalog/get-bootstrap/response.js +6 -0
- package/dist/server/server/features/catalog/get-bootstrap/use-case.js +19 -0
- package/dist/server/server/features/git/checkout-branch/endpoint.js +27 -0
- package/dist/server/server/features/git/clone-repository/endpoint.js +19 -0
- package/dist/server/server/features/git/get-summary/endpoint.js +13 -0
- package/dist/server/server/features/git/pull-rebase/endpoint.js +22 -0
- package/dist/server/server/features/git/push-policy.js +14 -0
- package/dist/server/server/features/git/push-upstream/endpoint.js +29 -0
- package/dist/server/server/features/git/refresh/endpoint.js +24 -0
- package/dist/server/server/features/git/refresh-policy.js +8 -0
- package/dist/server/server/features/health/get-health/endpoint.js +8 -0
- package/dist/server/server/features/health/get-health/request.js +6 -0
- package/dist/server/server/features/health/get-health/response.js +6 -0
- package/dist/server/server/features/sessions/application/ports.js +6 -0
- package/dist/server/server/features/sessions/application/resume-command.js +22 -0
- package/dist/server/server/features/sessions/application/start-settings.js +6 -0
- package/dist/server/server/features/sessions/forget-session/endpoint.js +15 -0
- package/dist/server/server/features/sessions/get-history/endpoint.js +20 -0
- package/dist/server/server/features/sessions/get-history/history-mapper.js +82 -0
- package/dist/server/server/features/sessions/get-session/endpoint.js +17 -0
- package/dist/server/server/features/sessions/history/use-case.js +8 -0
- package/dist/server/server/features/sessions/interaction/kind.js +8 -0
- package/dist/server/server/features/sessions/interaction/response-guard.js +8 -0
- package/dist/server/server/features/sessions/interaction/response-validator.js +35 -0
- package/dist/server/server/features/sessions/interrupt-turn/endpoint.js +19 -0
- package/dist/server/server/features/sessions/interrupt-turn/use-case.js +10 -0
- package/dist/server/server/features/sessions/lifecycle/use-case.js +15 -0
- package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +11 -0
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +12 -0
- package/dist/server/server/features/sessions/list-sessions/use-case.js +8 -0
- package/dist/server/server/features/sessions/model/errors.js +13 -0
- package/dist/server/server/features/sessions/model/events.js +6 -0
- package/dist/server/server/features/sessions/model/relay-session.js +122 -0
- package/dist/server/server/features/sessions/model/value-objects.js +22 -0
- package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +29 -0
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +26 -0
- package/dist/server/server/features/sessions/release-session/endpoint.js +25 -0
- package/dist/server/server/features/sessions/release-session/use-case.js +9 -0
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +24 -0
- package/dist/server/server/features/sessions/restore-session/endpoint.js +27 -0
- package/dist/server/server/features/sessions/restore-session/use-case.js +17 -0
- package/dist/server/server/features/sessions/session-events/endpoint.js +44 -0
- package/dist/server/server/features/sessions/start-session/endpoint.js +37 -0
- package/dist/server/server/features/sessions/start-session/request.js +22 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +29 -0
- package/dist/server/server/features/sessions/start-turn/endpoint.js +20 -0
- package/dist/server/server/features/sessions/stop-session/endpoint.js +25 -0
- package/dist/server/server/features/sessions/stop-session/use-case.js +9 -0
- package/dist/server/server/features/sessions/turn/start-policy.js +8 -0
- package/dist/server/server/index.js +7 -0
- package/dist/server/server/main.js +14 -0
- package/dist/server/server/platform/catalog/codex-profile-catalog.js +42 -0
- package/dist/server/server/platform/catalog/filesystem-workspace-catalog.js +60 -0
- package/dist/server/server/platform/catalog/profile-command.js +19 -0
- package/dist/server/server/platform/catalog/workspace-id.js +9 -0
- package/dist/server/server/platform/codex/approval-policy.js +8 -0
- package/dist/server/server/platform/codex/codex-process-launcher.js +27 -0
- package/dist/server/server/platform/codex/error-code.js +10 -0
- package/dist/server/server/platform/codex/json-rpc-client.js +71 -0
- package/dist/server/server/platform/codex/normalizer.js +81 -0
- package/dist/server/server/platform/codex/protocol-compatibility.js +12 -0
- package/dist/server/server/platform/codex/recent-thread-lister.js +49 -0
- package/dist/server/server/platform/codex/server-request.js +14 -0
- package/dist/server/server/platform/codex/session-runtime.js +142 -0
- package/dist/server/server/platform/codex/thread-id.js +10 -0
- package/dist/server/server/platform/events/replay.js +12 -0
- package/dist/server/server/platform/events/session-event-bus.js +17 -0
- package/dist/server/server/platform/events/slow-client.js +9 -0
- package/dist/server/server/platform/events/websocket-heartbeat.js +20 -0
- package/dist/server/server/platform/git/command.js +13 -0
- package/dist/server/server/platform/git/git-fetch-coordinator.js +40 -0
- package/dist/server/server/platform/git/git-inspector.js +116 -0
- package/dist/server/server/platform/git/git-summary-cache.js +26 -0
- package/dist/server/server/platform/git/git-summary.js +8 -0
- package/dist/server/server/platform/http/idempotency.js +9 -0
- package/dist/server/server/platform/http/problem-handler.js +32 -0
- package/dist/server/server/platform/http/problem.js +15 -0
- package/dist/server/server/platform/observability/safe-log.js +9 -0
- package/dist/server/server/platform/persistence/migrate.js +9 -0
- package/dist/server/server/platform/persistence/retention.js +9 -0
- package/dist/server/server/platform/persistence/sqlite-event-journal.js +50 -0
- package/dist/server/server/platform/persistence/sqlite-idempotency-store.js +22 -0
- package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +33 -0
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +43 -0
- package/dist/server/server/platform/persistence/sqlite.js +14 -0
- package/dist/server/server/platform/persistence/state-path.js +16 -0
- package/dist/server/server/platform/runtime/clock.js +9 -0
- package/dist/server/server/platform/runtime/concurrency.js +15 -0
- package/dist/server/server/platform/runtime/retry-policy.js +9 -0
- package/dist/server/server/platform/runtime/session-supervisor.js +41 -0
- package/dist/server/shared/contracts/problem.js +6 -0
- package/dist/server/shared/contracts/session-event.js +6 -0
- package/package.json +78 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { relative } from 'node:path';
|
|
7
|
+
export function normalizeCodexNotification(sessionId, sequence, occurredAt, notification, workspacePath) {
|
|
8
|
+
if (notification.method === 'item/agentMessage/delta')
|
|
9
|
+
return {
|
|
10
|
+
sessionId,
|
|
11
|
+
sequence,
|
|
12
|
+
occurredAt,
|
|
13
|
+
type: 'agentMessageDelta',
|
|
14
|
+
payload: { text: notification.params?.delta ?? '' },
|
|
15
|
+
};
|
|
16
|
+
if (notification.method === 'turn/completed')
|
|
17
|
+
return {
|
|
18
|
+
sessionId,
|
|
19
|
+
sequence,
|
|
20
|
+
occurredAt,
|
|
21
|
+
type: 'turnCompleted',
|
|
22
|
+
payload: notification.params ?? {},
|
|
23
|
+
};
|
|
24
|
+
if (notification.method === 'item/started' || notification.method === 'item/completed') {
|
|
25
|
+
const activity = safeActivity(notification.params?.item, workspacePath);
|
|
26
|
+
if (activity)
|
|
27
|
+
return { sessionId, sequence, occurredAt, type: 'activity.updated', payload: activity };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function safeActivity(item, workspacePath) {
|
|
32
|
+
if (!item || typeof item !== 'object')
|
|
33
|
+
return null;
|
|
34
|
+
const value = item;
|
|
35
|
+
if (typeof value.id !== 'string' || typeof value.type !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
const status = typeof value.status === 'string' ? ` · ${value.status}` : '';
|
|
38
|
+
if (value.type === 'commandExecution' && typeof value.command === 'string')
|
|
39
|
+
return { id: value.id, label: `Command${status}`, detail: value.command };
|
|
40
|
+
if (value.type === 'plan' && typeof value.text === 'string')
|
|
41
|
+
return { id: value.id, label: 'Plan', detail: value.text };
|
|
42
|
+
if (value.type === 'reasoning' && Array.isArray(value.summary)) {
|
|
43
|
+
const detail = reasoningSummary(value.summary).join('\n');
|
|
44
|
+
return detail ? { id: value.id, label: 'Reasoning summary', detail } : null;
|
|
45
|
+
}
|
|
46
|
+
if (value.type === 'fileChange' && Array.isArray(value.changes))
|
|
47
|
+
return {
|
|
48
|
+
id: value.id,
|
|
49
|
+
label: `fileChange${status}`,
|
|
50
|
+
detail: value.changes
|
|
51
|
+
.map((change) => change &&
|
|
52
|
+
typeof change === 'object' &&
|
|
53
|
+
typeof change.path === 'string'
|
|
54
|
+
? relativePath(change.path, workspacePath)
|
|
55
|
+
: '')
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join('\n'),
|
|
58
|
+
};
|
|
59
|
+
if ((value.type === 'mcpToolCall' || value.type === 'dynamicToolCall') &&
|
|
60
|
+
typeof value.tool === 'string')
|
|
61
|
+
return { id: value.id, label: `Tool${status}`, detail: value.tool };
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
function relativePath(path, workspacePath) {
|
|
65
|
+
if (!workspacePath)
|
|
66
|
+
return path;
|
|
67
|
+
const value = relative(workspacePath, path);
|
|
68
|
+
return value && !value.startsWith('..') ? value : path;
|
|
69
|
+
}
|
|
70
|
+
function reasoningSummary(parts) {
|
|
71
|
+
return parts.flatMap((part) => {
|
|
72
|
+
if (typeof part === 'string')
|
|
73
|
+
return part ? [part] : [];
|
|
74
|
+
if (part &&
|
|
75
|
+
typeof part === 'object' &&
|
|
76
|
+
part.type === 'summary_text' &&
|
|
77
|
+
typeof part.text === 'string')
|
|
78
|
+
return [part.text];
|
|
79
|
+
return [];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function protocolCompatibility(installedVersion, protocolVersion) {
|
|
7
|
+
if (!installedVersion)
|
|
8
|
+
return { compatible: false, code: 'CODEX_NOT_FOUND' };
|
|
9
|
+
if (installedVersion !== protocolVersion)
|
|
10
|
+
return { compatible: false, code: 'CODEX_PROTOCOL_MISMATCH' };
|
|
11
|
+
return { compatible: true };
|
|
12
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function createRecentThreadLister(deps) {
|
|
7
|
+
return {
|
|
8
|
+
async list() {
|
|
9
|
+
const profiles = (await deps.profiles.list()).filter((profile) => profile.state === 'ok');
|
|
10
|
+
const listed = await Promise.all(profiles.map(async ({ name }) => listProfileThreads(deps.launch, name, deps.root)));
|
|
11
|
+
const byId = new Map();
|
|
12
|
+
for (const thread of listed.flat()) {
|
|
13
|
+
const known = byId.get(thread.id);
|
|
14
|
+
if (!known || (thread.recencyAt ?? 0) > (known.recencyAt ?? 0))
|
|
15
|
+
byId.set(thread.id, thread);
|
|
16
|
+
}
|
|
17
|
+
return [...byId.values()].sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0));
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function listProfileThreads(launch, profile, cwd) {
|
|
22
|
+
const process = launch({ profile, cwd });
|
|
23
|
+
try {
|
|
24
|
+
await process.rpc.request('initialize', {
|
|
25
|
+
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
26
|
+
capabilities: null,
|
|
27
|
+
});
|
|
28
|
+
const result = (await process.rpc.request('thread/list', {
|
|
29
|
+
limit: 20,
|
|
30
|
+
sortKey: 'recency_at',
|
|
31
|
+
sortDirection: 'desc',
|
|
32
|
+
sourceKinds: ['cli', 'appServer'],
|
|
33
|
+
}));
|
|
34
|
+
return (result.data ?? []).flatMap((thread) => {
|
|
35
|
+
if (typeof thread.id !== 'string' || typeof thread.cwd !== 'string')
|
|
36
|
+
return [];
|
|
37
|
+
return [{ id: thread.id, cwd: thread.cwd, recencyAt: toRecency(thread.recencyAt) }];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
process.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function toRecency(value) {
|
|
48
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function toPendingInteraction(input) {
|
|
7
|
+
const kind = {
|
|
8
|
+
'item/commandExecution/requestApproval': 'commandApproval',
|
|
9
|
+
'item/fileChange/requestApproval': 'fileChangeApproval',
|
|
10
|
+
'item/permissions/requestApproval': 'permissionsApproval',
|
|
11
|
+
'item/tool/requestUserInput': 'userInput',
|
|
12
|
+
}[input.method];
|
|
13
|
+
return kind ? { requestId: String(input.id), kind, payload: input.params } : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { RelaySession, } from '../../features/sessions/model/relay-session.js';
|
|
7
|
+
export class CodexSessionRuntime {
|
|
8
|
+
launch;
|
|
9
|
+
processes;
|
|
10
|
+
onNotification;
|
|
11
|
+
onServerRequest;
|
|
12
|
+
onProcessExit;
|
|
13
|
+
constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit) {
|
|
14
|
+
this.launch = launch;
|
|
15
|
+
this.processes = processes;
|
|
16
|
+
this.onNotification = onNotification;
|
|
17
|
+
this.onServerRequest = onServerRequest;
|
|
18
|
+
this.onProcessExit = onProcessExit;
|
|
19
|
+
}
|
|
20
|
+
pendingRequests = new Map();
|
|
21
|
+
exitUnsubscribers = new Map();
|
|
22
|
+
async start(session, now, settings = {}) {
|
|
23
|
+
const process = this.launch({ profile: session.profile, cwd: session.workspacePath });
|
|
24
|
+
try {
|
|
25
|
+
process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
|
|
26
|
+
process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
|
|
27
|
+
this.attachExitHandler(session.id, process);
|
|
28
|
+
await process.rpc.request('initialize', {
|
|
29
|
+
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
30
|
+
capabilities: null,
|
|
31
|
+
});
|
|
32
|
+
const result = (await process.rpc.request('thread/start', {
|
|
33
|
+
cwd: session.workspacePath,
|
|
34
|
+
approvalPolicy: settings.approvalPolicy ?? 'on-request',
|
|
35
|
+
...(settings.model ? { model: settings.model } : {}),
|
|
36
|
+
...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
|
|
37
|
+
}));
|
|
38
|
+
if (!result.thread?.id)
|
|
39
|
+
throw new Error('CODEX_THREAD_ID_MISSING');
|
|
40
|
+
this.processes.set(session.id, process);
|
|
41
|
+
return RelaySession.rehydrate(session).bindThread(result.thread.id, now).snapshot;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
process.close();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
stop(sessionId) {
|
|
49
|
+
this.exitUnsubscribers.get(sessionId)?.();
|
|
50
|
+
this.exitUnsubscribers.delete(sessionId);
|
|
51
|
+
this.processes.get(sessionId)?.close();
|
|
52
|
+
this.processes.delete(sessionId);
|
|
53
|
+
}
|
|
54
|
+
/** Releases all relay-owned app-server children during graceful shutdown. */
|
|
55
|
+
stopAll() {
|
|
56
|
+
for (const sessionId of [...this.processes.keys()])
|
|
57
|
+
this.stop(sessionId);
|
|
58
|
+
}
|
|
59
|
+
resolveServerRequest(sessionId, requestId, result) {
|
|
60
|
+
const key = `${sessionId}:${requestId}`;
|
|
61
|
+
const resolve = this.pendingRequests.get(key);
|
|
62
|
+
if (!resolve)
|
|
63
|
+
return false;
|
|
64
|
+
this.pendingRequests.delete(key);
|
|
65
|
+
resolve(result);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
async startTurn(session, text, now) {
|
|
69
|
+
const process = this.processes.get(session.id);
|
|
70
|
+
if (!process || !session.threadId)
|
|
71
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
72
|
+
const result = (await process.rpc.request('turn/start', {
|
|
73
|
+
threadId: session.threadId,
|
|
74
|
+
input: [{ type: 'text', text, text_elements: [] }],
|
|
75
|
+
}));
|
|
76
|
+
if (!result.turn?.id)
|
|
77
|
+
throw new Error('CODEX_TURN_ID_MISSING');
|
|
78
|
+
return RelaySession.rehydrate(session).startTurn(result.turn.id, now).snapshot;
|
|
79
|
+
}
|
|
80
|
+
async interruptTurn(session, turnId) {
|
|
81
|
+
const process = this.processes.get(session.id);
|
|
82
|
+
if (!process || !session.threadId)
|
|
83
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
84
|
+
await process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
|
|
85
|
+
}
|
|
86
|
+
async readHistory(session) {
|
|
87
|
+
const process = this.processes.get(session.id);
|
|
88
|
+
if (!process || !session.threadId)
|
|
89
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
90
|
+
const result = (await process.rpc.request('thread/read', {
|
|
91
|
+
threadId: session.threadId,
|
|
92
|
+
includeTurns: true,
|
|
93
|
+
}));
|
|
94
|
+
const rawTurns = result.thread?.turns ?? [];
|
|
95
|
+
const activeTurn = rawTurns.find((turn) => turn.status === 'inProgress' && typeof turn.id === 'string');
|
|
96
|
+
return {
|
|
97
|
+
turns: rawTurns.map((turn) => ({
|
|
98
|
+
items: turn.items ?? [],
|
|
99
|
+
startedAt: typeof turn.startedAt === 'number' ? turn.startedAt : null,
|
|
100
|
+
completedAt: typeof turn.completedAt === 'number' ? turn.completedAt : null,
|
|
101
|
+
})),
|
|
102
|
+
activeTurnId: typeof activeTurn?.id === 'string' ? activeTurn.id : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async restore(session, now) {
|
|
106
|
+
if (!session.threadId)
|
|
107
|
+
throw new Error('CODEX_THREAD_ID_MISSING');
|
|
108
|
+
const process = this.launch({ profile: session.profile, cwd: session.workspacePath });
|
|
109
|
+
try {
|
|
110
|
+
process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
|
|
111
|
+
process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
|
|
112
|
+
this.attachExitHandler(session.id, process);
|
|
113
|
+
await process.rpc.request('initialize', {
|
|
114
|
+
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
115
|
+
capabilities: null,
|
|
116
|
+
});
|
|
117
|
+
await process.rpc.request('thread/resume', {
|
|
118
|
+
threadId: session.threadId,
|
|
119
|
+
cwd: session.workspacePath,
|
|
120
|
+
});
|
|
121
|
+
this.processes.set(session.id, process);
|
|
122
|
+
return RelaySession.rehydrate(session).restore(now).snapshot;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
process.close();
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
holdServerRequest(sessionId, request) {
|
|
130
|
+
if (!this.onServerRequest?.(sessionId, request)) {
|
|
131
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
|
|
132
|
+
}
|
|
133
|
+
return new Promise((resolve) => this.pendingRequests.set(`${sessionId}:${request.id}`, resolve));
|
|
134
|
+
}
|
|
135
|
+
attachExitHandler(sessionId, process) {
|
|
136
|
+
this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
|
|
137
|
+
this.processes.delete(sessionId);
|
|
138
|
+
this.exitUnsubscribers.delete(sessionId);
|
|
139
|
+
this.onProcessExit?.(sessionId);
|
|
140
|
+
}) ?? (() => { }));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function requireThreadId(threadId) {
|
|
7
|
+
if (!threadId)
|
|
8
|
+
throw new Error('THREAD_REQUIRED');
|
|
9
|
+
return threadId;
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function replayOrResync(events, after) {
|
|
7
|
+
if (!events.length)
|
|
8
|
+
return { kind: 'replay', events: [] };
|
|
9
|
+
return events[0].sequence > after + 1
|
|
10
|
+
? { kind: 'resync' }
|
|
11
|
+
: { kind: 'replay', events: events.filter((event) => event.sequence > after) };
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export class SessionEventBus {
|
|
7
|
+
listeners = new Map();
|
|
8
|
+
publish(event) {
|
|
9
|
+
this.listeners.get(event.sessionId)?.forEach((listener) => listener(event));
|
|
10
|
+
}
|
|
11
|
+
subscribe(sessionId, listener) {
|
|
12
|
+
const listeners = this.listeners.get(sessionId) ?? new Set();
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
this.listeners.set(sessionId, listeners);
|
|
15
|
+
return () => listeners.delete(listener);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export const slowClientLimitBytes = 1024 * 1024;
|
|
7
|
+
export function isSlowClient(bufferedBytes) {
|
|
8
|
+
return bufferedBytes > slowClientLimitBytes;
|
|
9
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function installWebSocketHeartbeat(socket, intervalMs = 25_000) {
|
|
7
|
+
let alive = true;
|
|
8
|
+
socket.on('pong', () => {
|
|
9
|
+
alive = true;
|
|
10
|
+
});
|
|
11
|
+
const interval = setInterval(() => {
|
|
12
|
+
if (!alive) {
|
|
13
|
+
socket.terminate();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
alive = false;
|
|
17
|
+
socket.ping();
|
|
18
|
+
}, intervalMs);
|
|
19
|
+
return () => clearInterval(interval);
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
export const GIT_READ_TIMEOUT_MS = 15_000;
|
|
10
|
+
export const GIT_FETCH_TIMEOUT_MS = 120_000;
|
|
11
|
+
export async function git(cwd, args, timeoutMs = GIT_READ_TIMEOUT_MS) {
|
|
12
|
+
return (await execute('git', args, { cwd, shell: false, timeout: timeoutMs })).stdout;
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
export class GitFetchCoordinator {
|
|
8
|
+
fetchWorkspace;
|
|
9
|
+
now;
|
|
10
|
+
throttleMs;
|
|
11
|
+
inFlight = new Map();
|
|
12
|
+
lastSuccessfulFetch = new Map();
|
|
13
|
+
constructor(fetchWorkspace, now = Date.now, throttleMs = 60_000) {
|
|
14
|
+
this.fetchWorkspace = fetchWorkspace;
|
|
15
|
+
this.now = now;
|
|
16
|
+
this.throttleMs = throttleMs;
|
|
17
|
+
}
|
|
18
|
+
lastSuccessfulAt(workspacePath) {
|
|
19
|
+
return this.lastSuccessfulFetch.get(resolve(workspacePath)) ?? null;
|
|
20
|
+
}
|
|
21
|
+
refresh(workspacePath) {
|
|
22
|
+
const key = resolve(workspacePath);
|
|
23
|
+
const active = this.inFlight.get(key);
|
|
24
|
+
if (active)
|
|
25
|
+
return active;
|
|
26
|
+
const lastFetchedAt = this.lastSuccessfulFetch.get(key);
|
|
27
|
+
if (lastFetchedAt !== undefined && this.now() - lastFetchedAt < this.throttleMs) {
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
const refresh = this.fetchWorkspace(workspacePath)
|
|
31
|
+
.then(() => {
|
|
32
|
+
this.lastSuccessfulFetch.set(key, this.now());
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
this.inFlight.delete(key);
|
|
36
|
+
});
|
|
37
|
+
this.inFlight.set(key, refresh);
|
|
38
|
+
return refresh;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { GIT_FETCH_TIMEOUT_MS, git } from './command.js';
|
|
7
|
+
export function parseDirtyCounts(value) {
|
|
8
|
+
return value
|
|
9
|
+
.split('\n')
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.reduce((counts, line) => ({
|
|
12
|
+
staged: counts.staged + (line[0] && line[0] !== ' ' && line.slice(0, 2) !== '??' ? 1 : 0),
|
|
13
|
+
unstaged: counts.unstaged + (line.slice(0, 2) !== '??' && line[1] && line[1] !== ' ' ? 1 : 0),
|
|
14
|
+
untracked: counts.untracked + (line.slice(0, 2) === '??' ? 1 : 0),
|
|
15
|
+
}), { staged: 0, unstaged: 0, untracked: 0 });
|
|
16
|
+
}
|
|
17
|
+
export function parseDivergence(value) {
|
|
18
|
+
const [ahead, behind] = value.trim().split(/\s+/).map(Number);
|
|
19
|
+
return {
|
|
20
|
+
ahead: Number.isSafeInteger(ahead) ? ahead : 0,
|
|
21
|
+
behind: Number.isSafeInteger(behind) ? behind : 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function inspectGit(cwd) {
|
|
25
|
+
try {
|
|
26
|
+
await git(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
27
|
+
const branch = await git(cwd, ['branch', '--show-current']).then((value) => value.trim() || null);
|
|
28
|
+
const branches = (await git(cwd, ['branch', '--format=%(refname:short)']))
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map((value) => value.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
const upstream = branch
|
|
33
|
+
? await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
34
|
+
.then((value) => value.trim())
|
|
35
|
+
.catch(() => null)
|
|
36
|
+
: null;
|
|
37
|
+
const divergence = upstream
|
|
38
|
+
? parseDivergence(await git(cwd, ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}']))
|
|
39
|
+
: { ahead: 0, behind: 0 };
|
|
40
|
+
const dirty = parseDirtyCounts(await git(cwd, ['status', '--porcelain=v1']));
|
|
41
|
+
const commits = (await git(cwd, ['log', '-20', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI']))
|
|
42
|
+
.trim()
|
|
43
|
+
.split('\n')
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((line) => {
|
|
46
|
+
const [hash, shortHash, subject, author, authoredAt] = line.split('\x1f');
|
|
47
|
+
return {
|
|
48
|
+
hash: hash,
|
|
49
|
+
shortHash: shortHash,
|
|
50
|
+
subject: subject,
|
|
51
|
+
author: author,
|
|
52
|
+
authoredAt: authoredAt,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
available: true,
|
|
57
|
+
branch,
|
|
58
|
+
branches,
|
|
59
|
+
upstream,
|
|
60
|
+
...divergence,
|
|
61
|
+
dirty,
|
|
62
|
+
commits,
|
|
63
|
+
fetchedAt: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return {
|
|
68
|
+
available: false,
|
|
69
|
+
branch: null,
|
|
70
|
+
branches: [],
|
|
71
|
+
upstream: null,
|
|
72
|
+
ahead: 0,
|
|
73
|
+
behind: 0,
|
|
74
|
+
dirty: { staged: 0, unstaged: 0, untracked: 0 },
|
|
75
|
+
commits: [],
|
|
76
|
+
fetchedAt: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function pushUpstream(cwd, upstream) {
|
|
81
|
+
const separator = upstream.indexOf('/');
|
|
82
|
+
if (separator < 1 || separator === upstream.length - 1)
|
|
83
|
+
throw new Error('NO_UPSTREAM');
|
|
84
|
+
await git(cwd, [
|
|
85
|
+
'push',
|
|
86
|
+
upstream.slice(0, separator),
|
|
87
|
+
`HEAD:refs/heads/${upstream.slice(separator + 1)}`,
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
export async function fetchUpstream(cwd) {
|
|
91
|
+
const upstream = await git(cwd, [
|
|
92
|
+
'rev-parse',
|
|
93
|
+
'--abbrev-ref',
|
|
94
|
+
'--symbolic-full-name',
|
|
95
|
+
'@{upstream}',
|
|
96
|
+
]);
|
|
97
|
+
const remote = upstream.trim().split('/')[0];
|
|
98
|
+
if (!remote)
|
|
99
|
+
throw new Error('NO_UPSTREAM');
|
|
100
|
+
await git(cwd, ['fetch', '--prune', remote], GIT_FETCH_TIMEOUT_MS);
|
|
101
|
+
}
|
|
102
|
+
export async function pullRebase(cwd) {
|
|
103
|
+
await git(cwd, ['pull', '--rebase'], GIT_FETCH_TIMEOUT_MS);
|
|
104
|
+
}
|
|
105
|
+
export async function checkoutBranch(cwd, branch) {
|
|
106
|
+
const branches = (await git(cwd, ['branch', '--format=%(refname:short)']))
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map((value) => value.trim())
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (!branches.includes(branch))
|
|
111
|
+
throw new Error('LOCAL_BRANCH_NOT_FOUND');
|
|
112
|
+
await git(cwd, ['switch', branch]);
|
|
113
|
+
}
|
|
114
|
+
export async function cloneRepository(workspaceRoot, address) {
|
|
115
|
+
await git(workspaceRoot, ['clone', '--', address], GIT_FETCH_TIMEOUT_MS);
|
|
116
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
/** Caches successful workspace inspections until a Git mutation invalidates them. */
|
|
8
|
+
export class GitSummaryCache {
|
|
9
|
+
inspectWorkspace;
|
|
10
|
+
summaries = new Map();
|
|
11
|
+
constructor(inspectWorkspace) {
|
|
12
|
+
this.inspectWorkspace = inspectWorkspace;
|
|
13
|
+
}
|
|
14
|
+
async inspect(workspacePath) {
|
|
15
|
+
const key = resolve(workspacePath);
|
|
16
|
+
const cached = this.summaries.get(key);
|
|
17
|
+
if (cached !== undefined)
|
|
18
|
+
return cached;
|
|
19
|
+
const summary = await this.inspectWorkspace(workspacePath);
|
|
20
|
+
this.summaries.set(key, summary);
|
|
21
|
+
return summary;
|
|
22
|
+
}
|
|
23
|
+
invalidate(workspacePath) {
|
|
24
|
+
this.summaries.delete(resolve(workspacePath));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function idempotencyKey(headers) {
|
|
7
|
+
const value = headers['idempotency-key'];
|
|
8
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
9
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function registerProblemHandler(app, serveSpa = false) {
|
|
7
|
+
app.setNotFoundHandler((request, reply) => {
|
|
8
|
+
if (serveSpa && request.method === 'GET' && !request.url.startsWith('/api/')) {
|
|
9
|
+
return reply.type('text/html; charset=utf-8').sendFile('index.html');
|
|
10
|
+
}
|
|
11
|
+
const problem = {
|
|
12
|
+
type: 'urn:gestalt-mobile:error:not-found',
|
|
13
|
+
title: 'Not found',
|
|
14
|
+
status: 404,
|
|
15
|
+
detail: `No route matches ${request.method} ${request.url}`,
|
|
16
|
+
code: 'NOT_FOUND',
|
|
17
|
+
retryable: false,
|
|
18
|
+
};
|
|
19
|
+
return reply.code(404).type('application/problem+json').send(problem);
|
|
20
|
+
});
|
|
21
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
22
|
+
const problem = {
|
|
23
|
+
type: 'urn:gestalt-mobile:error:internal',
|
|
24
|
+
title: 'Internal server error',
|
|
25
|
+
status: 500,
|
|
26
|
+
detail: 'The relay could not complete this request.',
|
|
27
|
+
code: 'INTERNAL_ERROR',
|
|
28
|
+
retryable: true,
|
|
29
|
+
};
|
|
30
|
+
return reply.code(500).type('application/problem+json').send(problem);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function problem(code, status, detail, retryable = false) {
|
|
7
|
+
return {
|
|
8
|
+
type: `urn:gestalt-mobile:error:${code.toLowerCase()}`,
|
|
9
|
+
title: code.replaceAll('_', ' '),
|
|
10
|
+
status,
|
|
11
|
+
detail,
|
|
12
|
+
code,
|
|
13
|
+
retryable,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
const sensitive = /prompt|message|output|answer|token|secret|environment/i;
|
|
7
|
+
export function safeLogFields(fields) {
|
|
8
|
+
return Object.fromEntries(Object.entries(fields).filter(([key]) => !sensitive.test(key)));
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, resolved_at TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
|
|
7
|
+
export function migrate(database) {
|
|
8
|
+
database.exec(schema);
|
|
9
|
+
}
|