DEV Community

Cover image for osFoundry कैसे पहचानता है कि Claude Code किसी permission prompt पर रुका है?
BBT for BBT हिन्दी

Posted on

osFoundry कैसे पहचानता है कि Claude Code किसी permission prompt पर रुका है?

जब Claude Code 2.1.303 कोई command चलाने की अनुमति माँगता है, तो वह अपने terminal में यह लिखता है। ये bytes एक असली capture से हैं, जिसे osFoundry test fixture के रूप में रखता है (resources/agent-detection/fixtures/claude.blocked.raw):

\r\x1b[1C\x1b[1B\x1b[39mDo\x1b[5Gyou\x1b[9Gwant\x1b[14Gto\x1b[17Gproceed?
\r\x1b[1C\x1b[1B\x1b[38;2;177;185;249m❯\x1b[4G\x1b[38;2;153;153;153m1. \x1b[38;2;177;185;249mYes
\r\x1b[3C\x1b[1B\x1b[38;2;153;153;153m2. \x1b[39mYes,\x1b[12Gand\x1b[16Galways\x1b[23Gallow ...
Enter fullscreen mode Exit fullscreen mode

(पढ़ने में आसानी के लिए line breaks जोड़े गए हैं। असली capture में कोई line break नहीं है।)

इसमें दो चीज़ें ऐसी हैं जो सबसे सीधा detector तोड़ देती हैं। "Do" और "you" के बीच कोई space नहीं है। \x1b[5G cursor को column 5 पर ले जाता है, और "space" बस एक ऐसा cell है जिसे छुआ ही नहीं गया। और कहीं भी \n नहीं है: हर row \r और cursor-down \x1b[1B से शुरू होती है। fixture फ़ोल्डर के सभी 11 Claude Code captures (हर एक 2,099 से 7,672 bytes) में एक भी newline byte नहीं है।

osFoundry का डेस्कटॉप ऐप अपने Sessions board के हर terminal pane के लिए यह सवाल हल करता है: "क्या यह agent मेरा इंतज़ार कर रहा है?" नीचे बताया गया है कि वह क्या करता है, क्यों करता है, और एक छोटा Python supervisor है जो एक live PTY पर तीन आम तरीकों (prompt patterns, output-idle timers, process state) की तुलना करता है। इस Mac पर claude binary नहीं है, इसलिए live हिस्सा एक fake agent चलाता है जो Claude Code के prompt के शब्द और redraw करने का तरीका कॉपी करता है। fixture वाला हिस्सा असली captured bytes इस्तेमाल करता है।

osFoundry असल में किस चीज़ से match करता है?

हर agent के लिए एक TOML फ़ाइल। Claude Code की फ़ाइल apps/web/src-tauri/resources/agent-detection/claude.toml है, और यही फ़ाइल https://osfoundry.io/agent-detection/claude.toml पर भी मिलती है। इसके rules, क्रम से:

[[rules]]
state = "blocked"
any = [
  'Do you want to (proceed|continue|make this edit|create)',
  '❯\s*1\.\s*Yes',
  'Esc to cancel · Tab to amend',
  'Enter to select · ↑/↓ to navigate',
  'Exit plan mode\?',
]

[[rules]]
state = "working"
any = ['esc to interrupt']

[[rules]]
state = "idle"
any = ['\? for shortcuts', '(?:⏸|⏵⏵) [a-z ]+ mode on']
Enter fullscreen mode Exit fullscreen mode

जो rule सबसे पहले match होता है वही जीतता है (runtime/detect.rs में CompiledManifest::evaluate)। blocked सबसे ऊपर है, क्योंकि prompt खुला होने पर भी Claude Code के footer में "esc to interrupt" लिखा रहता है। अगर working पहले test होता, तो prompt कभी दिखता ही नहीं। जिस screen पर कोई rule match नहीं होता, उसे idle बताया जाता है, कभी blocked नहीं।

फ़ाइल के comments बताते हैं कि क्या हटाया गया और क्यों। Would you like to हटा दिया गया, क्योंकि यह Codex के prompt के शब्द हैं, और Claude के आम जवाबों में "Would you like me to…" बार-बार आता है।

runtime/mod.rs हर write पर classify करने के बजाय हर 700 ms (SWEEP_INTERVAL) पर सभी panes का एक sweep चलाता है। spinner एक सेकंड में कई बार redraw होता है, तो हर write पर classify करने का मतलब होता एक ही जवाब पाने के लिए सेकंड में सैकड़ों बार regex चलाना।

output के आख़िरी कुछ KB पर grep क्यों नहीं?

ऊपर वाले bytes की वजह से। यहाँ rules को Python में port करके हर Claude Code fixture पर दो तरीकों से चलाया गया है। पहला तरीका आख़िरी 8 KB लेता है, escape sequences हटाता है, और हर line में आख़िरी \r के बाद का text रखता है। दूसरा तरीका bytes को 32×120 के screen emulator (लगभग 100 lines, नीचे vt.py) में डालता है और rows वापस पढ़ता है।

fixture                            truth    last 8 KB, stripped    emulated 32x120 screen
claude.blocked.edit.raw            blocked  idle  ✗                blocked
claude.blocked.plan.raw            blocked  idle  ✗                blocked
claude.blocked.question.raw        blocked  idle  ✗                blocked
claude.blocked.raw                 blocked  idle  ✗                blocked
claude.idle.after.raw              idle     idle                   idle
claude.idle.auto.raw               idle     idle                   idle
claude.idle.edit.raw               idle     idle                   idle
claude.idle.question.raw           idle     idle                   idle
claude.idle.raw                    idle     idle                   idle
claude.working.auto.raw            working  idle  ✗                working
claude.working.raw                 working  idle  ✗                working
correct: tail 5/11, screen 11/11
Enter fullscreen mode Exit fullscreen mode

stripped tail सारे idle सही सिर्फ़ इसलिए बताता है क्योंकि idle fallback है। उसे prompt कभी दिखता ही नहीं। \n न होने से पूरा capture एक ही "line" बन जाता है, और आख़िरी \r के बाद का text रखने से आख़िरी टुकड़े के अलावा सब फेंक दिया जाता है। उस step के बिना भी शब्द आपस में चिपके हुए निकलते हैं: Doyouwanttoproceed?।

osFoundry का code भी यही कहता है। terminal_pty.rs हर session के लिए एक vt100::Parser रखता है, और sweep सिर्फ़ screen_text() को, यानी emulated screen को, classify करता है। उसके ऊपर रखी 200 rows की history जानबूझकर बाहर रखी गई है। वहाँ का comment कहता है कि जिस prompt का user जवाब दे चुका है और जो ऊपर scroll हो चुका है, वह "would report the agent as blocked forever"।

emulator का बहुत अच्छा होना ज़रूरी नहीं। यह वाला text, \r \n \b, cursor moves (A B C D E F G H f d) और erase (J K) संभालता है, और colours, modes और OSC titles को छोड़ देता है। यह हर cell में एक character रखता है, तो कोई wide glyph एक column खिसका सकता है। पूरी rows पर चलने वाले regex को इससे फ़र्क नहीं पड़ता। यह सभी 11 captures को rules के लिए काफ़ी ठीक render करता है:

CSI = re.compile(r"\x1b\[([0-9;?]*)([ -/]*)([@-~])")
OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")

class Screen:
    def feed(self, text):
        i, n = 0, len(text)
        while i < n:
            c = text[i]
            if c == "\x1b":
                m = OSC.match(text, i)
                if m:
                    i = m.end(); continue
                m = CSI.match(text, i)
                if m:
                    self._csi(m.group(1), m.group(3))   # move x/y or blank cells
                    i = m.end(); continue
                i += 2; continue                        # ESC 7, ESC =, ...
            if c == "\r":   self.x = 0
            elif c == "\n": self._lf()                  # scrolls at the bottom row
            elif c == "\b": self.x = max(0, self.x - 1)
            elif c >= " ":
                if self.x >= self.cols:
                    self.x = 0; self._lf()
                self.grid[self.y][self.x] = c
                self.x += 1
            i += 1
Enter fullscreen mode Exit fullscreen mode

output रुकने तक इंतज़ार क्यों नहीं?

ज़्यादातर लोग सबसे पहले यही detector लिखते हैं: अगर agent ने N सेकंड तक कुछ print नहीं किया, तो शायद उसे आपकी ज़रूरत है। इसे screen rules के सामने परखने के लिए एक supervisor agent को PTY में चलाता है और हर 50 ms पर log करता है कि हर detector क्या सोचता है।

agent है fake_agent.py, एक stand-in, क्योंकि इस Mac पर कोई claude binary installed नहीं है। यह वे आदतें कॉपी करता है जो मायने रखती हैं:

  • \r से 10 Hz पर redraw होने वाला spinner, हर frame 60 bytes, जिसमें "esc to interrupt" लिखा है
  • tool calls जो चलते समय कुछ print नहीं करते (0.5 से 6 s), जैसा कई CLIs करते हैं
  • Claude Code के शब्दों में permission prompt, जिसमें हर शब्द असली capture की तरह CSI n G से रखा जाता है, और जवाब मिलने पर Ink की तरह cursor-up और \x1b[J से मिटा दिया जाता है
  • हर turn के अंत में ? for shortcuts वाला idle prompt, कभी-कभी ऐसे जवाब के बाद जो "Would you like me to add a regression test?" पर ख़त्म होता है
  • prompt और idle दोनों state में process PTY पर readline() में blocked रहता है

यह अपनी असली state एक side file में लिखता है जिसे सिर्फ़ scorer पढ़ता है। एक bot user बनकर हर prompt का जवाब उसके आने के 3 से 8 s बाद देता है, और turn ख़त्म होने के 3 से 10 s बाद अगला task type करता है। supervisor का मुख्य हिस्सा, छोटा करके:

pid, fd = pty.fork()
if pid == 0:
    os.execvp(sys.executable, [sys.executable, "fake_agent.py"])
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 32, 120, 0, 0))

screen, dec, raw = Screen(32, 120), codecs.getincrementaldecoder("utf-8")("replace"), bytearray()
while True:
    r, _, _ = select.select([fd], [], [], 0.01)
    if r:
        chunk = os.read(fd, 65536)
        raw += chunk; del raw[:-65536]
        screen.feed(dec.decode(chunk))
        last_out = time.time()
    if time.time() >= next_tick:                       # every 50 ms
        log(screen=classify(screen.text())[0],         # the rules above, on the emulated screen
            tail=classify(strip_tail(bytes(raw)))[0],  # the same rules, on the stripped 8 KB tail
            question=any(l.rstrip().endswith("?") for l in screen.text().splitlines()),
            quiet=time.time() - last_out)              # for the idle timers
        next_tick += 0.05
Enter fullscreen mode Exit fullscreen mode

seed 7 के साथ एक run: 30 tasks, 10.6 मिनट, 25 permission prompts, और उन पर इंतज़ार में 144 s। Latency वह समय है जो prompt draw होने से लेकर detector के "needs you" कहने तक लगता है। Linger यह है कि जवाब देने के बाद भी detector कितनी देर तक यही कहता रहा। false alarms को episodes / कुल सेकंड के रूप में गिना गया है, और इस हिसाब से बाँटा गया है कि agent असल में क्या कर रहा था।

Detector Median latency Max Missed Linger False, agent काम कर रहा False, agent idle
Screen rules, हर 50 ms 0.03 s 0.06 s 0 / 25 0 s 0 0
Screen rules, 700 ms sweep 0.26 s 0.70 s 0 / 25 0.4 s 0 0
वही rules, stripped 8 KB tail पर 0.03 s 0.06 s 0 / 25 77.0 s 0 0
"?" पर ख़त्म होने वाली screen line 0.03 s 0.06 s 0 / 25 58.0 s 0 / 231 s 4 / 143 s
Output quiet ≥ 0.5 s 0.52 s 0.56 s 0 / 25 0 s 31 / 78 s 30 / 168 s
Output quiet ≥ 1 s 1.02 s 1.06 s 0 / 25 0 s 28 / 63 s 30 / 153 s
Output quiet ≥ 2 s 2.03 s 2.06 s 0 / 25 0 s 21 / 38 s 30 / 123 s
Output quiet ≥ 3 s 3.02 s 3.06 s 0 / 25 0 s 17 / 19 s 30 / 93 s
Output quiet ≥ 5 s 5.02 s 5.05 s 9 / 25 0 s 3 / 1 s 18 / 46 s
Output quiet ≥ 8 s – – 25 / 25 0 s 0 6 / 7 s

idle timer के लिए कोई भी threshold काम नहीं करता। परिभाषा से ही उसकी latency उसका threshold है। 5 s से कम पर यह हर silent tool call पर fire होता है। 3 s तक के हर threshold पर यह सभी 30 turn ends पर fire होता है, क्योंकि "काम ख़त्म, अगले task का इंतज़ार" उतना ही शांत है जितना "permission का इंतज़ार"। 5 s और उससे ऊपर यह ऐसे prompts चूकने लगता है जिनका जवाब किसी ने समय पर दे दिया। 8 s वाली row सभी 25 सिर्फ़ इसलिए चूकती है क्योंकि bot कभी 8 s से ज़्यादा इंतज़ार नहीं करता, लेकिन जवाब देने का समय कुछ भी हो, pattern वही रहता है: detector या तो शोर करता है या देर से आता है।

"?" पर ख़त्म होने वाली screen line हर prompt पकड़ लेती है, और असली captures पर यह perfect दिखती: सिर्फ़ चार blocked fixtures में ऐसी line है। live run में यह agent के अपने सवाल, "Would you like me to add a regression test?", पर fire होती है, और वह line अगले task तक screen पर बनी रहती है। claude.toml ने Would you like to इसी वजह से हटाया था।

stripped tail इस run में prompts इसलिए पकड़ पाता है क्योंकि fake agent ❯ 1. Yes असली spaces के साथ लिखता है। फिर जवाब के बाद भी यह औसतन 77 s तक prompt report करता रहता है, क्योंकि मिटाए गए prompt के bytes आख़िरी 8 KB में तब तक रहते हैं जब तक लगभग 8 KB नया output उन्हें बाहर न धकेल दे। spinner के 600 bytes प्रति सेकंड और बीच के silent हिस्सों के साथ इसमें काफ़ी समय लगता है। emulated screen cursor-up और erase आते ही prompt भूल जाती है।

700 ms sweep की कीमत वही है जिसकी उम्मीद होगी: एक interval तक की latency (median 0.26 s, max 0.70 s) और एक interval तक का linger। जिस badge को कोई इंसान देखता है, उसके लिए यह ठीक है।

क्या OS नहीं बता सकता कि process stdin पढ़ रहा है?

macOS पर नहीं। supervisor ने agent के लिए सेकंड में दो बार ps -o stat=,time= sample किया:

working  samples  603  state S  99.5%  R  0.5%  CPU 0.04% of a core
idle     samples  357  state S 100.0%  R  0.0%  CPU 0.02% of a core
blocked  samples  280  state S 100.0%  R  0.0%  CPU 0.02% of a core
Enter fullscreen mode Exit fullscreen mode

अपने terminal पर read() में blocked process और spinner frames के बीच सो रहा process, दोनों S हैं। wchan column, जो कुछ systems पर kernel wait channel का नाम बताता है, दोनों के लिए - print करता है। CPU time से भी फ़र्क नहीं पता चलता, क्योंकि spinner का ख़र्च लगभग शून्य है। Claude Code जैसा Node-based CLI काम करते हुए भी और न करते हुए भी अपने event loop में इंतज़ार करता है, तो उसका state letter और भी कम बताएगा।

process table जिस सवाल का जवाब देती है, वह यह है कि terminal किस program के पास है। osFoundry runtime/signals.rs में tty का foreground process group (MasterPty::process_group_leader, यानी tcgetpgrp) पढ़ता है। इससे उसे पता चलता है कि pane में claude चल रहा है, भले ही उसे किसी सामान्य shell में type किया गया हो। इससे यह भी पता चलता है कि agent कब exit हो गया और shell वापस सामने आ गया, जबकि agent का आख़िरी prompt अभी भी screen पर है।

क्या screen पढ़ने से बेहतर कोई तरीका है?

हाँ: agent से ख़ुद बताने को कहिए। runtime/signals.rs तीन sources को तौलता है। जब osFoundry ख़ुद Claude Code launch करता है, तो runtime/integrations.rs --settings के ज़रिए hooks जोड़ता है। यह flag सिर्फ़ इसी एक launch के लिए settings जोड़ता है, और user के अपने hooks भी चलते रहते हैं।

{"hooks": {
  "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "osf-pane report working >/dev/null 2>&1 || true"}]}],
  "PreToolUse":       [{"matcher": "*", "hooks": [{"type": "command", "command": "osf-pane report working >/dev/null 2>&1 || true"}]}],
  "Notification":     [{"hooks": [{"type": "command", "command": "osf-pane notify >/dev/null 2>&1 || true"}]}],
  "Stop":             [{"hooks": [{"type": "command", "command": "osf-pane report done >/dev/null 2>&1 || true"}]}]
}}
Enter fullscreen mode Exit fullscreen mode

Claude Code Notification permission prompt के लिए भी fire करता है और input का एक मिनट इंतज़ार करने के बाद भी। इसलिए osf-pane notify payload पढ़ता है और permission को blocked पर और बाक़ी सबको idle पर map करता है। ACP पर (claude.toml में declare किए गए claude-agent-acp adapter के ज़रिए), session/request_permission request का मतलब है कि agent सीधे permission माँग रहा है, यह अनुमान नहीं बल्कि तथ्य है।

दिलचस्प हिस्सा signals::combine का क्रम है। screen से पढ़ा गया blocked हमेशा टिका रहता है। उसके बाद agent की ताज़ा report आती है, फिर "shell वापस सामने है", फिर बाक़ी screen rules। report पुरानी हो सकती है या मौजूद ही न हो, जैसे जब कोई hook चुपचाप fail हो जाए। screen पर दिखता prompt एक सटीक rule है और वही एक alert है जिसे कभी दबाया नहीं जाना चाहिए।

इससे दो छोटे फ़ैसले निकलते हैं। notifications पर हर session और state के लिए 30 s का cooldown है (runtime/notify.rs), क्योंकि redraw होता prompt blocked → working → blocked के बीच झूल सकता है। और कोई भी tool किसी pane में type करे, उससे पहले runtime/tools.rs पिछले sweep पर भरोसा करने के बजाय नया classify_session चलाता है। जो pane blocked है वह सिर्फ़ runtime_answer लेता है, जिसे user confirm करता है, कभी सामान्य runtime_send नहीं।

अगर आप इसे ख़ुद जोड़ना चाहें

  • byte tail नहीं, emulated screen को classify करें। Claude Code शब्दों को cursor moves से रखता है और newlines नहीं भेजता, और जवाब दिया जा चुका prompt कई मिनट तक bytes में बना रहता है।
  • "कोई सवाल" या "N सेकंड से शांत" के बजाय prompt के अपने शब्द और footer (Esc to cancel · Tab to amend) match करें। बाहर से देखने पर turn ends और silent tool calls बिल्कुल prompt जैसे लगते हैं।
  • blocked rules को working rules से ऊपर रखें, और "कोई rule match नहीं हुआ" का मतलब idle रखें। ग़लत "needs you" की कीमत देर से आए "needs you" से ज़्यादा है।
  • rules को एक data file में रखें जिसे आप बिना release के update कर सकें, और उन्हें चलाने वाले program से अलग रखें।
  • process table का इस्तेमाल identity और exit के लिए करें, इंतज़ार पहचानने के लिए नहीं। agent ख़ुद launch करें तो hooks या ACP इस्तेमाल करें, लेकिन screen पर दिखते prompt को report से ऊपर रखें।
  • किसी इंसान के लिए कुछ सौ milliseconds का sweep काफ़ी है। pane में type करने से ठीक पहले दोबारा check करें।

code चार छोटी फ़ाइलों में है, vt.py, rules.py, fake_agent.py और supervisor.py, साथ में tables के लिए fixtures.py और analyze.py। नतीजा जिस screen पर दिखता है, वह osFoundry डेस्कटॉप ऐप में agent sessions का Sessions board है।

Top comments (0)