@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
@@ -28,21 +28,29 @@ export async function composeBindings(input) {
28
28
  const adapters = input.adapters ?? defaultAdapters();
29
29
  const bindings = [];
30
30
  const runtimes = [];
31
+ // Profile 이 선언한 것을 발견 단계에 알려 준다. adapter 가 지역 흔적을 못 찾아도
32
+ // 사람이 적어 둔 결정은 후보가 될 수 있다 — 되는지는 여전히 probe 가 정한다 (C-09 §3.1).
33
+ const context = {
34
+ ...input.context,
35
+ ...(input.roles?.length
36
+ ? { declared: input.roles.map((role) => ({ adapterId: role.adapterId, resource: role.resource })) }
37
+ : {}),
38
+ };
31
39
  for (const adapter of adapters) {
32
40
  // 도구가 쓸 수 있는가와 이 프로젝트가 그 도구에 붙어 있는가는 다른 사실이다.
33
41
  // 합치면 사람이 "설치할 일인지 붙일 일인지"를 알 수 없다.
34
42
  if (adapter.runtime) {
35
43
  const status = await adapter
36
- .runtime(input.context)
44
+ .runtime(context)
37
45
  .catch((error) => ({ state: 'UNAVAILABLE', detail: String(error) }));
38
46
  runtimes.push({ adapterId: adapter.describe().id, ...status });
39
47
  }
40
- const candidates = await adapter.discover(input.context).catch(() => []);
48
+ const candidates = await adapter.discover(context).catch(() => []);
41
49
  for (const candidate of candidates) {
42
50
  // probe가 터지는 것과 "안 된다"는 다르다. 예외를 UNAVAILABLE로 옮겨 적되
43
51
  // 이유를 남긴다 — 조용히 후보에서 빼면 왜 안 보이는지 알 수 없다.
44
52
  const result = await adapter
45
- .probe(candidate, input.context)
53
+ .probe(candidate, context)
46
54
  .catch((error) => ({ state: 'UNAVAILABLE', detail: String(error) }));
47
55
  const role = input.roles?.find((r) => r.adapterId === candidate.adapterId && r.resource === candidate.resource)?.role;
48
56
  bindings.push({
@@ -62,4 +62,37 @@ export declare function rolesFor(plan: BindingPlan, declared: readonly {
62
62
  adapter: string;
63
63
  resource: string;
64
64
  }[]): Partial<Record<Capability, string>>;
65
+ /**
66
+ * 한 binding 이 여는 관측 통로 하나.
67
+ *
68
+ * **Monitor Core 는 이것을 여러 개 받는 것이 아니라, 하나씩 여러 번 받는다** — 채널마다
69
+ * 자기 cursor·coverage·observation ledger 를 갖는 별개의 Run 이다. 그래서 Core 에
70
+ * provider 분기도, multi-source 개념도 생기지 않는다 (설계 §8.1).
71
+ */
72
+ export type ObservationChannel = {
73
+ /** Profile 이 선언한 역할. 선언이 없으면 발견된 자리라는 뜻으로 비어 있다. */
74
+ role?: string;
75
+ adapterId: string;
76
+ resource: string;
77
+ /** DEGRADED 도 채널이다 — 일부만 되는 것과 안 되는 것은 다르다. */
78
+ state: ResolvedBinding['state'];
79
+ detail?: string;
80
+ eventSource: EventSource;
81
+ inventory?: InventoryPort;
82
+ resourceContext?: ResourceContextPort;
83
+ changeContext?: ChangeContextPort;
84
+ };
85
+ export type ObservationChannels = {
86
+ channels: ObservationChannel[];
87
+ /** 무엇을 왜 못 열었는지. 조용히 빠지면 사람이 이유를 알 수 없다. */
88
+ unavailable: string[];
89
+ };
90
+ /**
91
+ * 선언된 binding 마다 관측 통로를 하나씩 연다.
92
+ *
93
+ * **선언이 있으면 선언만 본다.** 과거 mirror 로 남은 remote 가 발견됐다는 이유로 채널이
94
+ * 하나 더 생기면, 사람이 고르지 않은 곳을 감시하게 된다 — 발견은 후보이지 결합이 아니다
95
+ * (C-11 §7). 선언이 하나도 없으면 발견된 것을 쓰되, 그때는 갈리면 갈린다고 말한다.
96
+ */
97
+ export declare function buildObservationChannels(input: BuildInput): Promise<ObservationChannels>;
65
98
  export declare function buildRuntimePorts(input: BuildInput): Promise<RuntimePorts>;
@@ -140,6 +140,80 @@ export function rolesFor(plan, declared) {
140
140
  }
141
141
  return roles;
142
142
  }
143
+ /**
144
+ * 관측 capability — 이 셋은 **하나를 고르는 문제가 아니다** (설계 §8).
145
+ *
146
+ * 코드가 GitLab 에 있고 작업 항목이 Jira 에 있는 프로젝트에서 둘 다 봐야 한다는 것은
147
+ * 요구이지 모호함이 아니다. 그런데 지금까지는 같은 capability 를 둘이 제공한다는
148
+ * 이유만으로 AMBIGUOUS 가 되어 감시가 통째로 서지 않았다.
149
+ *
150
+ * 그래서 capability 를 두 부류로 가른다:
151
+ *
152
+ * singular canonical.read 처럼 **한 곳이어야** 의미가 서는 것 → 역할로 하나를 고른다
153
+ * observation observe.delta · inventory.enumerate · context.resource → binding 마다 하나씩
154
+ */
155
+ const OBSERVATION_CAPABILITIES = [
156
+ 'observe.delta',
157
+ 'inventory.enumerate',
158
+ 'context.resource',
159
+ ];
160
+ /**
161
+ * 선언된 binding 마다 관측 통로를 하나씩 연다.
162
+ *
163
+ * **선언이 있으면 선언만 본다.** 과거 mirror 로 남은 remote 가 발견됐다는 이유로 채널이
164
+ * 하나 더 생기면, 사람이 고르지 않은 곳을 감시하게 된다 — 발견은 후보이지 결합이 아니다
165
+ * (C-11 §7). 선언이 하나도 없으면 발견된 것을 쓰되, 그때는 갈리면 갈린다고 말한다.
166
+ */
167
+ export async function buildObservationChannels(input) {
168
+ const usable = input.plan.bindings.filter((binding) => (binding.state === 'AVAILABLE' || binding.state === 'DEGRADED') &&
169
+ OBSERVATION_CAPABILITIES.some((capability) => binding.provides.includes(capability)));
170
+ const declared = usable.filter((binding) => binding.role !== undefined);
171
+ const chosen = declared.length > 0 ? declared : usable;
172
+ const channels = [];
173
+ const unavailable = [];
174
+ if (chosen.length === 0) {
175
+ const blocked = input.plan.bindings.filter((binding) => OBSERVATION_CAPABILITIES.some((capability) => binding.provides.includes(capability)));
176
+ unavailable.push(blocked.length > 0
177
+ ? `관측 통로를 제공하는 binding 이 있으나 지금 쓸 수 없다 (${blocked
178
+ .map((binding) => `${binding.adapterId}: ${binding.state}`)
179
+ .join(', ')})`
180
+ : '관측 통로를 제공하는 binding 이 없다');
181
+ return { channels, unavailable };
182
+ }
183
+ const findToken = input.findToken ??
184
+ (async (adapterId) => adapterId === 'gitlab' ? await discoverGitLabAccess() : await discoverToken());
185
+ for (const binding of chosen) {
186
+ const where = `${binding.adapterId}:${binding.resource}`;
187
+ const factory = FACTORIES[binding.adapterId];
188
+ if (!factory) {
189
+ unavailable.push(`${where}: 이 빌드에 조립 경로가 없다`);
190
+ continue;
191
+ }
192
+ const token = TOKENLESS.has(binding.adapterId) ? '' : await findToken(binding.adapterId);
193
+ if (token === null) {
194
+ // 자격이 없는 것은 "변화 없음"이 아니다 — 그 채널만 빠지고 이유가 남는다
195
+ unavailable.push(`${where}: 자격이 없어 관측 통로를 만들지 않았다`);
196
+ continue;
197
+ }
198
+ const made = factory(binding, input, token);
199
+ if (!made.eventSource) {
200
+ unavailable.push(`${where}: ${binding.adapterId} 가 관측 통로를 만들지 않았다`);
201
+ continue;
202
+ }
203
+ channels.push({
204
+ ...(binding.role ? { role: binding.role } : {}),
205
+ adapterId: binding.adapterId,
206
+ resource: binding.resource,
207
+ state: binding.state,
208
+ ...(binding.detail ? { detail: binding.detail } : {}),
209
+ eventSource: made.eventSource,
210
+ ...(made.inventory ? { inventory: made.inventory } : {}),
211
+ ...(made.resourceContext ? { resourceContext: made.resourceContext } : {}),
212
+ ...(made.changeContext ? { changeContext: made.changeContext } : {}),
213
+ });
214
+ }
215
+ return { channels, unavailable };
216
+ }
143
217
  export async function buildRuntimePorts(input) {
144
218
  const ports = { unavailable: [] };
145
219
  // 자격은 adapter마다 다른 곳에 있다. Core는 이 사실을 모르고, 여기서만 안다.
@@ -14,6 +14,14 @@ export type SetupState = {
14
14
  git: boolean;
15
15
  /** 이미 붙어 있으면 그 runtime 뿌리. 없으면 안 붙은 것이다. */
16
16
  ascRoot?: string;
17
+ /**
18
+ * runtime 디렉터리는 있는데 profile.lock을 읽지 못하는 상태 — 붙이다 만 것이다.
19
+ *
20
+ * 이것을 "붙어 있음"으로 읽으면 plan은 `applied`를 답하면서 실패할 `asc proceed`를
21
+ * 다음 행동으로 준다 (Windows 실전 실측 ASC-2: 파일 잠금이 빈 skeleton만 남긴
22
+ * 경우). 붙이다 만 상태는 붙일 것이 남은 상태다 — repair가 plan에 드러나야 한다.
23
+ */
24
+ attachmentBroken?: boolean;
17
25
  /** 붙어 있다면 무엇으로 붙었는가. */
18
26
  attachedProfile?: string;
19
27
  /** 사람이 `--profile` 로 지정한 것. */
@@ -34,6 +42,36 @@ export type SetupState = {
34
42
  * 그때는 설치된 `asc` 를 전제하지 않는다.
35
43
  */
36
44
  stableRuntime?: StableInstallState;
45
+ /**
46
+ * 이 기계의 지속 등록 상태 (설계 §6, Gate 7).
47
+ *
48
+ * **별도 onboarding 을 만들지 않는다.** 사용자가 `runtime enable` 을 따로 치게 하면
49
+ * 그것이 곧 "켜는 행위"가 되고, 이 제품이 없애려던 바로 그 단계다. 같은 plan 이
50
+ * runtime·host·workspace 와 함께 판단한다.
51
+ */
52
+ persistentRuntime?: {
53
+ action: 'none' | 'install' | 'unsupported';
54
+ adapter: string;
55
+ detail?: string;
56
+ };
57
+ /**
58
+ * Profile 이 선언한 작업 항목 결합의 준비 상태 (설계 §9.3).
59
+ *
60
+ * **선언은 이미 내려진 결정이다.** 그런데 그 도구가 준비되지 않았다는 이유로 setup 이
61
+ * 사람에게 "그 도구를 설정할까요?"라고 되물으면, 사람은 자기가 이미 적어 둔 것을 다시
62
+ * 답하게 된다. 고칠 수 있는 것은 고치고, 사람만 할 수 있는 것에서만 멈춘다.
63
+ */
64
+ workBinding?: {
65
+ adapter: string;
66
+ resource: string;
67
+ /** 지금 쓸 수 있는가. 쓸 수 있으면 아래 값들은 보지 않는다. */
68
+ ready: boolean;
69
+ /** 고칠 수 있는가, 사람이 해야 하는가, 다시 돌려도 소용없는가. */
70
+ remedy?: 'SELF_HEAL' | 'HUMAN' | 'HARD';
71
+ detail?: string;
72
+ /** 그 도구가 말한 자기 버전. 없으면 부를 수 없다 — 버전을 지어내지 않는다. */
73
+ version?: string;
74
+ };
37
75
  };
38
76
  export type SetupChange =
39
77
  /**
@@ -59,6 +97,27 @@ export type SetupChange =
59
97
  target: 'host-install';
60
98
  host: string;
61
99
  from: string;
100
+ }
101
+ /**
102
+ * Profile 이 선언한 작업 도구를 **그 도구의 공식 setup 으로** 되살린다 (설계 §9.3).
103
+ *
104
+ * ASC 가 그 도구의 설정을 손으로 조립하지 않는다 — 부르기만 한다.
105
+ */
106
+ | {
107
+ target: 'work-binding-setup';
108
+ adapter: string;
109
+ resource: string;
110
+ version: string;
111
+ }
112
+ /**
113
+ * 이 기계에 ASC runtime 을 등록한다 (설계 §4).
114
+ *
115
+ * **workspace 마다가 아니라 기계당 하나다.** 그래서 이 변경은 프로젝트와 무관하고,
116
+ * 붙는 것과 같은 계획에 함께 실린다.
117
+ */
118
+ | {
119
+ target: 'persistent-runtime';
120
+ adapter: string;
62
121
  };
63
122
  export type SetupStatus = 'already_configured' | 'ready_to_apply' | 'user_action_required';
64
123
  export type SetupCode =
@@ -67,7 +126,12 @@ export type SetupCode =
67
126
  /** 저장소에 두는 것은 팀의 결정이다 (C-11 불변식 ⑤). */
68
127
  | 'ASC_PROJECT_SCOPE_REQUIRES_CONSENT'
69
128
  /** 설치물을 사람이 고쳤다 — 덮는 것은 사람이 정한다 (L-5). */
70
- | 'ASC_HOST_INSTALL_MODIFIED';
129
+ | 'ASC_HOST_INSTALL_MODIFIED'
130
+ /**
131
+ * 작업 도구가 사람을 기다린다 — 자격 입력처럼 ASC 가 대신할 수 없는 것 (설계 §9.4).
132
+ * ASC 는 토큰을 받지도 저장하지도 않는다.
133
+ */
134
+ | 'ASC_WORK_BINDING_NEEDS_USER';
71
135
  /**
72
136
  * 다음에 할 일 하나. **두 형태를 함께 든다** (C-14 §3.4, 불변식 ⑯).
73
137
  *
@@ -118,6 +182,14 @@ export type SetupEffects = {
118
182
  installHost(change: Extract<SetupChange, {
119
183
  target: 'host-install';
120
184
  }>): Promise<void>;
185
+ /** 작업 도구의 공식 setup 을 부른다. ASC 가 그 설정을 조립하지 않는다. */
186
+ setupWorkBinding?(change: Extract<SetupChange, {
187
+ target: 'work-binding-setup';
188
+ }>): Promise<void>;
189
+ /** 이 기계에 runtime 을 등록한다. OS 별 형식은 adapter 뒤에 있다. */
190
+ registerPersistentRuntime?(change: Extract<SetupChange, {
191
+ target: 'persistent-runtime';
192
+ }>): Promise<void>;
121
193
  };
122
194
  export type ApplyResult = {
123
195
  applied: SetupChange[];
@@ -17,7 +17,9 @@ export function computeSetupPlan(state) {
17
17
  const evidence = [
18
18
  `project=${state.projectRoot}`,
19
19
  state.git ? 'git=yes' : 'git=no',
20
- state.ascRoot ? `attached=${state.ascRoot}` : 'attached=no',
20
+ state.ascRoot
21
+ ? `attached=${state.ascRoot}${state.attachmentBroken ? ' (BROKEN — profile.lock unreadable)' : ''}`
22
+ : 'attached=no',
21
23
  `scope=${state.scope}`,
22
24
  ];
23
25
  // 지금 명령이 어디서 도는가. 설치된 `asc` 가 없으면 bootstrap이고, 그때 agent에게
@@ -76,7 +78,40 @@ export function computeSetupPlan(state) {
76
78
  changes.push({ target: 'host-install', host: host.id, from: host.status });
77
79
  }
78
80
  }
79
- if (state.ascRoot) {
81
+ // 기계의 지속 등록. 프로젝트와 무관하므로 profile 선택을 기다리지 않는다 —
82
+ // stable runtime 설치와 같은 자리다.
83
+ if (state.persistentRuntime) {
84
+ const persistent = state.persistentRuntime;
85
+ evidence.push(`persistent=${persistent.action} (${persistent.adapter})`);
86
+ // 쓸 수 없는 OS 에서는 계획에 담지 않는다. 못 하는 것을 "할 일"로 적지 않는다.
87
+ if (persistent.action === 'install') {
88
+ changes.push({ target: 'persistent-runtime', adapter: persistent.adapter });
89
+ }
90
+ }
91
+ // Profile 이 선언한 작업 도구. **다시 묻지 않는다** — 결정은 이미 Profile 에 있다.
92
+ if (state.workBinding && !state.workBinding.ready) {
93
+ const work = state.workBinding;
94
+ evidence.push(`work:${work.adapter}=${work.remedy ?? 'NOT_READY'}`);
95
+ if (work.remedy === 'HUMAN') {
96
+ return {
97
+ status: 'user_action_required',
98
+ code: 'ASC_WORK_BINDING_NEEDS_USER',
99
+ changes,
100
+ requiresUserAction: true,
101
+ ...actions(mode, evidence, [{ type: 'proceed', ...command(['setup', 'status']) }]),
102
+ };
103
+ }
104
+ // 고칠 수 있는 것만 계획에 담는다. HARD 는 담지 않는다 — 다시 돌려도 달라지지 않는다.
105
+ if (work.remedy === 'SELF_HEAL' && work.version) {
106
+ changes.push({
107
+ target: 'work-binding-setup',
108
+ adapter: work.adapter,
109
+ resource: work.resource,
110
+ version: work.version,
111
+ });
112
+ }
113
+ }
114
+ if (state.ascRoot && !state.attachmentBroken) {
80
115
  // 붙어 있어도 **무엇을 고를 수 있었는지**는 사실이다. 사용자 소유 Profile을 새로 놓고
81
116
  // 계획을 물었을 때 그것이 어디에도 안 보이면, 놓은 사람은 경로를 의심하게 된다.
82
117
  if (state.profileCandidates.length > 0) {
@@ -84,7 +119,8 @@ export function computeSetupPlan(state) {
84
119
  }
85
120
  return finish(changes, evidence, state, mode, command);
86
121
  }
87
- // 아직 안 붙었다. 무엇으로 붙을지는 사람이 정한다.
122
+ // 아직 안 붙었거나, 붙이다 말았다(BROKEN). 무엇으로 붙을지는 사람이 정한다 —
123
+ // BROKEN이면 같은 선택으로 다시 붙이는 것이 repair다.
88
124
  const profile = state.requestedProfile ?? soleCandidate(state.profileCandidates);
89
125
  if (!profile) {
90
126
  evidence.push(`profile candidates=${state.profileCandidates.join(', ') || '(none)'}`);
@@ -171,6 +207,19 @@ export async function applySetupPlan(plan, effects) {
171
207
  case 'host-install':
172
208
  await effects.installHost(change);
173
209
  break;
210
+ case 'persistent-runtime':
211
+ // 이 갈래를 모르는 호출자에게는 이 변경이 없던 것으로 남는다.
212
+ if (!effects.registerPersistentRuntime)
213
+ continue;
214
+ await effects.registerPersistentRuntime(change);
215
+ break;
216
+ case 'work-binding-setup':
217
+ // 이 갈래를 모르는 호출자에게는 이 변경이 없던 것으로 남는다 —
218
+ // 안 한 것을 "했다"로 적지 않는다.
219
+ if (!effects.setupWorkBinding)
220
+ continue;
221
+ await effects.setupWorkBinding(change);
222
+ break;
174
223
  }
175
224
  applied.push(change);
176
225
  }
@@ -201,5 +250,9 @@ function changeLine(change) {
201
250
  return ` attach: ${change.profile} · scope ${change.scope}`;
202
251
  case 'host-install':
203
252
  return ` converge host installation: ${change.host} (currently ${change.from})`;
253
+ case 'work-binding-setup':
254
+ return ` repair ${change.adapter} for ${change.resource} through its own setup (${change.version})`;
255
+ case 'persistent-runtime':
256
+ return ` register this machine's ASC runtime with ${change.adapter}`;
204
257
  }
205
258
  }
@@ -0,0 +1,22 @@
1
+ export type ResolvedInvocation = {
2
+ command: string;
3
+ args: string[];
4
+ };
5
+ export type ResolveDeps = {
6
+ platform?: NodeJS.Platform;
7
+ env?: NodeJS.ProcessEnv;
8
+ /** 테스트 주입용 — 실제 파일시스템을 보지 않게 한다. */
9
+ exists?: (path: string) => boolean;
10
+ readText?: (path: string) => string | null;
11
+ nodePath?: string;
12
+ };
13
+ /**
14
+ * npm `.cmd` shim이 가리키는 JS 진입점.
15
+ *
16
+ * npm이 쓰는 shim은 두 세대가 있고 둘 다 `"%dp0%\<상대경로>" %*` 형태로 JS를 부른다:
17
+ * "%_prog%" "%dp0%\node_modules\<pkg>\<bin>.js" %*
18
+ * "%dp0%\node.exe" "%dp0%\node_modules\<pkg>\<bin>.js" %*
19
+ * 형태가 다르면 null — 아는 척하지 않고 cmd.exe 경로로 넘어간다.
20
+ */
21
+ export declare function shimTarget(shimText: string): string | null;
22
+ export declare function resolveExternalCommand(command: string, args: readonly string[], deps?: ResolveDeps): ResolvedInvocation;
@@ -0,0 +1,79 @@
1
+ // 바깥 CLI를 Windows에서도 실제로 찾아 부른다 (C-14 §11의 연장).
2
+ //
3
+ // Node는 보안 수정 이후 shell 없이 `.cmd` 를 실행하지 않는다. 그런데 npm이 전역 설치로
4
+ // 만들어 주는 명령은 Windows에서 전부 `.cmd` shim이다 — bare 이름을 Unix 방식으로만
5
+ // spawn하면 ENOENT/EINVAL이 나고, 호출자는 "설치돼 있지 않다"고 오판한다
6
+ // (Windows 실전 실측: shim이 PATH에 실재하는데 host probe가 not found →
7
+ // external_write_guard STOP까지 이어졌다).
8
+ //
9
+ // shell을 켜는 것은 답이 아니다 — 인자가 escape 없이 이어붙는다(DEP0190). 대신:
10
+ // ① PATH에서 `.exe` 를 찾으면 그대로 부른다 (shell 불필요).
11
+ // ② `.cmd` shim이면 그 안이 가리키는 JS 진입점을 읽어 지금 도는 node로 직접 부른다 —
12
+ // cli/asc.ts의 npm 해석(resolveCommand)과 같은 태도다.
13
+ // ③ shim을 못 읽으면 cmd.exe /d /c 로 그 .cmd 를 부른다 — cmd.exe는 진짜 실행 파일이라
14
+ // shell 옵션이 필요 없다.
15
+ // 셋 다 실패하면 이름 그대로 돌려준다 — PATH에 진짜 실행 파일이 있는 환경이 그 경우다.
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { delimiter as winDelimiter, dirname, extname, isAbsolute, join } from 'node:path/win32';
18
+ const defaultRead = (path) => {
19
+ try {
20
+ return readFileSync(path, 'utf8');
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ };
26
+ /**
27
+ * npm `.cmd` shim이 가리키는 JS 진입점.
28
+ *
29
+ * npm이 쓰는 shim은 두 세대가 있고 둘 다 `"%dp0%\<상대경로>" %*` 형태로 JS를 부른다:
30
+ * "%_prog%" "%dp0%\node_modules\<pkg>\<bin>.js" %*
31
+ * "%dp0%\node.exe" "%dp0%\node_modules\<pkg>\<bin>.js" %*
32
+ * 형태가 다르면 null — 아는 척하지 않고 cmd.exe 경로로 넘어간다.
33
+ */
34
+ export function shimTarget(shimText) {
35
+ const match = /"%dp0%\\([^"%]+\.(?:js|mjs|cjs))"/i.exec(shimText);
36
+ return match ? match[1] : null;
37
+ }
38
+ export function resolveExternalCommand(command, args, deps = {}) {
39
+ const platform = deps.platform ?? process.platform;
40
+ if (platform !== 'win32')
41
+ return { command, args: [...args] };
42
+ // 경로나 확장자를 이미 갖췄으면 호출자가 알고 부르는 것이다 — 손대지 않는다.
43
+ if (isAbsolute(command) || command.includes('/') || command.includes('\\') || extname(command) !== '') {
44
+ return { command, args: [...args] };
45
+ }
46
+ const env = deps.env ?? process.env;
47
+ const exists = deps.exists ?? existsSync;
48
+ const readText = deps.readText ?? defaultRead;
49
+ const nodePath = deps.nodePath ?? process.execPath;
50
+ const pathValue = env.PATH ?? env.Path ?? '';
51
+ let firstShim = null;
52
+ for (const dir of pathValue.split(winDelimiter)) {
53
+ if (!dir)
54
+ continue;
55
+ const exe = join(dir, `${command}.exe`);
56
+ if (exists(exe))
57
+ return { command: exe, args: [...args] };
58
+ if (!firstShim) {
59
+ for (const ext of ['.cmd', '.bat']) {
60
+ const shim = join(dir, `${command}${ext}`);
61
+ if (exists(shim)) {
62
+ firstShim = shim;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ if (firstShim) {
69
+ const text = readText(firstShim);
70
+ const target = text ? shimTarget(text) : null;
71
+ if (target) {
72
+ const script = join(dirname(firstShim), target);
73
+ if (exists(script))
74
+ return { command: nodePath, args: [script, ...args] };
75
+ }
76
+ return { command: 'cmd.exe', args: ['/d', '/c', firstShim, ...args] };
77
+ }
78
+ return { command, args: [...args] };
79
+ }
@@ -0,0 +1,73 @@
1
+ /** 이 기계에서 ASC 가 소유하는 등록물 하나. 이름이 곧 소유권 증거다. */
2
+ export declare const SERVICE_LABEL = "com.asc-agent.runtime";
3
+ /**
4
+ * 등록물이 실행할 명령.
5
+ *
6
+ * **한 회차만 돈다** (`runtime tick`). 계속 도는 프로세스를 등록하지 않는 이유는 셋이다:
7
+ * OS 가 이미 주기를 관리할 줄 알고, 죽었을 때 되살리는 것도 OS 가 더 잘하며, 짧게 도는
8
+ * 프로세스는 죽어 있는 동안 자원을 쓰지 않는다.
9
+ */
10
+ export type ServiceCommand = {
11
+ /** 실행 파일. 보통 지금 도는 node. */
12
+ program: string;
13
+ /** 인자. 첫 항목이 ASC 진입점이다. */
14
+ args: readonly string[];
15
+ /** 회차 간격(초). Core 상수가 아니다 — 호출자가 정한다 (C-12 불변식 ③). */
16
+ intervalSeconds: number;
17
+ };
18
+ /** 지금 이 기계의 등록 상태. */
19
+ export type ServiceState =
20
+ /** 등록된 적이 없다. */
21
+ {
22
+ kind: 'ABSENT';
23
+ }
24
+ /** 지금 우리가 쓰려는 것과 같다. */
25
+ | {
26
+ kind: 'CURRENT';
27
+ detail?: string;
28
+ }
29
+ /** 우리 것인데 낡았다 — 경로나 간격이 달라졌다. 수렴시킨다. */
30
+ | {
31
+ kind: 'STALE';
32
+ detail: string;
33
+ }
34
+ /** 이 OS 에서는 등록할 방법을 모른다. 없는 것을 있는 척하지 않는다. */
35
+ | {
36
+ kind: 'UNSUPPORTED';
37
+ detail: string;
38
+ };
39
+ /**
40
+ * OS 하나가 지켜야 할 계약.
41
+ *
42
+ * `uninstall` 은 **ASC 가 소유한 등록물만** 지운다. 사람이 만든 같은 이름의 무언가가
43
+ * 있으면 그것은 사람의 것이다 (C-03 §5.1 의 소유권 규칙과 같은 선).
44
+ */
45
+ export type PersistentRuntimeAdapter = {
46
+ id: 'launchd' | 'schtasks' | 'systemd-user';
47
+ /** 이 기계에서 쓸 수 있는가. 쓸 수 없으면 install 을 시도하지 않는다. */
48
+ supported(): Promise<boolean>;
49
+ status(command: ServiceCommand): Promise<ServiceState>;
50
+ install(command: ServiceCommand): Promise<void>;
51
+ uninstall(): Promise<void>;
52
+ };
53
+ /**
54
+ * 무엇을 해야 하는가 — **아무것도 하지 않는다.**
55
+ *
56
+ * setup 의 detect→plan→apply 와 같은 모양이다: 판정과 실행을 나누고, 계획에 없는 것은
57
+ * 일어나지 않는다 (C-14 불변식 ⑩).
58
+ */
59
+ export type PersistentRuntimePlan = {
60
+ action: 'none';
61
+ state: ServiceState;
62
+ } | {
63
+ action: 'install';
64
+ state: ServiceState;
65
+ } | {
66
+ action: 'unsupported';
67
+ state: Extract<ServiceState, {
68
+ kind: 'UNSUPPORTED';
69
+ }>;
70
+ };
71
+ export declare function planPersistentRuntime(adapter: PersistentRuntimeAdapter, command: ServiceCommand): Promise<PersistentRuntimePlan>;
72
+ /** 사람이 읽는 한 줄. 왜 그 판정인지가 함께 와야 한다. */
73
+ export declare function persistentRuntimeLine(id: string, plan: PersistentRuntimePlan): string;
@@ -0,0 +1,49 @@
1
+ // Persistent Runtime Port — 사용자가 켜지 않아도 도는 자리 (설계 §4·§5).
2
+ //
3
+ // C-12 는 상시성을 "상태를 지속시키고 계산을 짧게 돌리는 것"으로 정의했고, 불변식 ④ 는
4
+ // **Core 에 scheduler 제품을 박지 말라**고 했다. 그 둘을 함께 지키는 방법은 하나다:
5
+ // Core 는 "무엇을 등록해야 하는가"만 말하고, launchd·Task Scheduler·systemd 는 그 말을
6
+ // 자기 형식으로 옮기는 adapter 뒤에 둔다.
7
+ //
8
+ // Core 이 기계에 ASC runtime 하나가 등록돼 있어야 한다
9
+ // Adapter 그것을 이 OS 에서 어떻게 표현하는가
10
+ //
11
+ // **workspace 마다 하나가 아니다.** 사용자/기계당 하나이고, 그 하나가 여러 workspace 를
12
+ // 돌본다 (설계 §4.1) — workspace 가 늘 때마다 OS 서비스가 늘면 그것은 제품이 아니라 짐이다.
13
+ //
14
+ // root daemon 으로 올리지 않는다. 사용자 로그인 이후가 이 계약의 경계다 (설계 §4.3).
15
+ /** 이 기계에서 ASC 가 소유하는 등록물 하나. 이름이 곧 소유권 증거다. */
16
+ export const SERVICE_LABEL = 'com.asc-agent.runtime';
17
+ export async function planPersistentRuntime(adapter, command) {
18
+ if (!(await adapter.supported())) {
19
+ return {
20
+ action: 'unsupported',
21
+ state: { kind: 'UNSUPPORTED', detail: `${adapter.id} is not usable on this machine` },
22
+ };
23
+ }
24
+ const state = await adapter.status(command);
25
+ switch (state.kind) {
26
+ case 'CURRENT':
27
+ return { action: 'none', state };
28
+ case 'UNSUPPORTED':
29
+ return { action: 'unsupported', state };
30
+ // 없는 것과 낡은 것은 다른 사실이지만 할 일은 같다 — 지금 형태로 수렴시킨다.
31
+ case 'ABSENT':
32
+ case 'STALE':
33
+ return { action: 'install', state };
34
+ }
35
+ }
36
+ /** 사람이 읽는 한 줄. 왜 그 판정인지가 함께 와야 한다. */
37
+ export function persistentRuntimeLine(id, plan) {
38
+ switch (plan.action) {
39
+ case 'none':
40
+ return `Persistent runtime: registered with ${id}${plan.state.kind === 'CURRENT' && plan.state.detail ? ` (${plan.state.detail})` : ''}`;
41
+ case 'install':
42
+ return plan.state.kind === 'STALE'
43
+ ? `Persistent runtime: registration is behind — ${plan.state.detail}`
44
+ : `Persistent runtime: not registered yet`;
45
+ case 'unsupported':
46
+ // 못 하는 것을 "안 해도 된다"로 적지 않는다
47
+ return `Persistent runtime: this machine has no user-scope service manager ASC knows — ${plan.state.detail}`;
48
+ }
49
+ }
@@ -1,9 +1,9 @@
1
1
  export declare const RUNTIME_PACKAGE = "@asc-agent/runtime";
2
2
  export declare const BOOTSTRAP_PACKAGE = "@asc-agent/bootstrap";
3
3
  /** runtime과 bootstrap은 초기 release에서 lockstep이다. */
4
- export declare const RELEASE_VERSION = "0.3.1";
5
- export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.3.1";
6
- export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.3.1";
4
+ export declare const RELEASE_VERSION = "0.4.0";
5
+ export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.4.0";
6
+ export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.4.0";
7
7
  /**
8
8
  * 아직 설치되지 않은 machine에서 그대로 실행되는 형태 (C-14 §3.4).
9
9
  *
@@ -9,7 +9,7 @@
9
9
  export const RUNTIME_PACKAGE = '@asc-agent/runtime';
10
10
  export const BOOTSTRAP_PACKAGE = '@asc-agent/bootstrap';
11
11
  /** runtime과 bootstrap은 초기 release에서 lockstep이다. */
12
- export const RELEASE_VERSION = '0.3.1';
12
+ export const RELEASE_VERSION = '0.4.0';
13
13
  export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
14
14
  export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
15
15
  /**
@@ -76,7 +76,9 @@ export class ProgressService {
76
76
  return {
77
77
  ok: false,
78
78
  reason: 'NOT_OWNER',
79
- detail: `${logicalSessionId} Runtime이 붙어 있지 않다 먼저 소유권을 주장하라`,
79
+ // "주장하라"만으로는 다음 명령을 모른다 복구 명령까지가 오류 메시지다 (실측 ASC-4).
80
+ // Core는 host 이름을 모른다(B-17) — 자리만 비워 두면 Surface가 채운다.
81
+ detail: `${logicalSessionId} 에 Runtime이 붙어 있지 않다 — 먼저 소유권을 주장하라: asc host <host> bind ${logicalSessionId} --physical <id>`,
80
82
  };
81
83
  }
82
84
  if (binding.physicalSessionId !== physicalSessionId) {