@asc-agent/runtime 0.3.1 → 0.4.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/install.d.ts +7 -0
  3. package/dist/adapters/claude-code/install.js +114 -38
  4. package/dist/adapters/claude-code/probe.js +6 -1
  5. package/dist/adapters/claude-code/session-start.d.ts +20 -0
  6. package/dist/adapters/claude-code/session-start.js +111 -0
  7. package/dist/adapters/jam/adapter.d.ts +46 -9
  8. package/dist/adapters/jam/adapter.js +88 -22
  9. package/dist/adapters/jam/mcp-client.js +5 -1
  10. package/dist/adapters/jam/setup.d.ts +62 -0
  11. package/dist/adapters/jam/setup.js +85 -0
  12. package/dist/adapters/service/launchd.d.ts +12 -0
  13. package/dist/adapters/service/launchd.js +80 -0
  14. package/dist/adapters/service/schtasks.d.ts +16 -0
  15. package/dist/adapters/service/schtasks.js +67 -0
  16. package/dist/adapters/service/systemd-user.d.ts +15 -0
  17. package/dist/adapters/service/systemd-user.js +95 -0
  18. package/dist/cli/asc.js +886 -147
  19. package/dist/composition/registry.js +11 -3
  20. package/dist/composition/runtime.d.ts +33 -0
  21. package/dist/composition/runtime.js +74 -0
  22. package/dist/core/attach/setup-plan.d.ts +73 -1
  23. package/dist/core/attach/setup-plan.js +56 -3
  24. package/dist/core/distribution/external-command.d.ts +22 -0
  25. package/dist/core/distribution/external-command.js +79 -0
  26. package/dist/core/distribution/persistent-runtime.d.ts +73 -0
  27. package/dist/core/distribution/persistent-runtime.js +49 -0
  28. package/dist/core/distribution/release.d.ts +3 -3
  29. package/dist/core/distribution/release.js +1 -1
  30. package/dist/core/operator/progress.js +3 -1
  31. package/dist/core/runtime/background.d.ts +104 -0
  32. package/dist/core/runtime/background.js +225 -0
  33. package/dist/core/runtime/front.d.ts +48 -0
  34. package/dist/core/runtime/front.js +34 -0
  35. package/dist/core/runtime/session.js +6 -1
  36. package/dist/core/runtime/workspaces.d.ts +39 -0
  37. package/dist/core/runtime/workspaces.js +64 -0
  38. package/dist/core/workspace/resolve.d.ts +36 -0
  39. package/dist/core/workspace/resolve.js +124 -3
  40. package/dist/ports/adapter.d.ts +12 -0
  41. package/dist/schemas/profile.d.ts +6 -6
  42. package/package.json +1 -1
@@ -0,0 +1,104 @@
1
+ import type { ScopedStore } from '../../ports/state-store.ts';
2
+ import type { TickKind } from './orchestrator.ts';
3
+ /** 이 lease가 사는 열쇠. Monitor의 scan-lease와 다른 층이다 — 저쪽은 한 회차, 이쪽은 루프. */
4
+ export declare const RUNTIME_LEASE_KEY = "runtime-lease";
5
+ /** 마지막 회차 시각이 사는 열쇠. Orchestrator가 쓰고 status가 읽는다. */
6
+ export declare const LAST_RUN_KEY = "last-run";
7
+ /**
8
+ * lease가 죽은 것으로 보이기까지의 최소 시간.
9
+ *
10
+ * **주기보다 길어야 한다.** 10분마다 도는 루프에 5분 만료를 걸면 회차 사이에 자기 lease가
11
+ * 죽은 것으로 보이고, 두 번째 프로세스가 조용히 끼어든다. 그래서 회수 기준은 주기의
12
+ * 3배이며, 이 값은 그 아래로 내려가지 않게 막는 바닥이다.
13
+ */
14
+ export declare const MIN_STALE_MS: number;
15
+ /** 주기에서 회수 기준을 정한다. 상수를 Core에 박지 않는다 (C-12 불변식 ③). */
16
+ export declare const staleAfter: (intervalMs: number) => number;
17
+ export type LeaseRecord = {
18
+ /** 누가 잡고 있는가. 사람이 읽고 죽일 수 있어야 한다. */
19
+ owner: string;
20
+ pid: number;
21
+ /** 마지막으로 살아 있다고 말한 시각. 회차마다 갱신된다. */
22
+ at: string;
23
+ /** 언제부터 돌고 있는가. `at`과 달리 갱신되지 않는다. */
24
+ startedAt: string;
25
+ };
26
+ export type LeaseState =
27
+ /** 아무도 안 잡고 있다. */
28
+ {
29
+ kind: 'FREE';
30
+ }
31
+ /** 살아 있는 주인이 있다. */
32
+ | {
33
+ kind: 'HELD';
34
+ record: LeaseRecord;
35
+ }
36
+ /** 기록은 있는데 오래됐다 — 비정상 종료로 남은 것으로 본다. */
37
+ | {
38
+ kind: 'STALE';
39
+ record: LeaseRecord;
40
+ silentFor: number;
41
+ };
42
+ /**
43
+ * 루프 하나만 돌게 하는 lease.
44
+ *
45
+ * **이중 기동은 오류가 아니다** (C-12 불변식 ⑥). 늦게 온 쪽은 조용히 물러나고, 이미
46
+ * 도는 쪽이 계속한다. 비정상 종료로 남은 lease는 시간이 지나면 회수된다 — 한 번 죽었다고
47
+ * 영영 못 켜지면 그것이 더 나쁜 고장이다.
48
+ */
49
+ export declare class RuntimeLease {
50
+ #private;
51
+ constructor(deps: {
52
+ scope: ScopedStore;
53
+ owner: string;
54
+ pid?: number;
55
+ staleMs?: number;
56
+ now?: () => string;
57
+ });
58
+ /** 지금 누가 잡고 있는가. 읽기만 한다 — status가 이것을 그린다. */
59
+ read(): Promise<LeaseState>;
60
+ /**
61
+ * 잡는다. 이미 살아 있는 주인이 있으면 `false` — 조용히 물러나는 것이 계약이다.
62
+ *
63
+ * Monitor scan-lease와 같은 모양이다: `setIfAbsent` 로 원자적으로 걸고, 오래된 것만
64
+ * 지우고 다시 건다. 확인과 쓰기 사이의 틈은 Adapter가 막는다.
65
+ */
66
+ acquire(): Promise<boolean>;
67
+ /**
68
+ * 살아 있다고 말한다. 회차마다 부른다 — 이것이 없으면 주기가 만료보다 긴 순간
69
+ * 자기 lease가 죽은 것으로 보인다.
70
+ *
71
+ * **남의 lease를 갱신하지 않는다.** 주인이 바뀌어 있으면 `false` 를 돌려주고, 호출자는
72
+ * 그것을 "내가 밀려났다"로 읽는다.
73
+ */
74
+ renew(): Promise<boolean>;
75
+ /** 내 것일 때만 놓는다. 남이 이미 잡았으면 건드리지 않는다. */
76
+ release(): Promise<void>;
77
+ }
78
+ export type BackgroundStatus = {
79
+ lease: LeaseState;
80
+ /** 갈래별 마지막 실행 시각. 없는 갈래는 한 번도 돌지 않았다. */
81
+ lastRun: Partial<Record<TickKind, string>>;
82
+ /** 회수 기준. 사람이 "얼마나 조용하면 죽은 것인가"를 알아야 판단할 수 있다. */
83
+ staleMs: number;
84
+ };
85
+ /** 상태를 모은다. **읽기만 한다** — 보는 것이 상태를 바꾸면 아무도 못 본다. */
86
+ export declare function readBackground(scope: ScopedStore, staleMs: number, now?: () => string): Promise<BackgroundStatus>;
87
+ /**
88
+ * 사람이 읽는 줄.
89
+ *
90
+ * **"안 돌고 있다"와 "돌았는데 변화가 없다"를 합치지 않는다** (C-12 불변식 ⑫과 같은 태도).
91
+ * 그래서 lease 상태와 마지막 회차 시각을 각각 말한다 — 둘 중 하나만 보면 오해한다.
92
+ */
93
+ export declare function renderBackground(status: BackgroundStatus): string[];
94
+ /**
95
+ * 파일 하나짜리 ScopedStore (설계 §5.1).
96
+ *
97
+ * **서비스 lease 와 workspace lease 는 다른 소유 영역이다.** 저쪽은 "이 workspace 의
98
+ * 이번 회차를 누가 잡았는가"이고, 이쪽은 "이 기계의 runtime 을 지금 누가 도는가"다.
99
+ * 둘을 합치면 workspace 가 늘 때마다 기계 수준 직렬화가 무너진다.
100
+ *
101
+ * workspace 상태 저장소를 기계 뿌리에 열지 않는 이유: 그러면 ~/.asc 에 workspace 용
102
+ * 파일들이 생기고, 그것은 그 자리에 없어야 할 것들이다.
103
+ */
104
+ export declare function fileScope(path: string): ScopedStore;
@@ -0,0 +1,225 @@
1
+ // Background Runtime — 대화가 닫혀 있어도 도는 자리 (C-12 §0·§1.1).
2
+ //
3
+ // Orchestrator는 "언제 무엇을 부를지"를 이미 안다. 없던 것은 그 회차를 **누가 계속
4
+ // 돌리는가**와, 돌고 있는지를 **사람이 어떻게 보는가**였다:
5
+ //
6
+ // asc runtime start 터미널을 잡고 있어야 했다 — 창을 닫으면 감시가 죽는다
7
+ // asc runtime tick 한 회차만 돈다. 부를 사람이 없으면 아무 일도 안 일어난다
8
+ // asc runtime status 어느 빌드를 쓰는지만 답했다 (C-14 §4) — 감시가 사는지는 몰랐다
9
+ //
10
+ // 여기는 그 셋을 잇는 **상태**만 다룬다. 판정도, 감시도, 승인도 하지 않는다
11
+ // (C-12 불변식 ②). scheduler 제품도 모른다 (불변식 ④) — cron이 tick을 부르든, 떨어져
12
+ // 나간 프로세스가 스스로 돌든 같은 lease를 지난다.
13
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
14
+ import { dirname } from 'node:path';
15
+ /** 이 lease가 사는 열쇠. Monitor의 scan-lease와 다른 층이다 — 저쪽은 한 회차, 이쪽은 루프. */
16
+ export const RUNTIME_LEASE_KEY = 'runtime-lease';
17
+ /** 마지막 회차 시각이 사는 열쇠. Orchestrator가 쓰고 status가 읽는다. */
18
+ export const LAST_RUN_KEY = 'last-run';
19
+ /**
20
+ * lease가 죽은 것으로 보이기까지의 최소 시간.
21
+ *
22
+ * **주기보다 길어야 한다.** 10분마다 도는 루프에 5분 만료를 걸면 회차 사이에 자기 lease가
23
+ * 죽은 것으로 보이고, 두 번째 프로세스가 조용히 끼어든다. 그래서 회수 기준은 주기의
24
+ * 3배이며, 이 값은 그 아래로 내려가지 않게 막는 바닥이다.
25
+ */
26
+ export const MIN_STALE_MS = 5 * 60_000;
27
+ /** 주기에서 회수 기준을 정한다. 상수를 Core에 박지 않는다 (C-12 불변식 ③). */
28
+ export const staleAfter = (intervalMs) => Math.max(MIN_STALE_MS, intervalMs * 3);
29
+ /**
30
+ * 루프 하나만 돌게 하는 lease.
31
+ *
32
+ * **이중 기동은 오류가 아니다** (C-12 불변식 ⑥). 늦게 온 쪽은 조용히 물러나고, 이미
33
+ * 도는 쪽이 계속한다. 비정상 종료로 남은 lease는 시간이 지나면 회수된다 — 한 번 죽었다고
34
+ * 영영 못 켜지면 그것이 더 나쁜 고장이다.
35
+ */
36
+ export class RuntimeLease {
37
+ #scope;
38
+ #owner;
39
+ #pid;
40
+ #staleMs;
41
+ #now;
42
+ #startedAt;
43
+ constructor(deps) {
44
+ this.#scope = deps.scope;
45
+ this.#owner = deps.owner;
46
+ this.#pid = deps.pid ?? process.pid;
47
+ this.#staleMs = deps.staleMs ?? MIN_STALE_MS;
48
+ this.#now = deps.now ?? (() => new Date().toISOString());
49
+ }
50
+ /** 지금 누가 잡고 있는가. 읽기만 한다 — status가 이것을 그린다. */
51
+ async read() {
52
+ const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
53
+ if (!raw)
54
+ return { kind: 'FREE' };
55
+ let record;
56
+ try {
57
+ record = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ // 읽을 수 없는 lease는 죽은 것으로 본다. 다만 무엇이 있었는지는 남긴다.
61
+ return { kind: 'STALE', record: { owner: '(unreadable)', pid: 0, at: '', startedAt: '' }, silentFor: Infinity };
62
+ }
63
+ const silentFor = new Date(this.#now()).getTime() - new Date(record.at).getTime();
64
+ // 시계가 뒤로 갔거나 기록이 깨졌으면 살아 있는 쪽으로 읽는다 — 남의 lease를 뺏는
65
+ // 것보다 한 회차 쉬는 편이 싸다.
66
+ if (Number.isNaN(silentFor))
67
+ return { kind: 'HELD', record };
68
+ return silentFor >= this.#staleMs ? { kind: 'STALE', record, silentFor } : { kind: 'HELD', record };
69
+ }
70
+ /**
71
+ * 잡는다. 이미 살아 있는 주인이 있으면 `false` — 조용히 물러나는 것이 계약이다.
72
+ *
73
+ * Monitor scan-lease와 같은 모양이다: `setIfAbsent` 로 원자적으로 걸고, 오래된 것만
74
+ * 지우고 다시 건다. 확인과 쓰기 사이의 틈은 Adapter가 막는다.
75
+ */
76
+ async acquire() {
77
+ const at = this.#now();
78
+ this.#startedAt = at;
79
+ const mine = JSON.stringify({ owner: this.#owner, pid: this.#pid, at, startedAt: at });
80
+ if (await this.#scope.setIfAbsent(RUNTIME_LEASE_KEY, mine))
81
+ return true;
82
+ const state = await this.read();
83
+ if (state.kind === 'HELD')
84
+ return false;
85
+ await this.#scope.delete(RUNTIME_LEASE_KEY);
86
+ return this.#scope.setIfAbsent(RUNTIME_LEASE_KEY, mine);
87
+ }
88
+ /**
89
+ * 살아 있다고 말한다. 회차마다 부른다 — 이것이 없으면 주기가 만료보다 긴 순간
90
+ * 자기 lease가 죽은 것으로 보인다.
91
+ *
92
+ * **남의 lease를 갱신하지 않는다.** 주인이 바뀌어 있으면 `false` 를 돌려주고, 호출자는
93
+ * 그것을 "내가 밀려났다"로 읽는다.
94
+ */
95
+ async renew() {
96
+ const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
97
+ if (raw) {
98
+ try {
99
+ const held = JSON.parse(raw);
100
+ if (held.owner !== this.#owner)
101
+ return false;
102
+ this.#startedAt ??= held.startedAt;
103
+ }
104
+ catch {
105
+ // 못 읽는 lease 위에 내 것을 덮어쓴다 — 아래에서 다시 쓴다
106
+ }
107
+ }
108
+ const at = this.#now();
109
+ this.#startedAt ??= at;
110
+ await this.#scope.set(RUNTIME_LEASE_KEY, JSON.stringify({ owner: this.#owner, pid: this.#pid, at, startedAt: this.#startedAt }));
111
+ return true;
112
+ }
113
+ /** 내 것일 때만 놓는다. 남이 이미 잡았으면 건드리지 않는다. */
114
+ async release() {
115
+ const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
116
+ if (!raw)
117
+ return;
118
+ try {
119
+ if (JSON.parse(raw).owner !== this.#owner)
120
+ return;
121
+ }
122
+ catch {
123
+ // 못 읽는 것은 내가 남긴 것으로 보고 치운다
124
+ }
125
+ await this.#scope.delete(RUNTIME_LEASE_KEY);
126
+ }
127
+ }
128
+ /** 상태를 모은다. **읽기만 한다** — 보는 것이 상태를 바꾸면 아무도 못 본다. */
129
+ export async function readBackground(scope, staleMs, now = () => new Date().toISOString()) {
130
+ const lease = await new RuntimeLease({ scope, owner: '(reader)', staleMs, now }).read();
131
+ const raw = await scope.get(LAST_RUN_KEY);
132
+ let lastRun = {};
133
+ if (raw) {
134
+ try {
135
+ lastRun = JSON.parse(raw);
136
+ }
137
+ catch {
138
+ // 깨진 기록은 없는 것으로 본다 — 아래 렌더가 "한 번도 돌지 않았다"로 말한다
139
+ }
140
+ }
141
+ return { lease, lastRun, staleMs };
142
+ }
143
+ const ORDER = ['delta', 'reconcile', 'census', 'digest'];
144
+ /**
145
+ * 사람이 읽는 줄.
146
+ *
147
+ * **"안 돌고 있다"와 "돌았는데 변화가 없다"를 합치지 않는다** (C-12 불변식 ⑫과 같은 태도).
148
+ * 그래서 lease 상태와 마지막 회차 시각을 각각 말한다 — 둘 중 하나만 보면 오해한다.
149
+ */
150
+ export function renderBackground(status) {
151
+ const lines = [];
152
+ switch (status.lease.kind) {
153
+ case 'FREE':
154
+ lines.push('Background runtime: not running — `asc runtime start --detach` keeps it observing');
155
+ break;
156
+ case 'HELD':
157
+ lines.push(`Background runtime: running (pid ${status.lease.record.pid}, since ${status.lease.record.startedAt}, last heartbeat ${status.lease.record.at})`);
158
+ break;
159
+ case 'STALE':
160
+ lines.push(`Background runtime: not running — a lease from pid ${status.lease.record.pid} has been silent for ` +
161
+ `${Math.round(status.lease.silentFor / 60_000)} min and will be reclaimed on the next start`);
162
+ break;
163
+ }
164
+ const ran = ORDER.filter((kind) => status.lastRun[kind]);
165
+ if (ran.length === 0) {
166
+ // 설정만 하고 켜지 않은 상태다. 조용한 것과 구분해서 말한다.
167
+ lines.push(' no pass has run yet');
168
+ return lines;
169
+ }
170
+ for (const kind of ORDER) {
171
+ lines.push(` ${kind}: ${status.lastRun[kind] ?? 'never run'}`);
172
+ }
173
+ return lines;
174
+ }
175
+ /**
176
+ * 파일 하나짜리 ScopedStore (설계 §5.1).
177
+ *
178
+ * **서비스 lease 와 workspace lease 는 다른 소유 영역이다.** 저쪽은 "이 workspace 의
179
+ * 이번 회차를 누가 잡았는가"이고, 이쪽은 "이 기계의 runtime 을 지금 누가 도는가"다.
180
+ * 둘을 합치면 workspace 가 늘 때마다 기계 수준 직렬화가 무너진다.
181
+ *
182
+ * workspace 상태 저장소를 기계 뿌리에 열지 않는 이유: 그러면 ~/.asc 에 workspace 용
183
+ * 파일들이 생기고, 그것은 그 자리에 없어야 할 것들이다.
184
+ */
185
+ export function fileScope(path) {
186
+ const read = async () => {
187
+ try {
188
+ return JSON.parse(await readFile(path, 'utf8'));
189
+ }
190
+ catch {
191
+ // 없거나 깨진 파일은 빈 것으로 본다 — lease 는 시간으로 회수되므로 안전하다
192
+ return {};
193
+ }
194
+ };
195
+ // tmp+rename. 반쯤 쓰인 lease 를 다른 프로세스가 읽으면 판정이 흔들린다 (C-11 불변식 ⑨).
196
+ const write = async (data) => {
197
+ await mkdir(dirname(path), { recursive: true });
198
+ const tmp = `${path}.tmp-${process.pid}`;
199
+ await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
200
+ await rename(tmp, path);
201
+ };
202
+ return {
203
+ async get(key) {
204
+ return (await read())[key] ?? null;
205
+ },
206
+ async set(key, value) {
207
+ await write({ ...(await read()), [key]: value });
208
+ },
209
+ async delete(key) {
210
+ const data = await read();
211
+ delete data[key];
212
+ await write(data);
213
+ },
214
+ async keys(prefix) {
215
+ return Object.keys(await read()).filter((key) => (prefix ? key.startsWith(prefix) : true));
216
+ },
217
+ async setIfAbsent(key, value) {
218
+ const data = await read();
219
+ if (key in data)
220
+ return false;
221
+ await write({ ...data, [key]: value });
222
+ return true;
223
+ },
224
+ };
225
+ }
@@ -85,6 +85,54 @@ export type RestoreInput = {
85
85
  * 새로 계산하지 않는다. 여기서 파생을 만들면 그 파생이 곧 두 번째 정본이 된다.
86
86
  */
87
87
  export declare function restoreFront(input: RestoreInput): Promise<FrontState>;
88
+ /**
89
+ * 새 Front Session이 열렸을 때의 판정 (C-12 §4).
90
+ *
91
+ * **Host를 모른다.** Claude Code든 다른 무엇이든 "여기서 세션이 열렸다"는 사실 하나를
92
+ * 받고, 붙을 수 있는지와 무엇이 걸려 있는지를 답한다. Host adapter는 이 답을 자기
93
+ * 호스트의 형식으로 옮기기만 한다 — Core에 `if (host === 'claude')` 가 생기면 그 순간
94
+ * 두 번째 호스트는 Core를 고쳐야 들어온다.
95
+ *
96
+ * 세 갈래를 **구분한다**:
97
+ *
98
+ * NOT_ASC 이 경로는 ASC 소관이 아니다. 조용히 지나간다 — 남의 도구를 방해하지
99
+ * 않는다 (C-11 불변식 ⑪).
100
+ * UNAVAILABLE 붙어야 하는데 못 붙었다. **빈 화면을 주지 않고 왜인지 말한다**
101
+ * (C-12 불변식 ⑰).
102
+ * BOUND 붙었다. 지금 무엇이 걸려 있는지가 함께 온다.
103
+ */
104
+ export type FrontOpening = {
105
+ kind: 'NOT_ASC';
106
+ } | {
107
+ kind: 'UNAVAILABLE';
108
+ detail: string;
109
+ } | {
110
+ kind: 'BOUND';
111
+ state: FrontState;
112
+ };
113
+ /**
114
+ * 세션이 열렸다 — 붙을 수 있는가.
115
+ *
116
+ * **읽기만 한다.** 세션을 만들지 않는다: 실제 작업 요청이 없는데 Session Contract를
117
+ * 만들어 내는 것은 없던 사실을 제조하는 일이다 (AGENTS.md "Do not create a session to
118
+ * demonstrate that setup worked" 와 같은 선).
119
+ */
120
+ export declare function openFront(input: {
121
+ /** 이 경로가 어느 workspace인가. `null` 이면 ASC 소관이 아니다. */
122
+ workspace: {
123
+ workspaceId: string;
124
+ locator: string;
125
+ } | null;
126
+ /** 상태를 읽는다. 던지면 UNAVAILABLE 로 접힌다 — 못 읽은 것을 "없음"으로 그리지 않는다. */
127
+ restore: () => Promise<FrontState>;
128
+ }): Promise<FrontOpening>;
129
+ /**
130
+ * Host가 사람에게 보여 줄 줄. **Host별 포장은 여기 없다** — 문자열 목록까지가 Core다.
131
+ *
132
+ * `NOT_ASC` 는 빈 목록이다. 할 말이 없는 것과 "아무 일도 없다"는 다르고, 전자는
133
+ * 아무것도 띄우지 않는 것이 맞다.
134
+ */
135
+ export declare function frontOpeningLines(opening: FrontOpening): string[];
88
136
  /**
89
137
  * 사람이 읽는 복원 화면.
90
138
  *
@@ -63,6 +63,40 @@ export async function restoreFront(input) {
63
63
  ...(input.workspace ? { workspace: input.workspace } : {}),
64
64
  };
65
65
  }
66
+ /**
67
+ * 세션이 열렸다 — 붙을 수 있는가.
68
+ *
69
+ * **읽기만 한다.** 세션을 만들지 않는다: 실제 작업 요청이 없는데 Session Contract를
70
+ * 만들어 내는 것은 없던 사실을 제조하는 일이다 (AGENTS.md "Do not create a session to
71
+ * demonstrate that setup worked" 와 같은 선).
72
+ */
73
+ export async function openFront(input) {
74
+ if (!input.workspace)
75
+ return { kind: 'NOT_ASC' };
76
+ try {
77
+ return { kind: 'BOUND', state: await input.restore() };
78
+ }
79
+ catch (error) {
80
+ return { kind: 'UNAVAILABLE', detail: error instanceof Error ? error.message : String(error) };
81
+ }
82
+ }
83
+ /**
84
+ * Host가 사람에게 보여 줄 줄. **Host별 포장은 여기 없다** — 문자열 목록까지가 Core다.
85
+ *
86
+ * `NOT_ASC` 는 빈 목록이다. 할 말이 없는 것과 "아무 일도 없다"는 다르고, 전자는
87
+ * 아무것도 띄우지 않는 것이 맞다.
88
+ */
89
+ export function frontOpeningLines(opening) {
90
+ switch (opening.kind) {
91
+ case 'NOT_ASC':
92
+ return [];
93
+ case 'UNAVAILABLE':
94
+ // 조용히 빈 화면을 주지 않는다 (C-12 불변식 ⑰)
95
+ return [`ASC is attached here but its state could not be read — ${opening.detail}`];
96
+ case 'BOUND':
97
+ return renderFront(opening.state);
98
+ }
99
+ }
66
100
  /**
67
101
  * 사람이 읽는 복원 화면.
68
102
  *
@@ -70,7 +70,12 @@ export class SessionRuntime {
70
70
  return {
71
71
  ok: false,
72
72
  reason: 'NOT_OWNER',
73
- detail: `${id} 에는 Runtime이 붙어 있다 owner(${binding.physicalSessionId})만 기록할 있다`,
73
+ // owner가 누구인지만 말하면 owner 본인도 다음 명령을 모른다 pause/done은
74
+ // `--physical <owner>` 로 자격을 대고, 아니면 소유권을 먼저 놓는다 (실측 ASC-6).
75
+ // Core는 host 이름을 모른다(B-17) — `<host>` 자리는 Surface의 어휘다.
76
+ detail: `${id} 에는 Runtime이 붙어 있다 — owner(${binding.physicalSessionId})만 기록할 수 있다. ` +
77
+ `owner라면 --physical ${binding.physicalSessionId} 을 붙여 다시 실행하고, ` +
78
+ `그 세션이 끝났다면 asc host <host> release ${id} --physical ${binding.physicalSessionId} 로 소유권을 놓아라`,
74
79
  };
75
80
  }
76
81
  if (binding.physicalSessionId !== physicalSessionId) {
@@ -0,0 +1,39 @@
1
+ /** 운영 상태 — 별도 state machine 이 아니라 읽기 모델이다. */
2
+ export type WorkspaceHealth = 'ACTIVE' | 'DEGRADED' | 'DORMANT';
3
+ export type WorkspaceView = {
4
+ workspaceId: string;
5
+ /** 이 workspace 의 runtime 뿌리. */
6
+ root: string;
7
+ /** 지금 살아 있는 checkout 들. 비어 있으면 DORMANT 다. */
8
+ liveLocators: string[];
9
+ /** 등록돼 있지만 지금 없는 checkout 들. 지우지 않는다 — 돌아올 수 있다. */
10
+ missingLocators: string[];
11
+ health: WorkspaceHealth;
12
+ aliases: readonly string[];
13
+ };
14
+ export type WorkspaceInput = {
15
+ workspaceId: string;
16
+ root: string;
17
+ aliases: readonly string[];
18
+ locators: readonly string[];
19
+ };
20
+ /**
21
+ * 지금 이 기계의 상태를 계산한다. **판정도 삭제도 하지 않는다.**
22
+ *
23
+ * `rootExists` 가 거짓이면 runtime 자체가 사라진 것이다 — locator 가 살아 있어도
24
+ * 관측할 수 없으므로 DEGRADED 로 든다. 사라진 것을 조용히 지우지 않는 이유는 위와 같다.
25
+ */
26
+ export declare function viewWorkspaces(workspaces: readonly WorkspaceInput[], exists: (path: string) => boolean): WorkspaceView[];
27
+ /**
28
+ * 이번 회차에 실제로 돌 곳.
29
+ *
30
+ * DORMANT 는 **밖을 치지 않는다** (설계 §19). 살아 있는 checkout 이 없으면 그 저장소를
31
+ * 대신해 무엇을 물어볼 자리도 없고, 없는 자리를 대신해 외부에 질문하면 그것은 관측이
32
+ * 아니라 잡음이다.
33
+ */
34
+ export declare function dueWorkspaces(views: readonly WorkspaceView[]): {
35
+ workspaceId: string;
36
+ cwd: string;
37
+ }[];
38
+ /** 사람이 읽는 기계 전체 화면. `cd` 없이 지금 무엇이 도는지 보여야 한다 (설계 §13.2). */
39
+ export declare function renderWorkspaces(views: readonly WorkspaceView[]): string[];
@@ -0,0 +1,64 @@
1
+ // 이 기계가 돌보는 workspace 들 (설계 §6·§7).
2
+ //
3
+ // **두 번째 등록부를 만들지 않는다.** `monitorEnabled=true` 같은 플래그를 새로 두면 그것이
4
+ // 곧 두 번째 정본이 되고, 붙어 있는데 감시되지 않는(또는 그 반대의) 상태가 생긴다.
5
+ // 운영 상태는 **이미 있는 사실에서 계산한다**:
6
+ //
7
+ // workspace 가 index 에 있다 → 이 기계가 아는 workspace 다
8
+ // 살아 있는 locator 가 하나라도 있다 → 관측할 수 있다
9
+ // 하나도 없다 → DORMANT — 상태는 남기고 밖을 치지 않는다
10
+ //
11
+ // DORMANT 는 삭제 대상이 아니다 (설계 §3.9). checkout 을 잠시 지웠다고 몇 달치 기록을
12
+ // 버리면, 그 삭제를 되돌릴 방법이 없다. 새 locator 가 나타나면 다시 ACTIVE 가 된다.
13
+ /**
14
+ * 지금 이 기계의 상태를 계산한다. **판정도 삭제도 하지 않는다.**
15
+ *
16
+ * `rootExists` 가 거짓이면 runtime 자체가 사라진 것이다 — locator 가 살아 있어도
17
+ * 관측할 수 없으므로 DEGRADED 로 든다. 사라진 것을 조용히 지우지 않는 이유는 위와 같다.
18
+ */
19
+ export function viewWorkspaces(workspaces, exists) {
20
+ return workspaces.map((workspace) => {
21
+ const liveLocators = workspace.locators.filter((locator) => exists(locator));
22
+ const missingLocators = workspace.locators.filter((locator) => !exists(locator));
23
+ const rootExists = exists(workspace.root);
24
+ const health = liveLocators.length === 0 ? 'DORMANT' : rootExists ? 'ACTIVE' : 'DEGRADED';
25
+ return {
26
+ workspaceId: workspace.workspaceId,
27
+ root: workspace.root,
28
+ liveLocators,
29
+ missingLocators,
30
+ health,
31
+ aliases: workspace.aliases,
32
+ };
33
+ });
34
+ }
35
+ /**
36
+ * 이번 회차에 실제로 돌 곳.
37
+ *
38
+ * DORMANT 는 **밖을 치지 않는다** (설계 §19). 살아 있는 checkout 이 없으면 그 저장소를
39
+ * 대신해 무엇을 물어볼 자리도 없고, 없는 자리를 대신해 외부에 질문하면 그것은 관측이
40
+ * 아니라 잡음이다.
41
+ */
42
+ export function dueWorkspaces(views) {
43
+ return views
44
+ .filter((view) => view.health === 'ACTIVE')
45
+ // 여러 checkout 이 있으면 아무 곳에서나 한 번이면 된다 — 관측 대상은 workspace 이지
46
+ // checkout 이 아니다. 첫 번째를 쓰는 것은 안정적인 선택이다(목록 순서가 index 순서다).
47
+ .map((view) => ({ workspaceId: view.workspaceId, cwd: view.liveLocators[0] }));
48
+ }
49
+ /** 사람이 읽는 기계 전체 화면. `cd` 없이 지금 무엇이 도는지 보여야 한다 (설계 §13.2). */
50
+ export function renderWorkspaces(views) {
51
+ if (views.length === 0)
52
+ return ['No workspaces are registered on this machine.'];
53
+ const lines = [];
54
+ for (const view of views) {
55
+ const alias = view.aliases[0] ?? '(no alias)';
56
+ lines.push(`${view.workspaceId} ${view.health} ${alias}`);
57
+ for (const locator of view.liveLocators)
58
+ lines.push(` ${locator}`);
59
+ // 없어진 checkout 도 든다 — 지우지 않았다는 사실이 보여야 사람이 놀라지 않는다
60
+ for (const locator of view.missingLocators)
61
+ lines.push(` ${locator} (gone)`);
62
+ }
63
+ return lines;
64
+ }
@@ -11,6 +11,21 @@ export type Resolution = {
11
11
  workspaceId: string;
12
12
  locator: string;
13
13
  }
14
+ /**
15
+ * 등록된 적은 없지만 **같은 Git repository의 다른 checkout이 등록돼 있다.**
16
+ * 같은 논리 workspace의 다른 execution instance다 (C-11 §1.3) — 쪼개지도 합치지도 않는다.
17
+ */
18
+ | {
19
+ kind: 'LINKED_WORKTREE';
20
+ root: string;
21
+ workspaceId: string;
22
+ /** 지금 이 checkout의 최상위. 호출자가 이 값을 index에 등록해 다음 번을 빠르게 만든다. */
23
+ locator: string;
24
+ /** 근거가 된, 이미 등록돼 있던 형제 checkout. */
25
+ via: string;
26
+ /** main checkout인가 linked worktree인가. */
27
+ kindOfLocator: 'checkout' | 'worktree';
28
+ }
14
29
  /** 저장소 안의 `.asc/` — 팀이 채택했거나 아직 이전하지 않은 개인 legacy다. */
15
30
  | {
16
31
  kind: 'PROJECT_LOCAL';
@@ -28,7 +43,28 @@ export type ResolveInput = {
28
43
  /** 여기를 넘어서는 위로 올라가지 않는다. 보통 사용자 홈. */
29
44
  stopAt?: string;
30
45
  exists?: (path: string) => Promise<boolean>;
46
+ /**
47
+ * 이 경로가 속한 Git repository의 checkout 전부(main + linked). 없으면 이 갈래를 건너뛴다.
48
+ *
49
+ * **주입이 기본값이다** — guard hook처럼 매 호출마다 도는 소비자는 이것을 넘기지 않고,
50
+ * 그러면 예전과 똑같이 index만 본다.
51
+ */
52
+ worktrees?: WorktreeProbe;
31
53
  };
54
+ /**
55
+ * 같은 Git repository에 속한 checkout 경로들. 첫 번째가 main worktree다.
56
+ * git이 없거나 저장소가 아니면 `null` — "없다"와 "못 봤다"를 구분한다.
57
+ */
58
+ export type WorktreeProbe = (cwd: string) => Promise<readonly string[] | null>;
32
59
  export declare function resolveWorkspace(input: ResolveInput): Promise<Resolution>;
60
+ /**
61
+ * 기본 통로 — `git worktree list --porcelain`. 첫 `worktree` 항목이 main checkout이다.
62
+ *
63
+ * git이 없거나 저장소가 아니면 `null`을 돌려준다. 빈 배열이 아니다 — "checkout이 하나도
64
+ * 없다"와 "물어보지 못했다"는 다른 사실이고, 후자를 전자로 적으면 조용한 오판이 된다.
65
+ */
66
+ export declare const gitWorktrees: WorktreeProbe;
67
+ /** porcelain 출력에서 checkout 경로만. 순서를 보존한다 — 첫 줄이 main worktree다. */
68
+ export declare function parseWorktreeList(stdout: string): string[];
33
69
  /** 사람이 읽는 한 줄. 왜 그 뿌리인지가 함께 와야 사람이 틀린 결합을 알아챈다. */
34
70
  export declare function resolutionLine(resolution: Resolution): string;