@angar2/taskery 1.0.3 → 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.
- package/README.md +7 -7
- package/bin/check.js +3 -4
- package/bin/commands.js +18 -6
- package/bin/finish.js +27 -30
- package/bin/hosts/herdr.js +212 -0
- package/bin/hosts/index.js +82 -0
- package/bin/hosts/orca.js +199 -0
- package/bin/lib.js +4 -4
- package/bin/orche.js +156 -134
- package/bin/prepare.js +8 -34
- package/package.json +1 -1
- package/template/.taskery/rules/TASKERY_RULE.md +7 -3
- package/template/shared/skills/task-orche/SKILL.md +10 -9
|
@@ -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
|
-
//
|
|
122
|
-
function
|
|
123
|
-
|
|
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
|
-
|
|
691
|
+
sleepSync,
|
|
692
692
|
runShell,
|
|
693
693
|
tail,
|
|
694
694
|
OLD_MANIFEST_NAME,
|
package/bin/orche.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
//
|
|
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
|
-
// 턴 시작 미관측 뒤 Enter를 보내고 화면을 읽기 전 기다리는 시간
|
|
14
|
-
const SCREEN_SETTLE_MS = 2000;
|
|
15
12
|
|
|
16
13
|
function reportsFile(main) {
|
|
17
14
|
return path.join(main, '.taskery', 'reports.log');
|
|
@@ -21,36 +18,20 @@ function readPosFile(main) {
|
|
|
21
18
|
return path.join(L.stateDir(main), 'reports-read.json');
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
//
|
|
25
|
-
function orca(args) {
|
|
26
|
-
let out = '';
|
|
27
|
-
let err = '';
|
|
28
|
-
try {
|
|
29
|
-
out = execFileSync('orca', [...args, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
30
|
-
} catch (e) {
|
|
31
|
-
out = (e.stdout || '').toString();
|
|
32
|
-
err = ((e.stderr || '').toString().trim() || e.message).split('\n')[0];
|
|
33
|
-
}
|
|
34
|
-
let j = null;
|
|
35
|
-
try {
|
|
36
|
-
j = JSON.parse(out);
|
|
37
|
-
} catch (e) {
|
|
38
|
-
j = null;
|
|
39
|
-
}
|
|
40
|
-
if (j && j.ok) return j.result;
|
|
41
|
-
const why = (j && j.error && (j.error.message || j.error.code)) || err || out.trim() || '알 수 없는 오류';
|
|
42
|
-
throw new L.TaskeryError(`orca ${args.slice(0, 2).join(' ')} 실패: ${why}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// 셸에 그대로 넘길 수 있게 감싼다(--command는 탭의 셸에 입력된다)
|
|
21
|
+
// 셸에 그대로 넘길 수 있게 감싼다(실행 한 줄은 탭의 셸에 입력된다)
|
|
46
22
|
function shq(v) {
|
|
47
23
|
return /^[A-Za-z0-9_.\/:=@%+-]+$/.test(v) ? v : `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
48
24
|
}
|
|
49
25
|
|
|
50
|
-
// 에이전트 실행
|
|
51
|
-
function
|
|
52
|
-
if (agent === 'claude') return model ?
|
|
53
|
-
return
|
|
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(' ');
|
|
54
35
|
}
|
|
55
36
|
|
|
56
37
|
// 단계 표에서 비어 있는 첫 단계의 스킬 — 새 태스크면 task-plan
|
|
@@ -101,135 +82,171 @@ function firstInstruction(st, brief = {}) {
|
|
|
101
82
|
return blocks.map((b) => (Array.isArray(b) ? b.join('\n') : b)).join('\n\n');
|
|
102
83
|
}
|
|
103
84
|
|
|
104
|
-
//
|
|
105
|
-
function
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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;
|
|
111
93
|
}
|
|
112
94
|
|
|
113
|
-
//
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return screen.includes(marker.replace(/\s+/g, ''));
|
|
119
|
-
} catch (e) {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
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;
|
|
122
100
|
}
|
|
123
101
|
|
|
124
102
|
// 손 전달(--manual) — 탭을 띄우지 않고 사용자에게 전할 작업 폴더·실행 안내·첫 지시문을 돌려준다
|
|
125
103
|
function manualDispatch(main, st, a, brief) {
|
|
126
|
-
const prevTab = st
|
|
127
|
-
// 남은 탭 기록을 지운다 — 남겨 두면 wait-reports가 그
|
|
128
|
-
if (prevTab)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
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'))}`;
|
|
134
110
|
return [
|
|
135
111
|
'손 전달 — 탭을 띄우지 않았다. 사용자에게 아래를 전하고, 사용자가 작업 폴더에서 에이전트 세션을 열어 첫 지시문을 그대로 붙여 넣게 한다.',
|
|
136
112
|
`- 작업 폴더: ${L.workDir(main, st)}`,
|
|
137
113
|
`- 실행: ${run}`,
|
|
138
|
-
...(prevTab ? [`- 앞서 연 탭 ${prevTab}은 기록에서 지웠다. 그 탭에서 세션이 이미 일을 시작하지 않았는지 확인하고 닫는다.`] : []),
|
|
114
|
+
...(prevTab ? [`- 앞서 연 탭 ${prevTab.id}은 기록에서 지웠다. 그 탭에서 세션이 이미 일을 시작하지 않았는지 확인하고 닫는다.`] : []),
|
|
139
115
|
'--- 첫 지시문 ---',
|
|
140
116
|
firstInstruction(st, brief),
|
|
141
117
|
].join('\n');
|
|
142
118
|
}
|
|
143
119
|
|
|
144
|
-
|
|
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) {
|
|
145
131
|
const main = ctx.main;
|
|
146
132
|
L.requireInstalled(main);
|
|
147
|
-
if (!a.task) L.fail('
|
|
133
|
+
if (!a.task) L.fail('dispatch-task: 빠진 입력이 있다.\n- <task> — 태스크 번호 (첫 번째 인자)');
|
|
148
134
|
const num = L.parseTaskNum(a.task);
|
|
149
135
|
const st = L.readState(main, num);
|
|
150
136
|
const label = L.taskLabel(num);
|
|
151
137
|
if (st.closed) L.fail(`${label}은 이미 닫혔다.`);
|
|
152
138
|
const dir = L.workDir(main, st);
|
|
153
|
-
if (!fs.existsSync(dir)) L.fail(`
|
|
154
|
-
if (a.agent && !AGENTS.includes(a.agent)) L.fail(`
|
|
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(' · ')} 중 하나를 넣는다.`);
|
|
155
141
|
const brief = { scope: a.scope, avoid: a.avoid, read: a.read, extra: a.extra };
|
|
156
142
|
if (a.manual) return manualDispatch(main, st, a, brief);
|
|
157
|
-
if (!L.inOrca()) {
|
|
158
|
-
L.fail(
|
|
159
|
-
'orca-dispatch-task: Orca 탭 안이 아니라 태스크 세션을 띄울 수 없다(Orca 환경 변수 ORCA_TERMINAL_HANDLE이 없다). ' +
|
|
160
|
-
'Orca 탭 안에서 불렀다면 MCP 도구가 아니라 CLI(npx @angar2/taskery orca-dispatch-task …)로 다시 부른다. ' +
|
|
161
|
-
'Orca를 쓸 수 없으면 --manual로 다시 불러 사용자에게 손 전달한다.',
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
const missing = [];
|
|
165
|
-
if (!a.agent) missing.push(`--agent — ${AGENTS.join(' · ')}`);
|
|
166
|
-
if (!a.model) missing.push('--model — 에이전트 모델 (사용자가 정한 것)');
|
|
167
|
-
if (missing.length) L.fail(`orca-dispatch-task: 빠진 입력이 있다.\n${missing.map((m) => `- ${m}`).join('\n')}`);
|
|
168
143
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
waited = orca(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle']);
|
|
185
|
-
} catch (e) {
|
|
186
|
-
L.fail(`orca-dispatch-task: ${label} 탭(${handle})을 열었지만 준비를 기다리지 못해 첫 지시문을 보내지 않았다 — ${e.message}\n${manual}`);
|
|
187
|
-
}
|
|
188
|
-
if (waited && waited.wait && waited.wait.satisfied === false) {
|
|
189
|
-
const why = waited.wait.reason || waited.wait.status || '준비 대기 미충족';
|
|
190
|
-
L.fail(`orca-dispatch-task: ${label} 탭(${handle})을 열었지만 준비(tui-idle)가 되지 않아 첫 지시문을 보내지 않았다 — ${why}\n${manual}`);
|
|
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
|
+
}
|
|
191
158
|
}
|
|
192
|
-
|
|
193
|
-
|
|
159
|
+
|
|
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;
|
|
194
176
|
try {
|
|
195
|
-
|
|
177
|
+
opened = host.open({ main, dir, label, agent: launch.agent, args: launch.args, command: shellLine(launch) });
|
|
196
178
|
} catch (e) {
|
|
197
|
-
L.fail(`
|
|
179
|
+
L.fail(`dispatch-task: ${label} 탭을 열지 못했다 — ${e.message}`);
|
|
198
180
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|
|
186
|
+
|
|
187
|
+
// 첫 지시문은 저장하지 않는다 — 다시 부를 때는 같은 칸 인자로 다시 만든다
|
|
188
|
+
const fresh = L.readState(main, num);
|
|
189
|
+
let sent;
|
|
206
190
|
try {
|
|
207
|
-
|
|
191
|
+
sent = host.sendFirst(tab.id, firstInstruction(fresh, brief));
|
|
208
192
|
} catch (e) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (onScreen(handle, text.split('\n')[0].split(' ').slice(0, 4).join(' '))) {
|
|
216
|
-
sendNote = '턴 시작이 관측되지 않아 Enter를 한 번 더 보냈고, 탭 화면에 첫 지시문이 보인다 — 탭에서 진행을 확인한다.';
|
|
217
|
-
} else if (send()) {
|
|
218
|
-
sendNote = '첫 전송은 턴 시작이 관측되지 않고 Enter 뒤 화면에도 없어 한 번 더 보냈다.';
|
|
219
|
-
} else {
|
|
220
|
-
L.fail(`orca-dispatch-task: ${label} 탭(${handle})에 첫 지시문을 두 번 보냈지만 턴 시작이 관측되지 않았다.\n${manual}`);
|
|
221
|
-
}
|
|
193
|
+
L.fail(
|
|
194
|
+
[
|
|
195
|
+
`dispatch-task: ${label} 탭 ${tab.id}에 첫 지시문을 보냈지만 제출을 확인하지 못했다 — ${e.message}.`,
|
|
196
|
+
`- read-task ${label}로 화면을 읽는다. 지시문으로 일을 시작했으면 그대로 둔다. 지시문이 입력창에 남아 있으면 사용자에게 그 탭에서 Enter를 부탁한다. 화면에 없으면 dispatch-task ${label}를 같은 칸 인자로 다시 부른다.`,
|
|
197
|
+
].join('\n'),
|
|
198
|
+
);
|
|
222
199
|
}
|
|
200
|
+
saveTab(main, num, { ...tab, sent: true });
|
|
223
201
|
return [
|
|
224
|
-
`${label} 태스크 세션을 띄웠다 — 탭 ${
|
|
225
|
-
`- 실행: ${
|
|
202
|
+
`${label} 태스크 세션을 띄웠다 — ${host.label} 탭 ${tab.id}`,
|
|
203
|
+
launch ? `- 실행: ${shellLine(launch)}` : '- 앞서 연 탭에 첫 지시만 보냈다.',
|
|
226
204
|
`- 폴더: ${dir}`,
|
|
227
205
|
`- 첫 지시문을 보냈다(시작: ${startSkill(fresh)}, 범위: ${fresh.range}).`,
|
|
228
|
-
...(
|
|
206
|
+
...(sent && sent.note ? [`- 알림: ${sent.note}`] : []),
|
|
229
207
|
'다음: wait-reports가 걸려 있지 않으면 백그라운드 셸로 걸어 둔다.',
|
|
230
208
|
].join('\n');
|
|
231
209
|
}
|
|
232
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
|
+
|
|
233
250
|
async function reportTask(ctx, a) {
|
|
234
251
|
const main = ctx.main;
|
|
235
252
|
L.requireInstalled(main);
|
|
@@ -262,18 +279,23 @@ function takeUnread(main) {
|
|
|
262
279
|
return lines;
|
|
263
280
|
}
|
|
264
281
|
|
|
265
|
-
//
|
|
266
|
-
function
|
|
282
|
+
// 살펴볼 태스크 탭 — 열린 태스크 중 탭 기록이 있고, 그 호스트가 살펴볼 이유를 알려 준 것과 읽지 못한 것
|
|
283
|
+
function checkTabs(main) {
|
|
267
284
|
const out = [];
|
|
268
285
|
for (const st of L.listStates(main)) {
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
}
|
|
271
294
|
try {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
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);
|
|
275
297
|
} catch (e) {
|
|
276
|
-
out.push(
|
|
298
|
+
out.push(`${head}탭을 읽지 못했다(${e.message})`);
|
|
277
299
|
}
|
|
278
300
|
}
|
|
279
301
|
return out;
|
|
@@ -295,8 +317,8 @@ async function waitReports(ctx) {
|
|
|
295
317
|
if (Date.now() >= until) break;
|
|
296
318
|
await new Promise((r) => setTimeout(r, Math.min(POLL_MS, Math.max(0, until - Date.now()))));
|
|
297
319
|
}
|
|
298
|
-
const
|
|
299
|
-
return withRearm(main, [`보고 없이 ${L.minutes(WAIT_MS)}이 지났다.`,
|
|
320
|
+
const tabs = checkTabs(main);
|
|
321
|
+
return withRearm(main, [`보고 없이 ${L.minutes(WAIT_MS)}이 지났다.`, `살펴볼 탭: ${tabs.length ? '' : '없음'}`.trimEnd(), ...tabs]);
|
|
300
322
|
}
|
|
301
323
|
|
|
302
|
-
module.exports = {
|
|
324
|
+
module.exports = { dispatchTask, sendTask, readTask, reportTask, waitReports, firstInstruction };
|