@angar2/taskery 1.0.2 → 1.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.
@@ -0,0 +1,199 @@
1
+ // Orca 호스트 — Orca(작업 폴더마다 AI 탭을 여는 터미널 앱)의 방식. 계약은 hosts/index.js
2
+ // 워크트리는 taskery가 만든다. Orca 목록에는 저장소 설정 '밖에서 만든 워크트리 보이기'를 켜고 부모 브랜치의 하위로 붙인다
3
+ const { execFileSync } = require('child_process');
4
+ const L = require('../lib');
5
+
6
+ // 첫 지시문을 보낸 뒤 턴 시작을 지켜보는 시간(orca terminal send --wait-submit)
7
+ const SUBMIT_WAIT_SEC = 30;
8
+ // 턴 시작 미관측 뒤 Enter를 보내고 화면을 읽기 전 기다리는 시간
9
+ const SCREEN_SETTLE_MS = 2000;
10
+
11
+ // orca 명령을 --json으로 부르고 result를 돌려준다. 종료 코드가 0이 아니어도 JSON이 ok면 돌려준다(terminal wait 미충족)
12
+ function orca(args) {
13
+ let out = '';
14
+ let err = '';
15
+ try {
16
+ out = execFileSync('orca', [...args, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
17
+ } catch (e) {
18
+ out = (e.stdout || '').toString();
19
+ err = ((e.stderr || '').toString().trim() || e.message).split('\n')[0];
20
+ }
21
+ let j = null;
22
+ try {
23
+ j = JSON.parse(out);
24
+ } catch (e) {
25
+ j = null;
26
+ }
27
+ if (j && j.ok) return j.result;
28
+ const why = (j && j.error && (j.error.message || j.error.code)) || err || out.trim() || '알 수 없는 오류';
29
+ throw new L.TaskeryError(`orca ${args.slice(0, 2).join(' ')} 실패: ${why}`);
30
+ }
31
+
32
+ // orca() 오류 문구에서 명령 머리를 떼고 이유만
33
+ function reason(e) {
34
+ return e.message.replace(/^orca \S+ \S+ 실패: /, '');
35
+ }
36
+
37
+ // 부모 브랜치가 열려 있는 폴더 — 없으면 본진. Orca의 branch: 선택자는 등록된 모든 저장소에서 찾아 같은 이름(dev)이 겹치면 고르지 못한다(실측)
38
+ function parentPath(main, parent) {
39
+ let at = null;
40
+ for (const line of L.git(main, ['worktree', 'list', '--porcelain']).split('\n')) {
41
+ if (line.startsWith('worktree ')) at = line.slice('worktree '.length);
42
+ else if (line === `branch refs/heads/${parent}`) return at;
43
+ }
44
+ return main;
45
+ }
46
+
47
+ // taskery가 만든 워크트리를 Orca 목록에 부모 브랜치의 하위로 붙인다. Orca는 밖에서 만든 워크트리를 저장소 설정이 켜져 있어야 보여 준다.
48
+ // 붙이지 못해도 탭은 열 수 있어 멈추지 않고 알린다
49
+ function attach(main, { worktree, parent }) {
50
+ const notes = [];
51
+ try {
52
+ const r = orca(['repo', 'show', '--repo', `path:${main}`]);
53
+ if ((r && r.repo && r.repo.externalWorktreeVisibility) !== 'show') {
54
+ orca(['repo', 'set', '--repo', `path:${main}`, '--external-worktree-visibility', 'show']);
55
+ notes.push("Orca 저장소 설정 '밖에서 만든 워크트리 보이기'를 켰다");
56
+ }
57
+ orca(['worktree', 'set', '--worktree', `path:${worktree}`, '--parent-worktree', `path:${parentPath(main, parent)}`]);
58
+ } catch (e) {
59
+ notes.push(`Orca 목록에 워크트리를 붙이지 못했다(${reason(e)})`);
60
+ }
61
+ return notes;
62
+ }
63
+
64
+ // 탭이 입력을 받을 준비(tui-idle)가 됐나
65
+ function waitIdle(id) {
66
+ const r = orca(['terminal', 'wait', '--terminal', id, '--for', 'tui-idle']);
67
+ if (r && r.wait && r.wait.satisfied === false) return `준비(tui-idle)가 되지 않았다 — ${r.wait.reason || r.wait.status || '준비 대기 미충족'}`;
68
+ return null;
69
+ }
70
+
71
+ function open({ dir, label, command }) {
72
+ const created = orca(['terminal', 'create', '--worktree', `path:${dir}`, '--title', label, '--command', command]);
73
+ const id = created && created.terminal && created.terminal.handle;
74
+ if (!id) L.fail("Orca가 탭 handle을 돌려주지 않았다. 'orca terminal list'로 확인한다.");
75
+ // 탭은 열렸으므로 준비를 기다리지 못해도 던지지 않는다 — 기록해 두고 다시 부를 때 이어 간다
76
+ try {
77
+ return { id, blocked: waitIdle(id) };
78
+ } catch (e) {
79
+ return { id, blocked: `준비를 기다리지 못했다(${reason(e)})` };
80
+ }
81
+ }
82
+
83
+ // 닫힌 탭도 한동안 terminal show에 남는다 — orphaned(연결 끊김)면 없는 것으로 본다(실측)
84
+ function closed(r) {
85
+ return !r || !r.terminal || r.terminal.orphaned === true;
86
+ }
87
+
88
+ function alive(id) {
89
+ try {
90
+ return !closed(orca(['terminal', 'show', '--terminal', id]));
91
+ } catch (e) {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ function ready(id) {
97
+ try {
98
+ const why = waitIdle(id);
99
+ return why ? { ok: false, why } : { ok: true };
100
+ } catch (e) {
101
+ return { ok: false, why: `준비를 확인하지 못했다(${reason(e)})` };
102
+ }
103
+ }
104
+
105
+ function read(id) {
106
+ const r = orca(['terminal', 'read', '--terminal', id, '--screen']);
107
+ return ((r && r.terminal && r.terminal.tail) || []).join('\n');
108
+ }
109
+
110
+ // 지시문을 보내고 턴 시작이 관측됐는지 돌려준다. 받지 않았으면 던진다. 관측할 수 없는 곳(영수증 없음·provider가 보고 못 함)은 보낸 것으로 본다
111
+ function sendPrompt(id, text) {
112
+ let r;
113
+ try {
114
+ r = orca(['terminal', 'send', '--terminal', id, '--text', text, '--enter', '--wait-submit', String(SUBMIT_WAIT_SEC)]);
115
+ } catch (e) {
116
+ L.fail(`첫 지시문을 보내지 못했다(${reason(e)})`);
117
+ }
118
+ const send = (r && r.send) || {};
119
+ if (send.accepted === false) L.fail(`탭이 첫 지시문을 받지 않았다(${send.refusedReason || '거부'})`);
120
+ const p = send.prompt;
121
+ return !p || (p.stages || []).includes('turn_started') || ['unsupported', 'old-host'].includes(p.provider);
122
+ }
123
+
124
+ // 탭 화면에 첫 지시문 첫머리가 보이나 — 보이면 들어간 것이라 다시 보내지 않는다
125
+ function onScreen(id, text) {
126
+ const marker = text.split('\n')[0].split(' ').slice(0, 4).join(' ').replace(/\s+/g, '');
127
+ try {
128
+ return read(id).replace(/\s+/g, '').includes(marker);
129
+ } catch (e) {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ function sendFirst(id, text) {
135
+ // Orca가 턴 시작을 관측했다고 답해도 지시문이 입력창에 제출되지 않은 채 남은 적이 있다(stash TASK-105).
136
+ // 그래서 관측 여부와 무관하게 Enter만 한 번 더 보낸다 — 입력창이 비었거나 이미 진행 중이면 영향이 없다
137
+ const observed = sendPrompt(id, text);
138
+ let note = null;
139
+ try {
140
+ orca(['terminal', 'send', '--terminal', id, '--enter']);
141
+ } catch (e) {
142
+ if (observed) note = `확인용 Enter를 보내지 못했다(${reason(e)}) — 탭에서 진행을 확인한다.`;
143
+ else L.fail(`턴 시작이 관측되지 않았고 확인용 Enter도 보내지 못했다(${reason(e)})`);
144
+ }
145
+ if (observed) return { note };
146
+ // 관측되지 않았으면 화면을 본다 — 지시문이 보이면 Enter로 들어간 것이고, 없으면 준비 전에 사라진 것이라 한 번 더 보낸다
147
+ L.sleepSync(SCREEN_SETTLE_MS);
148
+ if (onScreen(id, text)) return { note: '턴 시작이 관측되지 않아 Enter를 한 번 더 보냈고, 탭 화면에 첫 지시문이 보인다 — 탭에서 진행을 확인한다.' };
149
+ if (sendPrompt(id, text)) return { note: '첫 전송은 턴 시작이 관측되지 않고 Enter 뒤 화면에도 없어 한 번 더 보냈다.' };
150
+ L.fail('두 번 보냈지만 턴 시작이 관측되지 않았다');
151
+ }
152
+
153
+ function send(id, text) {
154
+ let r;
155
+ try {
156
+ r = orca(['terminal', 'send', '--terminal', id, '--text', text, '--enter']);
157
+ } catch (e) {
158
+ L.fail(`보내지 못했다(${reason(e)})`);
159
+ }
160
+ const s = (r && r.send) || {};
161
+ if (s.accepted === false) L.fail(`탭이 받지 않았다(${s.refusedReason || '거부'})`);
162
+ }
163
+
164
+ // 탭을 닫는다 — 이미 없으면 false
165
+ function close(id) {
166
+ if (!alive(id)) return false;
167
+ orca(['terminal', 'close', '--terminal', id]);
168
+ return true;
169
+ }
170
+
171
+ // 마지막 출력이 quietMs 넘게 지났으면 그 시간
172
+ function check(id, quietMs) {
173
+ let r;
174
+ try {
175
+ r = orca(['terminal', 'show', '--terminal', id]);
176
+ } catch (e) {
177
+ L.fail(reason(e));
178
+ }
179
+ if (closed(r)) return '탭이 닫혔다';
180
+ const last = r.terminal.lastOutputAt;
181
+ return last && Date.now() - last >= quietMs ? `마지막 출력 ${L.minutes(Date.now() - last)} 전` : null;
182
+ }
183
+
184
+ module.exports = {
185
+ name: 'orca',
186
+ label: 'Orca',
187
+ envKeys: ['ORCA_TERMINAL_HANDLE'],
188
+ detect: (env) => !!env.ORCA_TERMINAL_HANDLE,
189
+ legacyTab: true,
190
+ open,
191
+ alive,
192
+ ready,
193
+ sendFirst,
194
+ send,
195
+ read,
196
+ check,
197
+ attach,
198
+ close,
199
+ };
package/bin/lib.js CHANGED
@@ -118,9 +118,9 @@ function ensureExclude(main, extra = []) {
118
118
  return missing;
119
119
  }
120
120
 
121
- // Orca 탭 안인가 — Orca가 탭에 넣는 환경 변수로 판단한다
122
- function inOrca() {
123
- return !!process.env.ORCA_TERMINAL_HANDLE;
121
+ // 동기 대기 — 동기 함수로 된 터미널 앱 명령 사이에서 쓴다
122
+ function sleepSync(ms) {
123
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
124
124
  }
125
125
 
126
126
  // ─── 셸 명령 실행 (코드 테스트) ───────────────────────
@@ -688,7 +688,7 @@ module.exports = {
688
688
  branchExists,
689
689
  fingerprint,
690
690
  ensureExclude,
691
- inOrca,
691
+ sleepSync,
692
692
  runShell,
693
693
  tail,
694
694
  OLD_MANIFEST_NAME,
@@ -716,6 +716,7 @@ module.exports = {
716
716
  docAbs,
717
717
  docShown,
718
718
  readDoc,
719
+ fenceMask,
719
720
  section,
720
721
  isNewDoc,
721
722
  meaningfulLines,
package/bin/orche.js CHANGED
@@ -1,15 +1,14 @@
1
- // orca-dispatch-task · report-task · wait-reports — 오케스트레이션의 띄우기·보고·보고 받기(§5-3, §5-4)
1
+ // dispatch-task · send-task · read-task · report-task · wait-reports — 오케스트레이션의 띄우기·탭에 말하기·보고·보고 받기(§5-3, §5-4)
2
+ // 탭을 다루는 일은 이 세션이 도는 터미널 앱의 호스트 파일(hosts/)이 한다 — 여기서는 앱 이름을 모른다
2
3
  const fs = require('fs');
3
4
  const path = require('path');
4
- const { execFileSync } = require('child_process');
5
5
  const L = require('./lib');
6
+ const H = require('./hosts');
6
7
 
7
8
  const AGENTS = ['claude', 'codex'];
8
- // 보고 없이 기다리는 시간이자 '오래 조용한 탭'의 기준. 환경 변수는 시험 전용 통로다
9
+ // 보고 없이 기다리는 시간이자 '살펴볼 탭'의 조용한 시간 기준. 환경 변수는 시험 전용 통로다
9
10
  const WAIT_MS = Number(process.env.TASKERY_WAIT_REPORTS_MS) || 60 * 60 * 1000;
10
11
  const POLL_MS = Math.min(1000, WAIT_MS);
11
- // 첫 지시문을 보낸 뒤 턴 시작을 지켜보는 시간(orca terminal send --wait-submit)
12
- const SUBMIT_WAIT_SEC = 30;
13
12
 
14
13
  function reportsFile(main) {
15
14
  return path.join(main, '.taskery', 'reports.log');
@@ -19,36 +18,20 @@ function readPosFile(main) {
19
18
  return path.join(L.stateDir(main), 'reports-read.json');
20
19
  }
21
20
 
22
- // orca 명령을 --json으로 부르고 result를 돌려준다. 종료 코드가 0이 아니어도 JSON이 ok면 돌려준다(terminal wait 미충족)
23
- function orca(args) {
24
- let out = '';
25
- let err = '';
26
- try {
27
- out = execFileSync('orca', [...args, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
28
- } catch (e) {
29
- out = (e.stdout || '').toString();
30
- err = ((e.stderr || '').toString().trim() || e.message).split('\n')[0];
31
- }
32
- let j = null;
33
- try {
34
- j = JSON.parse(out);
35
- } catch (e) {
36
- j = null;
37
- }
38
- if (j && j.ok) return j.result;
39
- const why = (j && j.error && (j.error.message || j.error.code)) || err || out.trim() || '알 수 없는 오류';
40
- throw new L.TaskeryError(`orca ${args.slice(0, 2).join(' ')} 실패: ${why}`);
41
- }
42
-
43
- // 셸에 그대로 넘길 수 있게 감싼다(--command는 탭의 셸에 입력된다)
21
+ // 셸에 그대로 넘길 수 있게 감싼다(실행 한 줄은 탭의 셸에 입력된다)
44
22
  function shq(v) {
45
23
  return /^[A-Za-z0-9_.\/:=@%+-]+$/.test(v) ? v : `'${String(v).replace(/'/g, `'\\''`)}'`;
46
24
  }
47
25
 
48
- // 에이전트 실행 명령 — 권한·승인 옵션은 붙이지 않는다(사용자 설정 그대로, I5). 모델이 없으면 모델 옵션을 뺀다
49
- function agentCommand(main, agent, model) {
50
- if (agent === 'claude') return model ? `claude --model ${shq(model)}` : 'claude';
51
- return `codex ${model ? `-m ${shq(model)} ` : ''}--add-dir ${shq(path.join(main, '.taskery'))}`;
26
+ // 에이전트 실행 — 권한·승인 옵션은 붙이지 않는다(사용자 설정 그대로, I5). 모델이 없으면 모델 옵션을 뺀다
27
+ function agentLaunch(main, agent, model) {
28
+ if (agent === 'claude') return { agent, args: model ? ['--model', model] : [] };
29
+ return { agent, args: [...(model ? ['-m', model] : []), '--add-dir', path.join(main, '.taskery')] };
30
+ }
31
+
32
+ // 실행 한 줄 — 손 전달 안내와 셸 한 줄로 띄우는 호스트가 쓴다
33
+ function shellLine(launch) {
34
+ return [launch.agent, ...launch.args].map(shq).join(' ');
52
35
  }
53
36
 
54
37
  // 단계 표에서 비어 있는 첫 단계의 스킬 — 새 태스크면 task-plan
@@ -99,125 +82,171 @@ function firstInstruction(st, brief = {}) {
99
82
  return blocks.map((b) => (Array.isArray(b) ? b.join('\n') : b)).join('\n\n');
100
83
  }
101
84
 
102
- // 지시문을 보내고 턴 시작이 관측됐는지 돌려준다. 받지 않았으면 refused. 관측할 수 없는 곳(영수증 없음·provider가 보고 못 함)은 보낸 것으로 본다
103
- function sendPrompt(handle, text) {
104
- const r = orca(['terminal', 'send', '--terminal', handle, '--text', text, '--enter', '--wait-submit', String(SUBMIT_WAIT_SEC)]);
105
- const send = (r && r.send) || {};
106
- if (send.accepted === false) return { refused: send.refusedReason || '거부' };
107
- const p = send.prompt;
108
- return { observed: !p || (p.stages || []).includes('turn_started') || ['unsupported', 'old-host'].includes(p.provider) };
85
+ // 탭 기록을 .state에 쓴다(null이면 지운다) — 태스크 세션이 쓰는 칸과 겹치지 않게 그때의 .state를 읽어 고친다
86
+ function saveTab(main, num, tab) {
87
+ const st = L.readState(main, num);
88
+ if (tab) st.tab = tab;
89
+ else delete st.tab;
90
+ L.writeState(main, st);
91
+ L.syncDoc(main, st);
92
+ return st;
109
93
  }
110
94
 
111
- // 탭 화면에 첫 지시문 첫머리가 보이나 — 보이면 들어간 것이라 다시 보내지 않는다
112
- function onScreen(handle, marker) {
113
- try {
114
- const r = orca(['terminal', 'read', '--terminal', handle, '--screen']);
115
- const screen = ((r && r.terminal && r.terminal.tail) || []).join('').replace(/\s+/g, '');
116
- return screen.includes(marker.replace(/\s+/g, ''));
117
- } catch (e) {
118
- return false;
119
- }
95
+ // 기록된 탭의 호스트 — 이 taskery가 모르는 이름이면 멈춘다
96
+ function hostOf(cmd, label, tab) {
97
+ const host = H.byName(tab.host);
98
+ if (!host) L.fail(`${cmd}: ${label} 탭을 띄운 터미널 앱 '${tab.host}'을 이 taskery가 모른다.`);
99
+ return host;
120
100
  }
121
101
 
122
102
  // 손 전달(--manual) — 탭을 띄우지 않고 사용자에게 전할 작업 폴더·실행 안내·첫 지시문을 돌려준다
123
103
  function manualDispatch(main, st, a, brief) {
124
- const prevTab = st.tab;
125
- // 남은 탭 기록을 지운다 — 남겨 두면 wait-reports가 그 handle을 계속 확인한다
126
- if (prevTab) {
127
- delete st.tab;
128
- L.writeState(main, st);
129
- L.syncDoc(main, st);
130
- }
131
- const run = a.agent ? agentCommand(main, a.agent, a.model && String(a.model)) : `Claude Code는 claude, Codex는 ${agentCommand(main, 'codex')}`;
104
+ const prevTab = H.tabOf(st);
105
+ // 남은 탭 기록을 지운다 — 남겨 두면 wait-reports가 그 탭을 계속 확인한다
106
+ if (prevTab) saveTab(main, st.num, null);
107
+ const run = a.agent
108
+ ? shellLine(agentLaunch(main, a.agent, a.model && String(a.model)))
109
+ : `Claude Code는 claude, Codex는 ${shellLine(agentLaunch(main, 'codex'))}`;
132
110
  return [
133
111
  '손 전달 — 탭을 띄우지 않았다. 사용자에게 아래를 전하고, 사용자가 작업 폴더에서 에이전트 세션을 열어 첫 지시문을 그대로 붙여 넣게 한다.',
134
112
  `- 작업 폴더: ${L.workDir(main, st)}`,
135
113
  `- 실행: ${run}`,
136
- ...(prevTab ? [`- 앞서 연 탭 ${prevTab}은 기록에서 지웠다. 그 탭에서 세션이 이미 일을 시작하지 않았는지 확인하고 닫는다.`] : []),
114
+ ...(prevTab ? [`- 앞서 연 탭 ${prevTab.id}은 기록에서 지웠다. 그 탭에서 세션이 이미 일을 시작하지 않았는지 확인하고 닫는다.`] : []),
137
115
  '--- 첫 지시문 ---',
138
116
  firstInstruction(st, brief),
139
117
  ].join('\n');
140
118
  }
141
119
 
142
- async function orcaDispatchTask(ctx, a) {
120
+ // 멈춤 ② — 탭은 열렸지만 에이전트가 첫 지시를 받을 준비가 안 됐다
121
+ function notReady(label, id, why) {
122
+ return [
123
+ `dispatch-task: ${label} 탭 ${id}을 열었지만 ${why}. 첫 지시문은 아직 보내지 않았다.`,
124
+ `- read-task ${label}로 화면을 읽고, 사용자에게 그 탭에서 확인해 달라고 부탁한다.`,
125
+ `- 사용자가 확인하면 dispatch-task ${label}를 같은 칸 인자(--scope·--avoid·--read·--extra)로 다시 부른다 — 열린 탭에 첫 지시만 보낸다.`,
126
+ ].join('\n');
127
+ }
128
+
129
+ // 띄우기(§7-2) — 탭 기록이 있으면 그 탭으로 이어 가고, 없으면 지금 호스트에 새 탭을 연다
130
+ async function dispatchTask(ctx, a) {
143
131
  const main = ctx.main;
144
132
  L.requireInstalled(main);
145
- if (!a.task) L.fail('orca-dispatch-task: 빠진 입력이 있다.\n- <task> — 태스크 번호 (첫 번째 인자)');
133
+ if (!a.task) L.fail('dispatch-task: 빠진 입력이 있다.\n- <task> — 태스크 번호 (첫 번째 인자)');
146
134
  const num = L.parseTaskNum(a.task);
147
135
  const st = L.readState(main, num);
148
136
  const label = L.taskLabel(num);
149
137
  if (st.closed) L.fail(`${label}은 이미 닫혔다.`);
150
138
  const dir = L.workDir(main, st);
151
- if (!fs.existsSync(dir)) L.fail(`orca-dispatch-task: ${label}의 워크트리가 없다(${dir}). 'status'로 확인한다.`);
152
- if (a.agent && !AGENTS.includes(a.agent)) L.fail(`orca-dispatch-task: --agent '${a.agent}'을 모른다. ${AGENTS.join(' · ')} 중 하나를 넣는다.`);
139
+ if (!fs.existsSync(dir)) L.fail(`dispatch-task: ${label}의 워크트리가 없다(${dir}). 'status'로 확인한다.`);
140
+ if (a.agent && !AGENTS.includes(a.agent)) L.fail(`dispatch-task: --agent '${a.agent}'을 모른다. ${AGENTS.join(' · ')} 중 하나를 넣는다.`);
153
141
  const brief = { scope: a.scope, avoid: a.avoid, read: a.read, extra: a.extra };
154
142
  if (a.manual) return manualDispatch(main, st, a, brief);
155
- if (!L.inOrca()) {
156
- L.fail(
157
- 'orca-dispatch-task: Orca 탭 안이 아니라 태스크 세션을 띄울 수 없다(Orca 환경 변수 ORCA_TERMINAL_HANDLE이 없다). ' +
158
- 'Orca 탭 안에서 불렀다면 MCP 도구가 아니라 CLI(npx @angar2/taskery orca-dispatch-task …)로 다시 부른다. ' +
159
- 'Orca를 쓸 수 없으면 --manual로 다시 불러 사용자에게 손 전달한다.',
160
- );
143
+
144
+ let tab = H.tabOf(st);
145
+ let host = null;
146
+ if (tab) {
147
+ host = hostOf('dispatch-task', label, tab);
148
+ if (!host.alive(tab.id)) {
149
+ // 사용자가 닫은 탭 — 기록을 지우고 새로 띄운다
150
+ saveTab(main, num, null);
151
+ tab = null;
152
+ } else if (tab.sent) {
153
+ L.fail(`dispatch-task: ${label} 탭 ${tab.id}에 이미 첫 지시를 보냈다. read-task ${label}로 화면을 확인한다. 새로 띄워야 하면 사용자가 그 탭을 닫은 뒤 다시 부른다.`);
154
+ } else {
155
+ const r = host.ready(tab.id);
156
+ if (!r.ok) L.fail(notReady(label, tab.id, r.why));
157
+ }
161
158
  }
162
- const missing = [];
163
- if (!a.agent) missing.push(`--agent — ${AGENTS.join(' · ')}`);
164
- if (!a.model) missing.push('--model — 에이전트 모델 (사용자가 정한 것)');
165
- if (missing.length) L.fail(`orca-dispatch-task: 빠진 입력이 있다.\n${missing.map((m) => `- ${m}`).join('\n')}`);
166
159
 
167
- const command = agentCommand(main, a.agent, String(a.model));
168
- const created = orca(['terminal', 'create', '--worktree', `path:${dir}`, '--title', label, '--command', command]);
169
- const handle = created && created.terminal && created.terminal.handle;
170
- if (!handle) L.fail(`orca-dispatch-task: ${label} 탭을 열었지만 Orca가 탭 handle을 돌려주지 않았다. 'orca terminal list'로 확인한다.`);
160
+ let launch = null;
161
+ if (!tab) {
162
+ host = H.current();
163
+ if (!host) {
164
+ L.fail(
165
+ `dispatch-task: 태스크 탭을 띄울 터미널 앱 안이 아니다(판정 환경 변수 ${H.envKeys().join('·')}가 모두 없다). ` +
166
+ '터미널 앱 탭 안에서 불렀다면 MCP 도구가 아니라 CLI(npx @angar2/taskery dispatch-task …)로 다시 부른다. ' +
167
+ '쓸 수 없으면 --manual로 다시 불러 사용자에게 손 전달한다.',
168
+ );
169
+ }
170
+ const missing = [];
171
+ if (!a.agent) missing.push(`--agent — ${AGENTS.join(' · ')}`);
172
+ if (!a.model) missing.push('--model — 에이전트 모델 (사용자가 정한 것)');
173
+ if (missing.length) L.fail(`dispatch-task: 빠진 입력이 있다.\n${missing.map((m) => `- ${m}`).join('\n')}`);
174
+ launch = agentLaunch(main, a.agent, String(a.model));
175
+ let opened;
176
+ try {
177
+ opened = host.open({ main, dir, label, agent: launch.agent, args: launch.args, command: shellLine(launch) });
178
+ } catch (e) {
179
+ L.fail(`dispatch-task: ${label} 탭을 열지 못했다 — ${e.message}`);
180
+ }
181
+ // 탭 기록을 .state에 남긴다 — 첫 지시문을 보내기 전이라 태스크 세션의 기록과 겹치지 않는다
182
+ tab = { host: host.name, id: opened.id, sent: false };
183
+ saveTab(main, num, tab);
184
+ if (opened.blocked) L.fail(notReady(label, tab.id, opened.blocked));
185
+ }
171
186
 
172
- // 탭 handle을 .state에 기록한다 — 첫 지시문을 보내기 전이라 태스크 세션의 기록과 겹치지 않는다
187
+ // 첫 지시문은 저장하지 않는다 — 다시 부를 때는 같은 칸 인자로 다시 만든다
173
188
  const fresh = L.readState(main, num);
174
- fresh.tab = handle;
175
- L.writeState(main, fresh);
176
- L.syncDoc(main, fresh);
177
-
178
- const text = firstInstruction(fresh, brief);
179
- const manual = `탭 화면을 'orca terminal read --terminal ${handle}'로 확인하고, 준비되면 아래 첫 지시문을 'orca terminal send --terminal ${handle} --text "<첫 지시문>" --enter'로 보낸다.\n--- 첫 지시문 ---\n${text}`;
180
- let waited;
189
+ let sent;
181
190
  try {
182
- waited = orca(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle']);
191
+ sent = host.sendFirst(tab.id, firstInstruction(fresh, brief));
183
192
  } catch (e) {
184
- L.fail(`orca-dispatch-task: ${label} 탭(${handle})을 열었지만 준비를 기다리지 못해 첫 지시문을 보내지 않았다 — ${e.message}\n${manual}`);
185
- }
186
- if (waited && waited.wait && waited.wait.satisfied === false) {
187
- const why = waited.wait.reason || waited.wait.status || '준비 대기 미충족';
188
- L.fail(`orca-dispatch-task: ${label} 탭(${handle})을 열었지만 준비(tui-idle)가 되지 않아 첫 지시문을 보내지 않았다 — ${why}\n${manual}`);
189
- }
190
- const send = () => {
191
- let r;
192
- try {
193
- r = sendPrompt(handle, text);
194
- } catch (e) {
195
- L.fail(`orca-dispatch-task: ${label} 탭(${handle})에 첫 지시문을 보내지 못했다 — ${e.message}\n${manual}`);
196
- }
197
- if (r.refused) L.fail(`orca-dispatch-task: ${label} 탭(${handle})이 첫 지시문을 받지 않았다 — ${r.refused}\n${manual}`);
198
- return r.observed;
199
- };
200
- // 턴 시작이 관측되지 않으면 화면을 보고, 지시문이 보이지 않을 때만 한 번 더 보낸다(준비 전 입력이 사라진 경우)
201
- let sendNote = null;
202
- if (!send()) {
203
- if (onScreen(handle, text.split('\n')[0].split(' ').slice(0, 4).join(' '))) {
204
- sendNote = '턴 시작은 관측되지 않았지만 탭 화면에 첫 지시문이 보인다 — 탭에서 진행을 확인한다.';
205
- } else if (send()) {
206
- sendNote = '첫 전송은 턴 시작이 관측되지 않고 화면에도 없어 한 번 더 보냈다.';
207
- } else {
208
- L.fail(`orca-dispatch-task: ${label} 탭(${handle})에 첫 지시문을 두 번 보냈지만 턴 시작이 관측되지 않았다.\n${manual}`);
209
- }
193
+ L.fail(
194
+ [
195
+ `dispatch-task: ${label} 탭 ${tab.id}에 첫 지시문을 보냈지만 제출을 확인하지 못했다 — ${e.message}.`,
196
+ `- read-task ${label}로 화면을 읽는다. 지시문으로 일을 시작했으면 그대로 둔다. 지시문이 입력창에 남아 있으면 사용자에게 그 탭에서 Enter를 부탁한다. 화면에 없으면 dispatch-task ${label}를 같은 칸 인자로 다시 부른다.`,
197
+ ].join('\n'),
198
+ );
210
199
  }
200
+ saveTab(main, num, { ...tab, sent: true });
211
201
  return [
212
- `${label} 태스크 세션을 띄웠다 — 탭 ${handle}`,
213
- `- 실행: ${command}`,
202
+ `${label} 태스크 세션을 띄웠다 — ${host.label} 탭 ${tab.id}`,
203
+ launch ? `- 실행: ${shellLine(launch)}` : '- 앞서 연 탭에 첫 지시만 보냈다.',
214
204
  `- 폴더: ${dir}`,
215
205
  `- 첫 지시문을 보냈다(시작: ${startSkill(fresh)}, 범위: ${fresh.range}).`,
216
- ...(sendNote ? [`- 알림: ${sendNote}`] : []),
206
+ ...(sent && sent.note ? [`- 알림: ${sent.note}`] : []),
217
207
  '다음: wait-reports가 걸려 있지 않으면 백그라운드 셸로 걸어 둔다.',
218
208
  ].join('\n');
219
209
  }
220
210
 
211
+ // send-task·read-task가 쓰는 탭 기록 — 없으면 멈춘다
212
+ function recordedTab(cmd, main, task) {
213
+ L.requireInstalled(main);
214
+ const num = L.parseTaskNum(task);
215
+ const st = L.readState(main, num);
216
+ const label = L.taskLabel(num);
217
+ if (st.closed) L.fail(`${label}은 이미 닫혔다.`);
218
+ const tab = H.tabOf(st);
219
+ if (!tab) L.fail(`${cmd}: ${label}에는 띄운 탭 기록이 없다. 손 전달한 태스크면 사용자에게 전달을 부탁한다.`);
220
+ return { label, tab, host: hostOf(cmd, label, tab) };
221
+ }
222
+
223
+ const ORCHE_TAG = '[오케스트레이션]';
224
+
225
+ async function sendTask(ctx, a) {
226
+ if (!a.task || !a.text || !String(a.text).trim()) L.fail('send-task: 태스크 번호와 보낼 말을 넣는다. 예: send-task TASK-012 "색은 파랑으로 한다"');
227
+ const { label, tab, host } = recordedTab('send-task', ctx.main, a.task);
228
+ const said = String(a.text).trim();
229
+ const text = said.startsWith(ORCHE_TAG) ? said : `${ORCHE_TAG} ${said}`;
230
+ try {
231
+ host.send(tab.id, text);
232
+ } catch (e) {
233
+ L.fail(`send-task: ${label} 탭(${tab.id}) — ${e.message}`);
234
+ }
235
+ return `${label} 탭 ${tab.id}에 보냈다.`;
236
+ }
237
+
238
+ async function readTask(ctx, a) {
239
+ if (!a.task) L.fail('read-task: 빠진 입력이 있다.\n- <task> — 태스크 번호 (첫 번째 인자)');
240
+ const { label, tab, host } = recordedTab('read-task', ctx.main, a.task);
241
+ let screen;
242
+ try {
243
+ screen = host.read(tab.id);
244
+ } catch (e) {
245
+ L.fail(`read-task: ${label} 탭(${tab.id}) — ${e.message}`);
246
+ }
247
+ return `${label} 탭 ${tab.id} 화면 (${host.label}):\n${screen}`;
248
+ }
249
+
221
250
  async function reportTask(ctx, a) {
222
251
  const main = ctx.main;
223
252
  L.requireInstalled(main);
@@ -250,18 +279,23 @@ function takeUnread(main) {
250
279
  return lines;
251
280
  }
252
281
 
253
- // 오래 조용한 태스크 탭 — 열린 태스크 중 탭 기록이 있고 마지막 출력이 기다린 시간보다 오래된 것
254
- function quietTabs(main) {
282
+ // 살펴볼 태스크 탭 — 열린 태스크 중 탭 기록이 있고, 그 호스트가 살펴볼 이유를 알려 준 것과 읽지 못한 것
283
+ function checkTabs(main) {
255
284
  const out = [];
256
285
  for (const st of L.listStates(main)) {
257
- if (st.closed || !st.tab) continue;
258
- const label = L.taskLabel(st.num);
286
+ const tab = H.tabOf(st);
287
+ if (st.closed || !tab) continue;
288
+ const head = `- ${L.taskLabel(st.num)} 탭 ${tab.id} — `;
289
+ const host = H.byName(tab.host);
290
+ if (!host) {
291
+ out.push(`${head}탭을 읽지 못했다(터미널 앱 '${tab.host}'을 모른다)`);
292
+ continue;
293
+ }
259
294
  try {
260
- const r = orca(['terminal', 'show', '--terminal', st.tab]);
261
- const last = r && r.terminal && r.terminal.lastOutputAt;
262
- if (last && Date.now() - last >= WAIT_MS) out.push(`- ${label} 탭 ${st.tab} — 마지막 출력 ${L.minutes(Date.now() - last)} 전`);
295
+ const why = host.check(tab.id, WAIT_MS);
296
+ if (why) out.push(head + why);
263
297
  } catch (e) {
264
- out.push(`- ${label} 탭 ${st.tab} — 탭을 읽지 못했다(${e.message.replace(/^orca terminal show 실패: /, '')})`);
298
+ out.push(`${head}탭을 읽지 못했다(${e.message})`);
265
299
  }
266
300
  }
267
301
  return out;
@@ -283,8 +317,8 @@ async function waitReports(ctx) {
283
317
  if (Date.now() >= until) break;
284
318
  await new Promise((r) => setTimeout(r, Math.min(POLL_MS, Math.max(0, until - Date.now()))));
285
319
  }
286
- const quiet = quietTabs(main);
287
- return withRearm(main, [`보고 없이 ${L.minutes(WAIT_MS)}이 지났다.`, `오래 조용한 탭: ${quiet.length ? '' : '없음'}`.trimEnd(), ...quiet]);
320
+ const tabs = checkTabs(main);
321
+ return withRearm(main, [`보고 없이 ${L.minutes(WAIT_MS)}이 지났다.`, `살펴볼 탭: ${tabs.length ? '' : '없음'}`.trimEnd(), ...tabs]);
288
322
  }
289
323
 
290
- module.exports = { orcaDispatchTask, reportTask, waitReports, firstInstruction };
324
+ module.exports = { dispatchTask, sendTask, readTask, reportTask, waitReports, firstInstruction };